diff --git a/agent.go b/agent.go
index 380206e..ab4c6e3 100644
--- a/agent.go
+++ b/agent.go
@@ -13,6 +13,7 @@ import (
"io"
"net"
"net/http"
+ "net/url"
"os"
"os/exec"
"path/filepath"
@@ -40,16 +41,18 @@ type agentConfig struct {
}
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"`
+ 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"`
+ Compose bool `json:"compose"`
+ UpdateAvailable bool `json:"updateAvailable"`
}
type AgentInterface struct {
@@ -913,14 +916,27 @@ func collectDockerContainers() ([]AgentContainer, string) {
inspect, inspectErr := client.Get("http://docker/containers/" + v.ID + "/json")
if inspectErr == nil {
var detail struct {
+ Image string
RestartCount int64
State struct{ Health *struct{ Status string } }
+ Config struct {
+ Labels map[string]string
+ }
}
if json.NewDecoder(inspect.Body).Decode(&detail) == nil {
item.Restarts = detail.RestartCount
if detail.State.Health != nil {
item.Health = detail.State.Health.Status
}
+ item.Compose = detail.Config.Labels["com.docker.compose.service"] != ""
+ imageResponse, imageErr := client.Get("http://docker/images/" + url.PathEscape(v.Image) + "/json")
+ if imageErr == nil {
+ var imageDetail struct{ ID string }
+ if json.NewDecoder(imageResponse.Body).Decode(&imageDetail) == nil && detail.Image != "" && imageDetail.ID != "" {
+ item.UpdateAvailable = detail.Image != imageDetail.ID
+ }
+ imageResponse.Body.Close()
+ }
}
inspect.Body.Close()
}
diff --git a/agent_actions.go b/agent_actions.go
index 4a611fe..561be56 100644
--- a/agent_actions.go
+++ b/agent_actions.go
@@ -8,7 +8,9 @@ import (
"io"
"net"
"net/http"
+ "os"
"os/exec"
+ "path/filepath"
"regexp"
"strings"
"time"
@@ -61,7 +63,7 @@ func (s *Store) QueueAgentAction(agentID, action, target string) (AgentAction, e
}
}
}
- case "docker-restart":
+ case "docker-restart", "docker-check", "docker-update":
if safeContainerName.MatchString(target) {
for _, container := range report.Containers {
if container.ID == target || container.Name == target {
@@ -70,7 +72,7 @@ func (s *Store) QueueAgentAction(agentID, action, target string) (AgentAction, e
}
}
}
- case "updates-refresh":
+ case "updates-refresh", "system-update":
valid = target == ""
}
if !valid {
@@ -91,7 +93,9 @@ func (s *Store) DispatchAgentActions(agentID string) ([]AgentCommand, error) {
return nil, err
}
defer tx.Rollback()
- _, _ = tx.Exec(`UPDATE agent_actions SET status='queued',started_at=NULL WHERE agent_id=? AND status='running' AND started_at`, agentID, time.Now().Add(-5*time.Minute).Unix())
+ // Обновление пакетов и Docker-образов может быть долгим. Не выдаём одну
+ // команду повторно, пока предыдущий запуск ещё имеет шанс завершиться.
+ _, _ = tx.Exec(`UPDATE agent_actions SET status='queued',started_at=NULL WHERE agent_id=? AND status='running' AND started_at`, agentID, time.Now().Add(-45*time.Minute).Unix())
rows, err := tx.Query(`SELECT id,action,target FROM agent_actions WHERE agent_id=? AND status='queued' ORDER BY id LIMIT 3`, agentID)
if err != nil {
return nil, err
@@ -160,6 +164,12 @@ func executeAgentCommand(command AgentCommand) AgentActionResult {
output = runAgentCombinedCommand(15*time.Second, "systemctl", "reset-failed", command.Target)
case "docker-restart":
output = restartAgentContainer(command.Target)
+ case "docker-update":
+ output = updateAgentContainer(command.Target, true)
+ case "docker-check":
+ output = updateAgentContainer(command.Target, false)
+ case "system-update":
+ output = updateAgentSystem()
case "updates-refresh":
guestInventory.Lock()
guestInventory.value.CollectedAt = time.Time{}
@@ -179,6 +189,77 @@ func executeAgentCommand(command AgentCommand) AgentActionResult {
return result
}
+func updateAgentSystem() string {
+ if output := runAgentCombinedCommand(3*time.Minute, "apt-get", "update"); strings.HasPrefix(output, "ERROR: ") {
+ return output
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
+ defer cancel()
+ command := exec.CommandContext(ctx, "apt-get", "-y", "upgrade")
+ command.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
+ output, err := command.CombinedOutput()
+ message := strings.TrimSpace(string(output))
+ if len(message) > 3500 {
+ message = message[len(message)-3500:]
+ }
+ if err != nil {
+ return "ERROR: " + err.Error() + ": " + message
+ }
+ guestInventory.Lock()
+ guestInventory.value.CollectedAt = time.Time{}
+ guestInventory.Unlock()
+ return firstNonEmpty(message, "Обновления системы установлены")
+}
+
+func updateAgentContainer(target string, recreate bool) string {
+ transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock")
+ }}
+ defer transport.CloseIdleConnections()
+ client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
+ response, err := client.Get("http://docker/containers/" + target + "/json")
+ if err != nil {
+ return "ERROR: " + err.Error()
+ }
+ defer response.Body.Close()
+ var detail struct {
+ Config struct {
+ Labels map[string]string `json:"Labels"`
+ } `json:"Config"`
+ }
+ if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&detail) != nil {
+ return "ERROR: не удалось получить Compose-конфигурацию контейнера"
+ }
+ labels := detail.Config.Labels
+ service := labels["com.docker.compose.service"]
+ workdir := labels["com.docker.compose.project.working_dir"]
+ files := strings.Split(labels["com.docker.compose.project.config_files"], ",")
+ if service == "" || workdir == "" || len(files) == 0 || !filepath.IsAbs(workdir) || !filepath.IsAbs(files[0]) {
+ return "ERROR: безопасное обновление доступно только для Docker Compose"
+ }
+ if _, err = exec.LookPath("docker"); err != nil {
+ return "ERROR: Docker CLI не найден"
+ }
+ commands := [][]string{{"compose", "-f", files[0], "pull", service}}
+ if recreate {
+ commands = append(commands, []string{"compose", "-f", files[0], "up", "-d", "--no-deps", service})
+ }
+ for _, args := range commands {
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
+ command := exec.CommandContext(ctx, "docker", args...)
+ command.Dir = workdir
+ output, commandErr := command.CombinedOutput()
+ cancel()
+ if commandErr != nil {
+ return "ERROR: " + commandErr.Error() + ": " + strings.TrimSpace(string(output))
+ }
+ }
+ if recreate {
+ return "Образ загружен, Compose-контейнер пересоздан"
+ }
+ return "Проверка завершена: образ загружен; если digest изменился, карточка покажет доступное обновление"
+}
+
func runAgentCombinedCommand(timeout time.Duration, name string, args ...string) string {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
diff --git a/agent_actions_test.go b/agent_actions_test.go
index 9b4eaa6..c7eb45b 100644
--- a/agent_actions_test.go
+++ b/agent_actions_test.go
@@ -20,17 +20,23 @@ func TestAgentActionAllowlistAndLifecycle(t *testing.T) {
if _, err = store.QueueAgentAction("a", "systemd-restart", "../../bad.service"); err == nil {
t.Fatal("unsafe unit accepted")
}
+ if _, err = store.QueueAgentAction("a", "docker-update", "gitea"); err != nil {
+ t.Fatalf("known Docker container rejected: %v", err)
+ }
+ if _, err = store.QueueAgentAction("a", "system-update", ""); err != nil {
+ t.Fatalf("system update rejected: %v", err)
+ }
action, err := store.QueueAgentAction("a", "systemd-restart", "gitea.service")
if err != nil {
t.Fatal(err)
}
commands, err := store.DispatchAgentActions("a")
- if err != nil || len(commands) != 1 || commands[0].ID != action.ID {
+ if err != nil || len(commands) != 3 || commands[2].ID != action.ID {
t.Fatalf("commands=%#v err=%v", commands, err)
}
store.CompleteAgentActions("a", []AgentActionResult{{ID: action.ID, Success: true, Message: "ok"}})
items, err := store.AgentActions(10)
- if err != nil || len(items) != 1 || items[0].Status != "completed" {
+ if err != nil || len(items) != 3 || items[0].Status != "completed" {
t.Fatalf("items=%#v err=%v", items, err)
}
}
diff --git a/alerts.go b/alerts.go
index 647429b..5287666 100644
--- a/alerts.go
+++ b/alerts.go
@@ -263,10 +263,10 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler
}
var networkErrors uint64
for _, iface := range metrics.Network.Interfaces {
- networkErrors += iface.ReceiveErrors + iface.TransmitErrors + iface.ReceiveDrops + iface.TransmitDrops
+ networkErrors += iface.NewErrors + iface.NewDrops
}
if networkErrors > 0 {
- add("network-errors", "warning", "Сеть", "Сетевые ошибки и потери пакетов", fmt.Sprintf("Суммарный счётчик интерфейсов: %d.", networkErrors))
+ add("network-errors", "warning", "Сеть", "Появились новые сетевые ошибки или потери", fmt.Sprintf("За последний интервал добавилось: %d. Старые счётчики не создают активный алерт.", networkErrors))
}
if !metrics.Network.Health.CheckedAt.IsZero() && !metrics.Network.Health.GatewayUp {
add("network-gateway", "critical", "Сеть", "Недоступен локальный шлюз", firstNonEmpty(metrics.Network.Health.Gateway, "Default gateway не определён."))
diff --git a/cpu.go b/cpu.go
index da334da..afd1d92 100644
--- a/cpu.go
+++ b/cpu.go
@@ -33,14 +33,15 @@ type CPUStatic struct {
type CPUMetrics struct {
CPUStatic
- UsagePercent float64 `json:"usagePercent"`
- FrequencyMHz float64 `json:"frequencyMhz"`
- Load1 float64 `json:"load1"`
- Load5 float64 `json:"load5"`
- Load15 float64 `json:"load15"`
- Uptime uint64 `json:"uptimeSeconds"`
- Temperatures []Temperature `json:"temperatures"`
- CollectedAt time.Time `json:"collectedAt"`
+ UsagePercent float64 `json:"usagePercent"`
+ FrequencyMHz float64 `json:"frequencyMhz"`
+ Load1 float64 `json:"load1"`
+ Load5 float64 `json:"load5"`
+ Load15 float64 `json:"load15"`
+ Uptime uint64 `json:"uptimeSeconds"`
+ Temperatures []Temperature `json:"temperatures"`
+ TopProcesses []AgentProcess `json:"topProcesses"`
+ CollectedAt time.Time `json:"collectedAt"`
}
type Temperature struct {
@@ -92,7 +93,7 @@ func (c *cpuCollector) collect() (CPUMetrics, error) {
return CPUMetrics{
CPUStatic: c.static, UsagePercent: usage, FrequencyMHz: frequency,
Load1: load1, Load5: load5, Load15: load15, Uptime: uptime,
- Temperatures: readTemperatures(), CollectedAt: time.Now(),
+ Temperatures: readTemperatures(), TopProcesses: collectAgentProcesses(), CollectedAt: time.Now(),
}, nil
}
diff --git a/maintenance_window.go b/maintenance_window.go
index 635ea84..5285f77 100644
--- a/maintenance_window.go
+++ b/maintenance_window.go
@@ -17,6 +17,51 @@ type MaintenanceWindow struct {
AllAlerts bool `json:"allAlerts"`
ServiceIDs []int64 `json:"serviceIds"`
VMIDs []int `json:"vmids"`
+ Automatic bool `json:"automatic"`
+ Source string `json:"source,omitempty"`
+}
+
+func automaticBackupMaintenance(activity ProxmoxActivity, links []ServiceLink, now time.Time) MaintenanceWindow {
+ vmids := []int{}
+ backupRunning := false
+ for _, task := range activity.Tasks {
+ if task.Type != "vzdump" || task.EndTime > 0 || (task.Status != "" && !strings.EqualFold(task.Status, "running")) {
+ continue
+ }
+ backupRunning = true
+ if vmid := backupTaskVMID(task); vmid > 0 {
+ vmids = append(vmids, vmid)
+ }
+ }
+ vmids = uniqueInt(vmids)
+ if !backupRunning {
+ return MaintenanceWindow{}
+ }
+ selected := map[int]bool{}
+ for _, vmid := range vmids {
+ selected[vmid] = true
+ }
+ serviceIDs := []int64{}
+ for _, link := range links {
+ if selected[link.VMID] {
+ serviceIDs = append(serviceIDs, link.ServiceID)
+ }
+ }
+ return MaintenanceWindow{Enabled: true, Active: true, StartedAt: now.Unix(), EndsAt: now.Add(2 * time.Minute).Unix(), Reason: "Автоматически: выполняется backup VM/LXC", VMIDs: vmids, ServiceIDs: uniqueInt64(serviceIDs), AllAlerts: len(vmids) == 0, Automatic: true, Source: "backup"}
+}
+
+func backupTaskVMID(task ProxmoxTask) int {
+ if vmid, err := strconv.Atoi(strings.TrimSpace(task.ID)); err == nil && vmid > 0 {
+ return vmid
+ }
+ parts := strings.Split(task.UPID, ":")
+ for i, part := range parts {
+ if part == "vzdump" && i+1 < len(parts) {
+ vmid, _ := strconv.Atoi(parts[i+1])
+ return vmid
+ }
+ }
+ return 0
}
func (s *Store) MaintenanceWindow() MaintenanceWindow {
diff --git a/maintenance_window_test.go b/maintenance_window_test.go
index dc46747..4b04dcd 100644
--- a/maintenance_window_test.go
+++ b/maintenance_window_test.go
@@ -38,6 +38,21 @@ func TestMaintenanceExpiresAutomatically(t *testing.T) {
}
}
+func TestRunningBackupCreatesScopedAutomaticMaintenance(t *testing.T) {
+ now := time.Now()
+ window := automaticBackupMaintenance(ProxmoxActivity{Tasks: []ProxmoxTask{{Type: "vzdump", ID: "105"}, {Type: "vzdump", ID: "106", EndTime: now.Unix()}}}, []ServiceLink{{ServiceID: 7, VMID: 105}, {ServiceID: 8, VMID: 999}}, now)
+ if !window.Active || !window.Automatic || window.Source != "backup" || len(window.VMIDs) != 1 || window.VMIDs[0] != 105 || len(window.ServiceIDs) != 1 || window.ServiceIDs[0] != 7 {
+ t.Fatalf("неверное автоматическое окно: %+v", window)
+ }
+}
+
+func TestRunningBackupWithoutVMIDSuppressesAllAlerts(t *testing.T) {
+ window := automaticBackupMaintenance(ProxmoxActivity{Tasks: []ProxmoxTask{{Type: "vzdump", UPID: "unknown"}}}, nil, time.Now())
+ if !window.Active || !window.AllAlerts {
+ t.Fatalf("backup с неизвестной целью должен безопасно подавить алерты: %+v", window)
+ }
+}
+
func TestPlannedAlertOnlyAppearsInHistory(t *testing.T) {
store, err := openStore(":memory:")
if err != nil {
diff --git a/network.go b/network.go
index 8bb00f1..e03f237 100644
--- a/network.go
+++ b/network.go
@@ -3,6 +3,7 @@ package main
import (
"bufio"
"context"
+ "fmt"
"io"
"net"
"net/http"
@@ -36,6 +37,9 @@ type NetworkHealth struct {
ExternalIPChanged bool `json:"externalIpChanged"`
DisappearedInterfaces []string `json:"disappearedInterfaces"`
CheckedAt time.Time `json:"checkedAt"`
+ Summary string `json:"summary"`
+ LikelyCause string `json:"likelyCause"`
+ RecommendedAction string `json:"recommendedAction"`
}
type NetworkInterface struct {
@@ -46,6 +50,8 @@ type NetworkInterface struct {
TransmitErrors uint64 `json:"transmitErrors"`
ReceiveDrops uint64 `json:"receiveDrops"`
TransmitDrops uint64 `json:"transmitDrops"`
+ NewErrors uint64 `json:"newErrors"`
+ NewDrops uint64 `json:"newDrops"`
ReceivePerSec float64 `json:"receivePerSec"`
TransmitPerSec float64 `json:"transmitPerSec"`
VMID int `json:"vmid,omitempty"`
@@ -113,6 +119,12 @@ func (c *networkCollector) collect() (NetworkMetrics, error) {
if old, ok := c.previousInterfaces[v.Name]; ok && elapsed > 0 && v.ReceiveBytes >= old.ReceiveBytes && v.TransmitBytes >= old.TransmitBytes {
v.ReceivePerSec = float64(v.ReceiveBytes-old.ReceiveBytes) / elapsed
v.TransmitPerSec = float64(v.TransmitBytes-old.TransmitBytes) / elapsed
+ if v.ReceiveErrors >= old.ReceiveErrors && v.TransmitErrors >= old.TransmitErrors {
+ v.NewErrors = v.ReceiveErrors - old.ReceiveErrors + v.TransmitErrors - old.TransmitErrors
+ }
+ if v.ReceiveDrops >= old.ReceiveDrops && v.TransmitDrops >= old.TransmitDrops {
+ v.NewDrops = v.ReceiveDrops - old.ReceiveDrops + v.TransmitDrops - old.TransmitDrops
+ }
}
}
c.previousInterfaces = map[string]NetworkInterface{}
@@ -138,12 +150,41 @@ func (c *networkCollector) collect() (NetworkMetrics, error) {
}
}
c.knownInterfaces = currentNames
+ explainNetworkHealth(&c.health, details)
return NetworkMetrics{
Interface: strings.Join(interfaceNames, ", "), Interfaces: details, ReceiveBytes: current.received,
TransmitBytes: current.sent, ReceivePerSec: receivedRate, TransmitPerSec: sentRate, Health: c.health,
}, nil
}
+func explainNetworkHealth(health *NetworkHealth, interfaces []NetworkInterface) {
+ health.Summary, health.LikelyCause, health.RecommendedAction = "Сеть работает штатно", "Проблем не обнаружено", "Действий не требуется"
+ switch {
+ case health.Gateway == "":
+ health.Summary, health.LikelyCause, health.RecommendedAction = "Не найден маршрут в локальную сеть", "На хосте отсутствует default gateway", "Проверьте адрес шлюза и конфигурацию bridge/VLAN"
+ case !health.GatewayUp:
+ health.Summary, health.LikelyCause, health.RecommendedAction = "Хост не видит локальный шлюз", "Проблема между Proxmox и роутером: линк, bridge, bond или VLAN", "Проверьте кабель, carrier и настройки vmbr/bond/VLAN"
+ case health.PacketLoss > 0:
+ health.Summary = fmt.Sprintf("Нестабильная локальная сеть: потеряно %.1f%% пакетов", health.PacketLoss)
+ health.LikelyCause, health.RecommendedAction = "Перегрузка или ошибки линка между Proxmox и шлюзом", "Проверьте errors/drops, кабель, порт коммутатора и duplex"
+ case !health.DNSUp && health.InternetUp:
+ health.Summary, health.LikelyCause, health.RecommendedAction = "Интернет доступен, но DNS не отвечает", "Проблема DNS-сервера или /etc/resolv.conf", "Проверьте настроенные DNS и попробуйте разрешить домен вручную"
+ case health.DNSUp && !health.InternetUp:
+ health.Summary, health.LikelyCause, health.RecommendedAction = "Локальная сеть и DNS работают, но внешняя проверка недоступна", "Проблема выше роутера, блокировка HTTPS или недоступность провайдера", "Проверьте маршрут наружу, firewall и доступ к внешним адресам"
+ }
+ for _, v := range interfaces {
+ if v.Kind == "Physical" && (!v.Carrier || v.State == "down") {
+ health.Summary, health.LikelyCause, health.RecommendedAction = v.Name+": физический линк не поднят", "Нет carrier либо интерфейс административно выключен", "Проверьте кабель, порт коммутатора и состояние интерфейса"
+ return
+ }
+ if v.NewErrors+v.NewDrops > 0 {
+ health.Summary = fmt.Sprintf("%s: за последний интервал появилось %d сетевых ошибок/потерь", v.Name, v.NewErrors+v.NewDrops)
+ health.LikelyCause, health.RecommendedAction = "Счётчики интерфейса сейчас растут из-за перегрузки, кабеля, MTU или duplex", "Проверьте скорость и duplex, кабель, порт коммутатора и MTU на обоих концах"
+ return
+ }
+ }
+}
+
func decorateInterface(v *NetworkInterface) {
base := filepath.Join("/sys/class/net", v.Name)
v.State = readTrimmed(filepath.Join(base, "operstate"))
diff --git a/network_test.go b/network_test.go
index ac5d93b..30ca1be 100644
--- a/network_test.go
+++ b/network_test.go
@@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
+ "strings"
"testing"
)
@@ -33,6 +34,26 @@ func TestReadNetworkSnapshot(t *testing.T) {
}
}
+func TestExplainNetworkHealthDistinguishesDNSFailure(t *testing.T) {
+ health := NetworkHealth{Gateway: "192.168.0.1", GatewayUp: true, InternetUp: true, DNSUp: false}
+ explainNetworkHealth(&health, nil)
+ if !strings.Contains(health.Summary, "DNS") || health.RecommendedAction == "" {
+ t.Fatalf("непонятная диагностика: %+v", health)
+ }
+}
+
+func TestExplainNetworkHealthIgnoresOldInterfaceCounters(t *testing.T) {
+ health := NetworkHealth{Gateway: "192.168.0.1", GatewayUp: true, InternetUp: true, DNSUp: true}
+ explainNetworkHealth(&health, []NetworkInterface{{Name: "eno1", Carrier: true, State: "up", ReceiveDrops: 42}})
+ if health.Summary != "Сеть работает штатно" {
+ t.Fatalf("старый накопленный счётчик вызвал текущую тревогу: %+v", health)
+ }
+ explainNetworkHealth(&health, []NetworkInterface{{Name: "eno1", Carrier: true, State: "up", ReceiveDrops: 43, NewDrops: 1}})
+ if !strings.Contains(health.Summary, "последний интервал") {
+ t.Fatalf("рост счётчика не объяснён: %+v", health)
+ }
+}
+
func TestInterfaceVMID(t *testing.T) {
for name, want := range map[string]int{"tap100i0": 100, "veth108i0": 108, "fwbr113i0": 113, "vmbr0": 0, "enp6s0": 0} {
if got := interfaceVMID(name); got != want {
diff --git a/web.go b/web.go
index 5ab3231..5ca4e91 100644
--- a/web.go
+++ b/web.go
@@ -70,6 +70,9 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle
metrics.ServiceLinks, _ = store.ServiceLinks()
decorateNetworkInterfaces(&metrics.Network, metrics.Guests)
metrics.MaintenanceWindow = store.MaintenanceWindow()
+ if !metrics.MaintenanceWindow.Active {
+ metrics.MaintenanceWindow = automaticBackupMaintenance(metrics.Activity, metrics.ServiceLinks, time.Now())
+ }
allAlerts := groupLinkedAlerts(metrics, correlateAlerts(metrics, evaluateAlerts(metrics, thresholds)))
activeAlerts, plannedAlerts := splitMaintenanceAlerts(allAlerts, metrics.MaintenanceWindow)
metrics.Alerts, err = store.SyncAlertsWithMaintenance(activeAlerts, plannedAlerts, metrics.MaintenanceWindow.Reason)
@@ -696,12 +699,16 @@ footer{color:#657581;font-size:12px;margin-top:17px}
+ Обзор Топ процессов
+
Загрузка —
Сокеты —
Физические ядра —
Логические потоки —
Средняя частота —
Uptime —
Load · 1 мин —
Load · 5 мин —
Load · 15 мин —
Температурные датчики Поиск датчиков…
+
+ Самые нагруженные процессы хоста Получение списка процессов…
CPU и температура Последние 7 дней
@@ -732,6 +739,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Получено с запуска —
Передано с запуска —
Интерфейсы —
Доступность инфраструктуры Шлюз —
DNS —
Интернет —
Внешний IP —
+ Понятная диагностика Проверка сети…
Вероятная причина: —
Что проверить: —
Входящий и исходящий трафик Последние 7 дней
@@ -897,8 +905,8 @@ function endpointHTML(v){const tls=v.tlsDays==null?'Нет TLS':v.tlsDays<0?'С
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=>''+safe(v.name)+'
'+(v.endpoints||[]).length+' '+((v.endpoints||[]).length===1?'адрес':'адреса')+' проверяются независимо
'+(v.up?'Всё доступно':'Есть проблема')+' '+(v.endpoints||[]).map(endpointHTML).join('')+'
Открыть сервис ↗
').join(''):'Добавьте первую проверку в настройках. '}
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=>' '+safe(v.name)+' ').join(''):'Сервисы не добавлены. ';el('maintenanceGuests').innerHTML=maintenanceGuests.length?maintenanceGuests.map(v=>' '+safe(v.name||('VMID '+v.vmid))+' · '+(v.type==='qemu'?'VM':'LXC')+' ').join(''):'VM/LXC не найдены. ';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 ''+safe(g.name)+'
'+(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(', '))+'
'+(down?'Нет линка':errors?'Есть потери':'Без ошибок')+' Сейчас ↓ '+rate(rx)+'
Сейчас ↑ '+rate(tx)+'
Получено '+size(primary.reduce((a,v)=>a+v.receiveBytes,0))+'
Передано '+size(primary.reduce((a,v)=>a+v.transmitBytes,0))+'
Ошибки / drops '+errors+'
'}).join('')}
+function applyMaintenanceState(window){maintenanceWindow=window||{};const active=!!maintenanceWindow.active,automatic=!!maintenanceWindow.automatic;el('maintenanceBanner').hidden=!active;el('maintenanceBannerReason').textContent=(automatic?'Автоматический режим · ':'')+(maintenanceWindow.reason||'');el('maintenanceBannerEnds').textContent=active?(automatic?'до завершения backup':'до '+new Date(maintenanceWindow.endsAt*1000).toLocaleString('ru-RU')):'';el('stopMaintenance').disabled=!active||automatic;el('maintenanceStatus').textContent=active?(automatic?'Автоматически включён на время backup выбранных VM/LXC и связанных сервисов.':'Активен до '+new Date(maintenanceWindow.endsAt*1000).toLocaleString('ru-RU')+' · '+maintenanceWindow.reason):'Режим выключен.';if(active&&!automatic){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),total=g.items.reduce((a,v)=>a+v.receiveErrors+v.transmitErrors+v.receiveDrops+v.transmitDrops,0),fresh=g.items.reduce((a,v)=>a+(v.newErrors||0)+(v.newDrops||0),0),down=primary.some(v=>v.kind==='Physical'&&!v.carrier&&(v.master||v.receiveBytes+v.transmitBytes>0));return ''+safe(g.name)+'
'+(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(', '))+'
'+(down?'Нет линка':fresh?'Новые потери: '+fresh:'Сейчас без потерь')+' Сейчас ↓ '+rate(rx)+'
Сейчас ↑ '+rate(tx)+'
Получено '+size(primary.reduce((a,v)=>a+v.receiveBytes,0))+'
Передано '+size(primary.reduce((a,v)=>a+v.transmitBytes,0))+'
Новые / всего errors+drops '+fresh+' / '+total+'
'}).join('')}
function renderIOAttribution(disks){const active=disks.filter(d=>d.ioCause||(d.topConsumers||[]).length);el('ioAttributionList').innerHTML=active.length?active.map(d=>''+safe(d.name)+' · '+safe(d.ioCause||'Источник не определён')+'
'+safe(d.ioCauseDetail||'Собираем статистику процессов')+'
'+(d.ioConfidence||0)+'% '+(d.topConsumers||[]).slice(0,5).map(v=>''+safe(v.kind)+' · '+safe(v.name)+' · '+fixed(v.sharePercent)+'% · ↓'+rate(v.readBytesPerSec)+' ↑'+rate(v.writeBytesPerSec)+' ').join('')+'
').join(''):'Для атрибуции нужно несколько realtime-циклов. '}
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 для подробностей.'})}
@@ -925,8 +933,8 @@ function renderAgentServices(services,filter='all'){const failed=v=>v.state==='f
function renderAgentServicesV2(services,filter='all'){const failed=v=>v.state==='failed'||v.subState==='failed',custom=v=>['added','local','runtime'].includes(v.origin),filtered=(services||[]).filter(v=>filter==='all'||filter==='failed'&&failed(v)||filter==='system'&&v.origin==='system'||filter==='custom'&&custom(v)||filter==='flapping'&&v.flapping),originLabel=v=>v.origin==='local'?'Локальная':v.origin==='added'?'Добавленный пакет':v.origin==='runtime'?'Временная':'Системная',chip=(label,value)=>value?''+label+' · '+safe(value)+' ':'';el('agentServiceDetails').innerHTML='Все · '+services.length+' Системные · '+services.filter(v=>v.origin==='system').length+' Добавленные · '+services.filter(custom).length+' Упали · '+services.filter(failed).length+' Перезапускаются · '+services.filter(v=>v.flapping).length+'
'+(filtered.length?filtered.map(v=>'
'+safe(v.name)+'
'+safe(v.description||'Описание отсутствует')+'
'+originLabel(v)+(v.package?' · пакет '+safe(v.package):'')+(v.unitPath?' · '+safe(v.unitPath):'')+'
'+(v.flapping?'Частые рестарты':safe(v.subState||v.state))+' Запущена '+(v.startedAt?new Date(v.startedAt*1000).toLocaleString('ru-RU'):'—')+'
PID / команда '+(v.mainPid||'—')+' · '+safe(v.execStart||'—')+'
Рестарты / exit '+(v.restarts||0)+' / '+(v.exitCode||0)+'
Память / CPU time '+(v.memoryBytes?size(v.memoryBytes):'Нет accounting')+' / '+(v.cpuSeconds?fixed(v.cpuSeconds)+' сек.':'—')+'
Порты и зависимости
'+(v.listenPorts||[]).map(p=>chip('порт',p)).join('')+(v.requires||[]).map(d=>chip('requires',d)).join('')+(v.after||[]).slice(0,6).map(d=>chip('after',d)).join('')+'
'+((v.logs||[]).length?'Последние сообщения журнала
'+v.logs.map(l=>'
'+(l.time?new Date(l.time*1000).toLocaleString('ru-RU')+' · ':'')+safe(l.message)+'
').join('')+'
':'')+'').join(''):'
Служб в этой группе нет. ')+'
';el('agentServiceDetails').querySelectorAll('[data-agent-service-filter]').forEach(button=>button.onclick=()=>renderAgentServicesV2(services,button.dataset.agentServiceFilter))}
function openAgentDetail(agent){const r=agent.report||{},services=r.services||[],issues=r.issues||[],containers=r.containers||[],interfaces=r.interfaces||[],filesystems=r.filesystems||[],processes=r.processes||[],ports=r.listenPorts||[],oom=r.oomKills||[],pressure=r.pressure||{},memoryPercent=r.memoryTotal?r.memoryUsed/r.memoryTotal*100:0,diskPercent=r.rootTotal?r.rootUsed/r.rootTotal*100:0;el('agentDetailTitle').textContent=agent.name||agent.hostname;el('agentDetailMeta').textContent=(agent.hostname||'')+' · VMID '+(agent.vmid||'не указан')+' · '+(r.osName||r.os||'Linux')+' · '+(r.kernel||'ядро не определено')+' · агент '+(agent.version||'—');el('agentOverview').innerHTML='Состояние '+(agent.online?'Онлайн':'Не отвечает')+'
CPU / Load '+fixed(r.cpuPercent||0)+'% / '+fixed(r.load1||0)+'
Память '+size(r.memoryUsed)+' · '+fixed(memoryPercent)+'%
Раздел / '+size(r.rootUsed)+' · '+fixed(diskPercent)+'%
Uptime '+uptime(r.uptimeSeconds||0)+'
Обновления '+(r.updatesAvailable||0)+' пакетов'+(r.rebootRequired?' · нужен reboot':'')+'
IP-адреса
'+(interfaces.length?interfaces.map(v=>''+safe(v.name)+' · '+safe((v.addresses||[]).join(', '))+' ').join(''):'Не найдены ')+'
Последний heartbeat: '+new Date(agent.lastSeen*1000).toLocaleString('ru-RU')+' · системный инвентарь: '+(r.inventoryAt?new Date(r.inventoryAt*1000).toLocaleString('ru-RU'):'ещё не собран')+'
';renderAgentServicesV2(services);el('agentResourceDetails').innerHTML='CPU pressure · 10/60/300с '+fixed(pressure.cpu?.avg10||0)+' / '+fixed(pressure.cpu?.avg60||0)+' / '+fixed(pressure.cpu?.avg300||0)+'
Memory pressure '+fixed(pressure.memory?.avg10||0)+' / '+fixed(pressure.memory?.avg60||0)+' / '+fixed(pressure.memory?.avg300||0)+'
I/O pressure '+fixed(pressure.io?.avg10||0)+' / '+fixed(pressure.io?.avg60||0)+' / '+fixed(pressure.io?.avg300||0)+'
'+(filesystems.length?filesystems.map(v=>'
'+safe(v.mountpoint)+'
'+safe(v.device)+' · '+safe(v.type)+'
'+fixed(v.usedPercent)+'% Занято / всего '+size(v.usedBytes)+' / '+size(v.totalBytes)+'
Inode '+fixed(v.inodesPercent||0)+'% · '+(v.inodesUsed||0)+' / '+(v.inodesTotal||0)+'
').join(''):'
Файловые системы ещё не получены. ')+'
';el('agentProcessDetails').innerHTML='Слушающие порты
'+(ports.length?ports.map(v=>''+safe(v.protocol)+' · '+safe(v.address)+':'+v.port+' · '+safe(v.process||'PID '+(v.pid||'—'))+' ').join(''):'Не найдены или ss недоступен. ')+'
Топ процессов
'+(processes.length?processes.map(v=>'
'+safe(v.name)+' PID '+v.pid+' · '+safe(v.command)+'
CPU '+fixed(v.cpuPercent)+'% RAM '+size(v.memoryBytes)+' Read '+size(v.readBytes)+' Write '+size(v.writeBytes)+' ').join(''):'
Процессы ещё не получены. ')+'
';el('agentIssueDetails').innerHTML=''+(oom.map(v=>'
'+new Date(v.time*1000).toLocaleString('ru-RU')+' · '+safe(v.message)+'
').join(''))+(issues.length?issues.map(v=>'
'+safe(v.unit||'system')+'
'+(v.time?new Date(v.time*1000).toLocaleString('ru-RU'):'Время не указано')+'
Ошибка '+safe(v.message)+'
').join(''):'
Ошибок уровня err и выше за 24 часа нет. ')+'
';el('agentDockerDetails').innerHTML=''+(containers.length?containers.map(v=>'
'+safe(v.name)+'
'+safe(v.image)+' · '+safe(v.id)+'
'+safe(v.health||v.state)+' Статус '+safe(v.status)+'
CPU '+fixed(v.cpuPercent||0)+'%
Память '+size(v.memoryUsage||0)+'
Рестарты '+v.restarts+'
Порты '+safe((v.ports||[]).join(', ')||'—')+'
').join(''):'
Docker-контейнеры не обнаружены. ')+'
';const first=document.querySelector('[data-agent-tab="overview"]');document.querySelectorAll('[data-agent-tab]').forEach(t=>t.classList.toggle('active',t===first));document.querySelectorAll('[data-agent-panel]').forEach(p=>p.hidden=p.dataset.agentPanel!=='overview');el('agentDetailDialog').showModal()}
let currentDetailAgent=null;const openAgentDetailBase=openAgentDetail;openAgentDetail=function(agent){currentDetailAgent=agent;openAgentDetailBase(agent);const r=agent.report||{},processes=r.processes||[],oom=r.oomKills||[],summary='Swap '+size(r.swapUsed||0)+' / '+size(r.swapTotal||0)+'
Процессы в D-state '+processes.filter(v=>String(v.state||'').startsWith('D')).length+'
OOM за 24 часа '+oom.length+'
';el('agentResourceDetails').insertAdjacentHTML('afterbegin',summary);addAgentActionButtons(agent);loadAgentHistory(agent,24)}
-function addAgentActionButtons(agent){el('agentOverview').insertAdjacentHTML('beforeend','Обновить список пакетов
');el('agentServiceDetails').querySelectorAll('.disk-item').forEach(article=>{const name=article.querySelector('.disk-model')?.textContent;if(!name)return;article.insertAdjacentHTML('beforeend','Перезапустить Сбросить failed
')});el('agentDockerDetails').querySelectorAll('.disk-item').forEach(article=>{const name=article.querySelector('.disk-model')?.textContent;if(name)article.insertAdjacentHTML('beforeend','Перезапустить контейнер
')})}
-async function queueAgentAction(action,target){if(!currentDetailAgent||!confirm('Выполнить действие «'+action+'» для '+(target||currentDetailAgent.name)+'?'))return;const response=await fetch('/api/agent-actions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agentId:currentDetailAgent.id,action,target})});alert(response.ok?'Действие поставлено в очередь. Результат появится после следующего heartbeat.':await response.text());if(response.ok)setTimeout(loadAgentActions,12000)}
+function addAgentActionButtons(agent){el('agentOverview').insertAdjacentHTML('beforeend','Проверить обновления ОС Установить обновления ОС
');el('agentServiceDetails').querySelectorAll('.disk-item').forEach(article=>{const name=article.querySelector('.disk-model')?.textContent;if(!name)return;article.insertAdjacentHTML('beforeend','Перезапустить Сбросить failed
')});el('agentDockerDetails').querySelectorAll('.disk-item').forEach((article,index)=>{const container=(agent.report?.containers||[])[index],name=article.querySelector('.disk-model')?.textContent;if(!name)return;article.insertAdjacentHTML('beforeend','Обновление образа
'+(container?.updateAvailable?'Доступен уже загруженный новый образ':'Запущен текущий локальный образ')+'
Перезапустить '+(container?.compose?'Проверить образ Обновить ':'Обновление из панели доступно для Docker Compose ')+'
')})}
+async function queueAgentAction(action,target){const labels={'system-update':'установить обновления операционной системы','updates-refresh':'проверить обновления операционной системы','docker-check':'проверить и загрузить свежий Docker-образ','docker-update':'пересоздать Compose-контейнер на свежем образе','docker-restart':'перезапустить Docker-контейнер','systemd-restart':'перезапустить службу','systemd-reset-failed':'сбросить состояние failed'};if(!currentDetailAgent||!confirm('Выполнить действие «'+(labels[action]||action)+'» для '+(target||currentDetailAgent.name)+'?'))return;const response=await fetch('/api/agent-actions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agentId:currentDetailAgent.id,action,target})});alert(response.ok?'Действие поставлено в очередь. Результат появится после следующего heartbeat.':await response.text());if(response.ok)setTimeout(loadAgentActions,12000)}
async function loadAgentHistory(agent,hours=24){const kinds=['agent-resource','agent-swap'],responses=await Promise.all(kinds.map(kind=>fetch('/api/entity-history?kind='+kind+'&hours='+hours).then(r=>r.json()))),key=agent.id+' · ',resource=(responses[0]||[]).find(v=>v.name.startsWith(key)),swap=(responses[1]||[]).find(v=>v.name.startsWith(key)),points=new Map();(resource?.points||[]).forEach(p=>points.set(p.timestamp,{timestamp:p.timestamp,cpu:p.value1,ram:p.value2}));(swap?.points||[]).forEach(p=>{const item=points.get(p.timestamp)||{timestamp:p.timestamp};item.swap=p.value1;points.set(p.timestamp,item)});chartConfigs.agentHistoryChart={series:[{label:'CPU',color:'#55d58a',value:p=>p.cpu,format:percent},{label:'RAM',color:'#9d8cff',value:p=>p.ram,format:percent},{label:'Swap',color:'#ff78b8',value:p=>p.swap,format:percent}]};const canvas=el('agentHistoryChart');drawChart('agentHistoryChart',[...points.values()].sort((a,b)=>a.timestamp-b.timestamp));if(!canvas.dataset.agentHover){setupAgentHistoryHover(canvas);canvas.dataset.agentHover='1'}}
function setupAgentHistoryHover(canvas){canvas.addEventListener('mousemove',event=>{const state=canvas._chartState,tooltip=el('agentHistoryDetails').querySelector('.chart-tooltip');if(!state||!tooltip)return;const rect=canvas.getBoundingClientRect(),x=event.clientX-rect.left,index=Math.max(0,Math.min(state.points.length-1,Math.round((x-state.pad.l)/state.pw*(state.points.length-1)))),point=state.points[index];tooltip.innerHTML=''+new Date(point.timestamp*1000).toLocaleString('ru-RU')+'
'+state.series.map(s=>' '+s.label+''+(s.value(point)==null?'—':s.format(s.value(point)))+'
').join('');tooltip.classList.add('visible');tooltip.style.left=Math.min(x+12,canvas.clientWidth-tooltip.offsetWidth-10)+'px';tooltip.style.top='55px'});canvas.addEventListener('mouseleave',()=>el('agentHistoryDetails').querySelector('.chart-tooltip').classList.remove('visible'))}
function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{available:false,pools:[]},u=x.ups||{available:false},g=x.guests||{available:false,guests:[]},b=x.backups||{available:false,items:[]},mt=x.maintenance||{},alerts=x.alerts||[],disks=x.disks||[],avg=c.temperatures.length?c.temperatures.reduce((a,t)=>a+t.celsius,0)/c.temperatures.length:null;
@@ -938,12 +946,12 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av
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=>''+safe(a.source)+' · '+(a.severity==='critical'?'Критическое':a.severity==='warning'?'Предупреждение':'Информация')+(a.firstSeen?' · С '+new Date(a.firstSeen*1000).toLocaleString('ru-RU'):'')+'
'+safe(a.title)+'
'+safe(a.message)+'
Принято ').join(''):'Всё в порядке Новых неподтверждённых событий нет.
';
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=>''+t.label+''+fixed(t.celsius)+'°C ').join(''):'Датчики температуры не найдены ';
+ 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=>''+safe(t.label)+''+fixed(t.celsius)+'°C ').join(''):'Датчики температуры не найдены ';const cpuProcesses=(c.topProcesses||[]).slice().sort((a,b)=>b.cpuPercent-a.cpuPercent).slice(0,12);el('cpuProcessList').innerHTML=cpuProcesses.length?cpuProcesses.map(v=>''+safe(v.name)+' PID '+v.pid+' · '+safe(v.command)+'
CPU '+fixed(v.cpuPercent)+'% RAM '+size(v.memoryBytes)+' Read '+size(v.readBytes)+' Write '+size(v.writeBytes)+' ').join(''):'Процессы не получены. ';
el('memUsage').textContent=fixed(m.usagePercent);el('memBar').style.width=Math.min(m.usagePercent,100)+'%';el('memUsed').textContent=size(m.usedBytes);el('memAvailable').textContent=size(m.availableBytes);el('dMemUsed').textContent=size(m.usedBytes);el('dMemAvailable').textContent=size(m.availableBytes);el('dMemTotal').textContent=size(m.totalBytes);el('dMemCache').textContent=size(m.cachedBytes);el('dMemBuffers').textContent=size(m.buffersBytes);el('dMemPercent').textContent=fixed(m.usagePercent)+'%';el('dSwapUsed').textContent=size(m.swapUsedBytes);el('dSwapTotal').textContent=size(m.swapTotalBytes);el('dSwapPercent').textContent=fixed(m.swapPercent)+'%';
const failed=disks.filter(d=>d.smartStatus==='Ошибка').length,attention=disks.filter(d=>d.smartStatus==='Требует внимания').length,available=disks.filter(d=>d.smartAvailable).length;let health=failed?'Ошибка':attention?'Внимание':available===disks.length&&disks.length?'Исправны':available?'Частично':'Нет данных';
el('diskCount').textContent=disks.length;el('diskCountLabel').textContent=disks.length===1?' диск':' дисков';el('diskHealth').textContent=health;el('diskUsage').textContent=fixed(s.usagePercent)+'%';el('diskBar').style.width=Math.min(s.usagePercent,100)+'%';el('dDiskUsed').textContent=size(s.usedBytes);el('dDiskFree').textContent=size(s.freeBytes);el('dDiskTotal').textContent=size(s.totalBytes);el('dDiskPercent').textContent=fixed(s.usagePercent)+'%';
el('diskList').innerHTML=disks.length?disks.map(d=>{const ioWarn=d.ioPressureSeconds>=60,badge=d.smartStatus==='Ошибка'?'bad':d.smartStatus==='Исправен'?(ioWarn?'warn':''):'warn',reasons=[...(d.attentionReasons||[]),...(ioWarn?['Длительная I/O-нагрузка: '+fixed(d.ioPressureSeconds)+' сек.']:[])],reason=reasons.join(' · '),tip=reason?' tabindex="0" data-reason="'+safe(reason)+'"':'';return ''+safe(d.model)+'
'+safe(d.path)+' · SN: '+safe(d.serial)+'
'+(ioWarn?'Высокий I/O':safe(d.smartStatus))+' Тип / объём '+safe(d.type)+' · '+size(d.sizeBytes)+'
Занято '+(d.usedBytes==null?'—':size(d.usedBytes)+' · '+fixed(d.usagePercent)+'%')+'
Температура / износ '+(d.temperatureCelsius==null?'—':fixed(d.temperatureCelsius)+' °C')+' · '+(d.wearUsedPercent==null?'—':fixed(d.wearUsedPercent)+'%')+'
IOPS · чтение / запись '+fixed(d.readIops)+' / '+fixed(d.writeIops)+'
Скорость · чтение / запись '+rate(d.readBytesPerSec)+' / '+rate(d.writeBytesPerSec)+'
Latency · чтение / запись '+fixed(d.readLatencyMs)+' / '+fixed(d.writeLatencyMs)+' ms
Utilization / очередь '+fixed(d.utilizationPercent)+'% / '+fixed(d.averageQueue)+' ('+d.queueDepth+' сейчас)
Reallocated / Pending '+d.reallocated+' / '+d.pending+'
Uncorrectable / CRC '+d.uncorrectable+' / '+d.crcErrors+'
Последний self-test '+safe(d.lastSelfTest||'—')+(d.lastSelfTestAt?' · '+new Date(d.lastSelfTestAt).toLocaleDateString('ru-RU'):'')+(d.shortTestStarted?' · запущен новый':'')+'
'}).join(''):'Физические накопители не найдены ';
- 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 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':'')+(nh.packetLoss?' · потери '+fixed(nh.packetLoss)+'%':'');el('dNetDNS').textContent=nh.dnsUp?'Работает':'Ошибка';el('dNetInternet').textContent=nh.internetUp?'Доступен':'Недоступен';el('dNetExternalIP').textContent=(nh.externalIp||'—')+(nh.externalIpChanged?' · изменился':'');el('dNetSummary').textContent=nh.summary||'Диагностика ещё не завершена';el('dNetCause').textContent=nh.likelyCause||'—';el('dNetAction').textContent=nh.recommendedAction||'—';
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 ''+safe(p.name)+'
Scrub: '+safe(p.scrubStatus||p.scan||'нет данных')+(p.lastScrubAt?' · '+new Date(p.lastScrubAt).toLocaleString('ru-RU'):'')+(p.scrubDuration?' · '+safe(p.scrubDuration):'')+'
'+safe(p.health)+' Объём / свободно '+size(p.sizeBytes)+' / '+size(p.freeBytes)+'
Заполнение '+fixed(p.capacityPercent)+'%
Read / write / checksum '+p.readErrors+' / '+p.writeErrors+' / '+p.checksumErrors+'
Vdev · data / special / cache / log
'+(vdevs.length?vdevs.map(v=>''+safe(v.class)+' · '+safe(v.name)+' · '+safe(v.state)+' · '+v.read+'/'+v.write+'/'+v.checksum+' ').join(''):'Нет данных ')+'
Datasets
'+(poolDatasets.length?poolDatasets.map(d=>''+safe(d.name)+' · '+fixed(d.capacityPercent)+'% · '+size(d.usedBytes)+' ').join(''):'Нет datasets ')+'
'}).join(''):''+(z.available?'ZFS-пулы не найдены':'Команда zpool недоступна')+' ';
const val=(v,suffix='')=>v==null?'—':fixed(v)+suffix;el('upsName').textContent=u.available?(u.model||u.name||'Источник бесперебойного питания'):'UPS не настроен';el('upsCharge').textContent=u.chargePercent==null?'—':fixed(u.chargePercent);el('upsBar').style.width=u.chargePercent==null?'0%':Math.min(u.chargePercent,100)+'%';el('upsStatus').textContent=u.available?u.statusLabel:'Не найден';el('upsRuntime').textContent=u.runtimeSeconds==null?'—':uptime(u.runtimeSeconds);el('dUpsName').textContent=u.model||u.name||'Источник бесперебойного питания';el('dUpsStatus').textContent=u.available?u.statusLabel:'Не настроен';el('dUpsCharge').textContent=val(u.chargePercent,'%');el('dUpsRuntime').textContent=u.runtimeSeconds==null?'—':uptime(u.runtimeSeconds);el('dUpsLoad').textContent=val(u.loadPercent,'%');el('dUpsInput').textContent=val(u.inputVoltage,' V');el('dUpsOutput').textContent=val(u.outputVoltage,' V');el('dUpsFrequency').textContent=val(u.inputFrequency,' Hz');el('dUpsBattery').textContent=val(u.batteryVoltage,' V');el('dUpsTemp').textContent=val(u.temperatureCelsius,' °C');el('dUpsCommunication').textContent=u.communicationOk?'Связь установлена':'Нет связи';el('dUpsBatteryDate').textContent=u.batteryDate||'Не сообщает';el('dUpsBatteryAge').textContent=u.batteryAgeDays?u.batteryAgeDays+' дней':'—';el('dUpsSource').textContent=u.available?(u.source+' · '+(u.name||'UPS')):'Установите и настройте NUT или apcupsd';
@@ -970,7 +978,9 @@ function drawChart(id,points){const canvas=el(id),config=chartConfigs[id];if(!ca
function setupChartHover(canvas){canvas.addEventListener('mousemove',event=>{const state=canvas._chartState,section=canvas.closest('.chart-section'),tooltip=section&§ion.querySelector('.chart-tooltip');if(!state||!tooltip)return;const rect=canvas.getBoundingClientRect(),x=event.clientX-rect.left,index=Math.max(0,Math.min(state.points.length-1,Math.round((x-state.pad.l)/state.pw*(state.points.length-1)))),point=state.points[index],date=new Date(point.timestamp*1000);tooltip.innerHTML=''+date.toLocaleString('ru-RU')+'
'+state.series.map(s=>{const value=s.value(point),shown=value==null||!Number.isFinite(value)?'—':s.format(value);return ' '+s.label+''+shown+'
'}).join('');tooltip.classList.add('visible');const sectionRect=section.getBoundingClientRect(),left=Math.min(event.clientX-sectionRect.left+12,section.clientWidth-tooltip.offsetWidth-10);tooltip.style.left=Math.max(10,left)+'px';tooltip.style.top=Math.max(55,event.clientY-sectionRect.top-tooltip.offsetHeight-12)+'px'});canvas.addEventListener('mouseleave',()=>{const tooltip=canvas.closest('.chart-section').querySelector('.chart-tooltip');tooltip.classList.remove('visible')})}
async function showHistory(section){const hours=Number(section.dataset.hours||1);await Promise.all([...section.querySelectorAll('canvas')].map(async canvas=>{try{drawChart(canvas.id,entityChartKinds[canvas.id]?await loadEntityChart(canvas.id,hours):await loadHistory(hours))}catch(e){drawChart(canvas.id,[])}}))}
function setupDetailTabs(){document.querySelectorAll('dialog').forEach(dialog=>{const chart=dialog.querySelector('.chart-section');if(!chart)return;const head=dialog.querySelector('.modal-head'),tabs=document.createElement('div'),realtime=document.createElement('div');tabs.className='detail-tabs';tabs.innerHTML='Реальное время История ';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='1 час 24 часа 7 дней ';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)})})}
+function setupCPUTabs(){document.querySelectorAll('[data-cpu-tab]').forEach(tab=>tab.onclick=()=>{const dialog=tab.closest('dialog');dialog.querySelectorAll('[data-cpu-tab]').forEach(v=>v.classList.toggle('active',v===tab));dialog.querySelectorAll('[data-cpu-panel]').forEach(v=>v.hidden=v.dataset.cpuPanel!==tab.dataset.cpuTab)})}
setupDetailTabs();
+setupCPUTabs();
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)});