From f6498a2fc197e0bccf810e850b4702bcc6cd412a Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 21 Aug 2026 21:15:52 +0300 Subject: [PATCH] Add backup maintenance and guest update controls --- agent.go | 36 +++++++++++----- agent_actions.go | 87 ++++++++++++++++++++++++++++++++++++-- agent_actions_test.go | 10 ++++- alerts.go | 4 +- cpu.go | 19 +++++---- maintenance_window.go | 45 ++++++++++++++++++++ maintenance_window_test.go | 15 +++++++ network.go | 41 ++++++++++++++++++ network_test.go | 21 +++++++++ web.go | 22 +++++++--- 10 files changed, 268 insertions(+), 32 deletions(-) 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 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=>'').join(''):'Сервисы не добавлены.';el('maintenanceGuests').innerHTML=maintenanceGuests.length?maintenanceGuests.map(v=>'').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='
'+(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=>'
OOM kill
Критично
'+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','
')});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','
')});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='';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)});