diff --git a/README.md b/README.md index 302eb26..e188f63 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,17 @@ health-check, число перезапусков, CPU и память. Аген unit-файла, время запуска, число перезапусков, последний exit code, память и накопленное CPU-время, когда соответствующий accounting доступен в systemd. +Расширенная диагностика агента включает последние сообщения проблемных служб, +`Requires`/`After`, PID, команду запуска и слушающие порты; выявляет частые +рестарты, OOM-kill и процессы в D-state. Отдельно отображаются top процессов по +CPU и RAM с накопленным I/O, swap, PSI pressure, все постоянные файловые системы, +их заполнение и использование inode. + +Панель «Диагностика homelab» и карта сопоставляют известные IP и порты с VM/LXC, +агентом, systemd-службой или Docker-контейнером и настроенной HTTP/TCP-проверкой. +Связи по PID и порту считаются подтверждёнными; совпадения только по названию +помечаются как предположение. + Откройте `Настройки → Агенты`, укажите адрес Dashboard, доступный из VM/LXC, и VMID. Кнопка создаст готовую команду установки. Одноразовый токен действует 15 минут и после регистрации заменяется индивидуальным секретом агента. diff --git a/agent.go b/agent.go index 45c79cc..c6c5498 100644 --- a/agent.go +++ b/agent.go @@ -58,18 +58,25 @@ type AgentInterface struct { } type AgentService struct { - Name string `json:"name"` - State string `json:"state"` - SubState string `json:"subState"` - Description string `json:"description"` - Origin string `json:"origin"` - UnitPath string `json:"unitPath,omitempty"` - Package string `json:"package,omitempty"` - Restarts uint64 `json:"restarts"` - ExitCode int `json:"exitCode"` - StartedAt int64 `json:"startedAt,omitempty"` - MemoryBytes uint64 `json:"memoryBytes,omitempty"` - CPUSeconds float64 `json:"cpuSeconds,omitempty"` + Name string `json:"name"` + State string `json:"state"` + SubState string `json:"subState"` + Description string `json:"description"` + Origin string `json:"origin"` + UnitPath string `json:"unitPath,omitempty"` + Package string `json:"package,omitempty"` + Restarts uint64 `json:"restarts"` + ExitCode int `json:"exitCode"` + StartedAt int64 `json:"startedAt,omitempty"` + MemoryBytes uint64 `json:"memoryBytes,omitempty"` + CPUSeconds float64 `json:"cpuSeconds,omitempty"` + MainPID int `json:"mainPid,omitempty"` + ExecStart string `json:"execStart,omitempty"` + Requires []string `json:"requires,omitempty"` + After []string `json:"after,omitempty"` + ListenPorts []int `json:"listenPorts,omitempty"` + Logs []AgentLog `json:"logs,omitempty"` + Flapping bool `json:"flapping"` } type AgentIssue struct { @@ -79,30 +86,37 @@ type AgentIssue struct { } type AgentReport struct { - Hostname string `json:"hostname"` - MachineID string `json:"machineId"` - Version string `json:"version"` - OS string `json:"os"` - Arch string `json:"arch"` - OSName string `json:"osName"` - Kernel string `json:"kernel"` - UptimeSeconds int64 `json:"uptimeSeconds"` - Load1 float64 `json:"load1"` - CPUPercent float64 `json:"cpuPercent"` - MemoryTotal uint64 `json:"memoryTotal"` - MemoryUsed uint64 `json:"memoryUsed"` - RootTotal uint64 `json:"rootTotal"` - RootUsed uint64 `json:"rootUsed"` - DockerAvailable bool `json:"dockerAvailable"` - DockerError string `json:"dockerError,omitempty"` - Containers []AgentContainer `json:"containers"` - Interfaces []AgentInterface `json:"interfaces"` - Services []AgentService `json:"services"` - Issues []AgentIssue `json:"issues"` - UpdatesAvailable int `json:"updatesAvailable"` - RebootRequired bool `json:"rebootRequired"` - InventoryAt int64 `json:"inventoryAt"` - CollectedAt int64 `json:"collectedAt"` + Hostname string `json:"hostname"` + MachineID string `json:"machineId"` + Version string `json:"version"` + OS string `json:"os"` + Arch string `json:"arch"` + OSName string `json:"osName"` + Kernel string `json:"kernel"` + UptimeSeconds int64 `json:"uptimeSeconds"` + Load1 float64 `json:"load1"` + CPUPercent float64 `json:"cpuPercent"` + MemoryTotal uint64 `json:"memoryTotal"` + MemoryUsed uint64 `json:"memoryUsed"` + SwapTotal uint64 `json:"swapTotal"` + SwapUsed uint64 `json:"swapUsed"` + RootTotal uint64 `json:"rootTotal"` + RootUsed uint64 `json:"rootUsed"` + DockerAvailable bool `json:"dockerAvailable"` + DockerError string `json:"dockerError,omitempty"` + Containers []AgentContainer `json:"containers"` + Interfaces []AgentInterface `json:"interfaces"` + Services []AgentService `json:"services"` + Issues []AgentIssue `json:"issues"` + Processes []AgentProcess `json:"processes"` + Filesystems []AgentFilesystem `json:"filesystems"` + ListenPorts []AgentListenPort `json:"listenPorts"` + Pressure AgentPressure `json:"pressure"` + OOMKills []AgentLog `json:"oomKills"` + UpdatesAvailable int `json:"updatesAvailable"` + RebootRequired bool `json:"rebootRequired"` + InventoryAt int64 `json:"inventoryAt"` + CollectedAt int64 `json:"collectedAt"` } type agentInventory struct { @@ -501,6 +515,11 @@ func collectAgentReport() AgentReport { } report.CPUPercent = sampleAgentCPU() report.Interfaces = collectAgentInterfaces() + report.Processes = collectAgentProcesses() + report.Filesystems = collectAgentFilesystems() + report.ListenPorts = collectAgentListenPorts() + report.Pressure = collectAgentPressure() + report.OOMKills = cachedAgentOOMKills() if data, err := os.ReadFile("/proc/meminfo"); err == nil { values := map[string]uint64{} for _, line := range strings.Split(string(data), "\n") { @@ -515,6 +534,10 @@ func collectAgentReport() AgentReport { if report.MemoryTotal > available { report.MemoryUsed = report.MemoryTotal - available } + report.SwapTotal = values["SwapTotal"] + if report.SwapTotal > values["SwapFree"] { + report.SwapUsed = report.SwapTotal - values["SwapFree"] + } } var stat syscall.Statfs_t if syscall.Statfs("/", &stat) == nil { @@ -637,7 +660,7 @@ func enrichAgentServices(services []AgentService) []AgentService { if len(services) == 0 { return services } - args := []string{"show", "--no-pager", "--property=Id", "--property=FragmentPath", "--property=NRestarts", "--property=ExecMainStatus", "--property=ActiveEnterTimestampUSec", "--property=MemoryCurrent", "--property=CPUUsageNSec"} + args := []string{"show", "--no-pager", "--property=Id", "--property=FragmentPath", "--property=NRestarts", "--property=ExecMainStatus", "--property=ActiveEnterTimestampUSec", "--property=MemoryCurrent", "--property=CPUUsageNSec", "--property=MainPID", "--property=ExecStart", "--property=Requires", "--property=After"} for _, service := range services { args = append(args, service.Name) } @@ -706,6 +729,23 @@ func enrichAgentServices(services []AgentService) []AgentService { service.MemoryBytes, _ = strconv.ParseUint(values["MemoryCurrent"], 10, 64) cpu, _ := strconv.ParseUint(values["CPUUsageNSec"], 10, 64) service.CPUSeconds = float64(cpu) / 1_000_000_000 + pid, _ := strconv.Atoi(values["MainPID"]) + service.MainPID = pid + service.ExecStart = compactSystemdExec(values["ExecStart"]) + service.Requires = limitedFields(values["Requires"], 12) + service.After = limitedFields(values["After"], 12) + service.Flapping = service.Restarts >= 5 && service.StartedAt > time.Now().Add(-10*time.Minute).Unix() + if service.State == "failed" || service.SubState == "failed" || service.Flapping { + service.Logs = collectAgentJournal(service.Name, 8, "-2h") + } + } + ports := collectAgentListenPorts() + for i := range services { + for _, port := range ports { + if services[i].MainPID > 0 && port.PID == services[i].MainPID { + services[i].ListenPorts = appendUniqueInt(services[i].ListenPorts, port.Port) + } + } } return services } diff --git a/agent_diagnostics.go b/agent_diagnostics.go new file mode 100644 index 0000000..db28873 --- /dev/null +++ b/agent_diagnostics.go @@ -0,0 +1,329 @@ +package main + +import ( + "bufio" + "encoding/json" + "net" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +type AgentLog struct { + Time int64 `json:"time"` + Message string `json:"message"` +} + +type AgentProcess struct { + PID int `json:"pid"` + Name string `json:"name"` + State string `json:"state"` + CPUPercent float64 `json:"cpuPercent"` + MemoryBytes uint64 `json:"memoryBytes"` + ReadBytes uint64 `json:"readBytes"` + WriteBytes uint64 `json:"writeBytes"` + Elapsed int64 `json:"elapsedSeconds"` + Command string `json:"command"` +} + +type AgentFilesystem struct { + Device string `json:"device"` + Mountpoint string `json:"mountpoint"` + Type string `json:"type"` + TotalBytes uint64 `json:"totalBytes"` + UsedBytes uint64 `json:"usedBytes"` + UsedPercent float64 `json:"usedPercent"` + InodesTotal uint64 `json:"inodesTotal"` + InodesUsed uint64 `json:"inodesUsed"` + InodesPercent float64 `json:"inodesPercent"` +} + +type AgentListenPort struct { + Protocol string `json:"protocol"` + Address string `json:"address"` + Port int `json:"port"` + PID int `json:"pid,omitempty"` + Process string `json:"process,omitempty"` +} + +type AgentPressureValue struct { + Avg10 float64 `json:"avg10"` + Avg60 float64 `json:"avg60"` + Avg300 float64 `json:"avg300"` +} + +type AgentPressure struct { + CPU AgentPressureValue `json:"cpu"` + Memory AgentPressureValue `json:"memory"` + IO AgentPressureValue `json:"io"` +} + +var agentOOMCache struct { + sync.Mutex + value []AgentLog + at time.Time +} + +func compactSystemdExec(value string) string { + if pathAt := strings.Index(value, "path="); pathAt >= 0 { + value = value[pathAt+5:] + } + if end := strings.IndexAny(value, " ;}"); end > 0 { + value = value[:end] + } + if len(value) > 240 { + value = value[:240] + } + return strings.TrimSpace(value) +} + +func limitedFields(value string, limit int) []string { + fields := strings.Fields(value) + if len(fields) > limit { + fields = fields[:limit] + } + return fields +} + +func appendUniqueInt(items []int, value int) []int { + for _, item := range items { + if item == value { + return items + } + } + return append(items, value) +} + +func collectAgentJournal(unit string, limit int, since string) []AgentLog { + args := []string{"--no-pager", "-o", "json", "--reverse", "-n", strconv.Itoa(limit), "--since", since} + if unit != "" { + args = append(args, "-u", unit) + } + output := runAgentCommand(6*time.Second, "journalctl", args...) + result := []AgentLog{} + for _, line := range strings.Split(output, "\n") { + var entry struct { + Message any `json:"MESSAGE"` + Timestamp string `json:"__REALTIME_TIMESTAMP"` + } + if json.Unmarshal([]byte(line), &entry) != nil { + continue + } + message, ok := entry.Message.(string) + if !ok || strings.TrimSpace(message) == "" { + continue + } + microseconds, _ := strconv.ParseInt(entry.Timestamp, 10, 64) + result = append(result, AgentLog{Time: microseconds / 1_000_000, Message: message}) + } + return result +} + +func cachedAgentOOMKills() []AgentLog { + agentOOMCache.Lock() + defer agentOOMCache.Unlock() + if time.Since(agentOOMCache.at) < time.Minute { + return agentOOMCache.value + } + output := runAgentCommand(6*time.Second, "journalctl", "-k", "--since", "24 hours ago", "--reverse", "--no-pager", "-o", "json", "-g", "Out of memory|Killed process") + result := []AgentLog{} + for _, line := range strings.Split(output, "\n") { + var entry struct { + Message any `json:"MESSAGE"` + Timestamp string `json:"__REALTIME_TIMESTAMP"` + } + if json.Unmarshal([]byte(line), &entry) != nil { + continue + } + message, ok := entry.Message.(string) + if !ok { + continue + } + microseconds, _ := strconv.ParseInt(entry.Timestamp, 10, 64) + result = append(result, AgentLog{Time: microseconds / 1_000_000, Message: message}) + if len(result) >= 20 { + break + } + } + agentOOMCache.value, agentOOMCache.at = result, time.Now() + return result +} + +func collectAgentProcesses() []AgentProcess { + result := []AgentProcess{} + seen := map[int]bool{} + for _, sortBy := range []string{"-pcpu", "-rss"} { + output := runAgentCommand(5*time.Second, "ps", "-eo", "pid=,comm=,state=,pcpu=,rss=,etimes=,args=", "--sort="+sortBy) + added := 0 + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) < 7 { + continue + } + pid, err := strconv.Atoi(fields[0]) + if err != nil || seen[pid] { + continue + } + seen[pid] = true + cpu, _ := strconv.ParseFloat(fields[3], 64) + rss, _ := strconv.ParseUint(fields[4], 10, 64) + elapsed, _ := strconv.ParseInt(fields[5], 10, 64) + item := AgentProcess{PID: pid, Name: fields[1], State: fields[2], CPUPercent: cpu, MemoryBytes: rss * 1024, Elapsed: elapsed, Command: strings.Join(fields[6:], " ")} + item.ReadBytes, item.WriteBytes = readAgentProcessIO(pid) + result = append(result, item) + added++ + if added >= 12 || len(result) >= 24 { + break + } + } + } + return result +} + +func readAgentProcessIO(pid int) (uint64, uint64) { + data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "io")) + if err != nil { + return 0, 0 + } + values := map[string]uint64{} + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + values[strings.TrimSuffix(fields[0], ":")], _ = strconv.ParseUint(fields[1], 10, 64) + } + return values["read_bytes"], values["write_bytes"] +} + +func collectAgentFilesystems() []AgentFilesystem { + file, err := os.Open("/proc/self/mounts") + if err != nil { + return nil + } + defer file.Close() + excluded := map[string]bool{"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "tmpfs": true, "cgroup": true, "cgroup2": true, "overlay": true, "squashfs": true, "nsfs": true, "tracefs": true, "debugfs": true, "securityfs": true, "pstore": true, "mqueue": true, "hugetlbfs": true, "fusectl": true, "configfs": true, "autofs": true, "rpc_pipefs": true} + seen := map[string]bool{} + result := []AgentFilesystem{} + scanner := bufio.NewScanner(file) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 3 || excluded[fields[2]] || seen[fields[1]] { + continue + } + mountpoint := strings.ReplaceAll(fields[1], `\040`, " ") + var stat syscall.Statfs_t + if syscall.Statfs(mountpoint, &stat) != nil || stat.Blocks == 0 { + continue + } + seen[mountpoint] = true + total := stat.Blocks * uint64(stat.Bsize) + free := stat.Bavail * uint64(stat.Bsize) + used := total - free + item := AgentFilesystem{Device: fields[0], Mountpoint: mountpoint, Type: fields[2], TotalBytes: total, UsedBytes: used, UsedPercent: float64(used) / float64(total) * 100, InodesTotal: stat.Files} + if stat.Files > stat.Ffree { + item.InodesUsed = stat.Files - stat.Ffree + item.InodesPercent = float64(item.InodesUsed) / float64(stat.Files) * 100 + } + result = append(result, item) + if len(result) >= 30 { + break + } + } + sort.Slice(result, func(i, j int) bool { return result[i].Mountpoint < result[j].Mountpoint }) + return result +} + +var ssPIDPattern = regexp.MustCompile(`pid=([0-9]+)`) +var ssNamePattern = regexp.MustCompile(`users:\(\(\"([^\"]+)\"`) + +func collectAgentListenPorts() []AgentListenPort { + output := runAgentCommand(5*time.Second, "ss", "-H", "-lntup") + result := []AgentListenPort{} + seen := map[string]bool{} + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + protocol := strings.ToLower(fields[0]) + local := "" + for _, field := range fields[1:] { + if strings.Contains(field, ":") && !strings.Contains(field, "users:") { + local = field + break + } + } + if local == "" { + continue + } + address, portText, err := net.SplitHostPort(local) + if err != nil { + idx := strings.LastIndex(local, ":") + if idx < 0 { + continue + } + address, portText = local[:idx], local[idx+1:] + } + port, _ := strconv.Atoi(portText) + if port <= 0 { + continue + } + pid := 0 + if match := ssPIDPattern.FindStringSubmatch(line); len(match) > 1 { + pid, _ = strconv.Atoi(match[1]) + } + process := "" + if match := ssNamePattern.FindStringSubmatch(line); len(match) > 1 { + process = match[1] + } + key := protocol + "|" + address + "|" + portText + "|" + strconv.Itoa(pid) + if seen[key] { + continue + } + seen[key] = true + result = append(result, AgentListenPort{Protocol: protocol, Address: address, Port: port, PID: pid, Process: process}) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Port == result[j].Port { + return result[i].Protocol < result[j].Protocol + } + return result[i].Port < result[j].Port + }) + return result +} + +func collectAgentPressure() AgentPressure { + return AgentPressure{CPU: readPressure("/proc/pressure/cpu"), Memory: readPressure("/proc/pressure/memory"), IO: readPressure("/proc/pressure/io")} +} + +func readPressure(path string) AgentPressureValue { + data, err := os.ReadFile(path) + if err != nil { + return AgentPressureValue{} + } + line := strings.SplitN(string(data), "\n", 2)[0] + value := AgentPressureValue{} + for _, field := range strings.Fields(line) { + key, raw, ok := strings.Cut(field, "=") + if !ok { + continue + } + number, _ := strconv.ParseFloat(raw, 64) + switch key { + case "avg10": + value.Avg10 = number + case "avg60": + value.Avg60 = number + case "avg300": + value.Avg300 = number + } + } + return value +} diff --git a/agent_test.go b/agent_test.go index 3d3b230..e414c3d 100644 --- a/agent_test.go +++ b/agent_test.go @@ -69,6 +69,21 @@ func TestClassifyAgentService(t *testing.T) { } } +func TestAgentServiceDiagnosticsHelpers(t *testing.T) { + if got := compactSystemdExec(`{ path=/usr/bin/example ; argv[]=/usr/bin/example --serve ; }`); got != "/usr/bin/example" { + t.Fatalf("unexpected executable: %q", got) + } + fields := limitedFields("a b c d", 2) + if len(fields) != 2 || fields[0] != "a" || fields[1] != "b" { + t.Fatalf("unexpected limited fields: %#v", fields) + } + ports := appendUniqueInt([]int{80}, 80) + ports = appendUniqueInt(ports, 443) + if len(ports) != 2 || ports[1] != 443 { + t.Fatalf("unexpected ports: %#v", ports) + } +} + func TestAgentReportAuthenticationAndOfflineState(t *testing.T) { store, err := openStore(":memory:") if err != nil { @@ -110,3 +125,22 @@ func TestAgentProblemsBecomeMaintenanceAwareAlerts(t *testing.T) { t.Fatalf("active=%d planned=%d alerts=%#v", len(active), len(planned), alerts) } } + +func TestAgentDiagnosticsBecomeAlerts(t *testing.T) { + metrics := dashboardMetrics{Agents: []ManagedAgent{{ID: "node", Name: "app", VMID: 105, Online: true, Report: AgentReport{ + Services: []AgentService{{Name: "web.service", State: "active", Flapping: true, Restarts: 8, StartedAt: time.Now().Unix()}}, + Filesystems: []AgentFilesystem{{Mountpoint: "/data", TotalBytes: 1000, UsedBytes: 950, UsedPercent: 95}}, + }}}} + alerts := evaluateAlerts(metrics, defaultThresholds()) + wanted := map[string]bool{"agent-flapping-node-web.service-105": false, "agent-filesystem-node-/data-105": false} + for _, alert := range alerts { + if _, ok := wanted[alert.ID]; ok { + wanted[alert.ID] = true + } + } + for id, found := range wanted { + if !found { + t.Fatalf("missing alert %s in %#v", id, alerts) + } + } +} diff --git a/alerts.go b/alerts.go index ddc3faa..fddd78a 100644 --- a/alerts.go +++ b/alerts.go @@ -215,8 +215,22 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler for _, service := range agent.Report.Services { if service.State == "failed" || service.SubState == "failed" { add("agent-systemd-"+agent.ID+"-"+service.Name+vmSuffix, "critical", "Агенты", agent.Name+": упала служба "+service.Name, fmt.Sprintf("%s · VMID %d.", firstNonEmpty(service.Description, service.SubState), agent.VMID)) + } else if service.Flapping { + add("agent-flapping-"+agent.ID+"-"+service.Name+vmSuffix, "warning", "Агенты", agent.Name+": служба постоянно перезапускается", fmt.Sprintf("%s: %d рестартов, последний запуск %s · VMID %d.", service.Name, service.Restarts, time.Unix(service.StartedAt, 0).Format("15:04:05"), agent.VMID)) } } + for _, filesystem := range agent.Report.Filesystems { + if filesystem.UsedPercent >= 90 { + add("agent-filesystem-"+agent.ID+"-"+filesystem.Mountpoint+vmSuffix, "critical", "Агенты", agent.Name+": заканчивается место "+filesystem.Mountpoint, fmt.Sprintf("Заполнено %.1f%%, свободно %.1f GB · VMID %d.", filesystem.UsedPercent, float64(filesystem.TotalBytes-filesystem.UsedBytes)/1073741824, agent.VMID)) + } + if filesystem.InodesPercent >= 90 { + add("agent-inodes-"+agent.ID+"-"+filesystem.Mountpoint+vmSuffix, "critical", "Агенты", agent.Name+": заканчиваются inode "+filesystem.Mountpoint, fmt.Sprintf("Использовано %.1f%% inode · VMID %d.", filesystem.InodesPercent, agent.VMID)) + } + } + if len(agent.Report.OOMKills) > 0 { + latest := agent.Report.OOMKills[0] + add("agent-oom-"+agent.ID+vmSuffix, "critical", "Агенты", agent.Name+": ядро завершало процессы из-за нехватки памяти", fmt.Sprintf("%s · %s · VMID %d.", time.Unix(latest.Time, 0).Format("02.01 15:04"), latest.Message, agent.VMID)) + } if agent.Report.RebootRequired { add("agent-reboot-"+agent.ID+vmSuffix, "info", "Агенты", agent.Name+": требуется перезагрузка", fmt.Sprintf("Обновления системы запросили перезагрузку · VMID %d.", agent.VMID)) } diff --git a/web.go b/web.go index 10a9136..cda628a 100644 --- a/web.go +++ b/web.go @@ -518,6 +518,7 @@ footer{color:#657581;font-size:12px;margin-top:17px} .monitor .fill{background:linear-gradient(90deg,#55d58a,#43c6d9)}.shutdown-plan{display:grid;gap:8px;margin-top:12px}.shutdown-step{padding:10px 12px;border:1px solid var(--line);border-radius:10px}.infra-status{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:14px 0}.infra-status>div{padding:11px;border:1px solid var(--line);border-radius:10px}.infra-status b{display:block;margin-top:4px}@media(max-width:700px){.infra-status{grid-template-columns:1fr 1fr}} .agents .fill{background:linear-gradient(90deg,#55d58a,#6ba8ff)}.agent-command{display:block;width:100%;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--text);font:12px/1.5 ui-monospace,monospace;overflow-wrap:anywhere;white-space:pre-wrap}.agent-containers{display:flex;flex-wrap:wrap;gap:7px;margin-top:13px} .agent-detail-panel{padding:16px}.agent-detail-panel[hidden]{display:none}.agent-detail-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.agent-detail-summary>div{padding:14px;border:1px solid var(--line);border-radius:12px}.agent-detail-summary b{display:block;margin-top:5px;font:600 16px ui-monospace,monospace}.agent-filter{display:flex;gap:6px;margin-bottom:12px}.agent-filter button{padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);cursor:pointer}.agent-filter button.active{border-color:var(--green);color:var(--green)}@media(max-width:700px){.agent-detail-summary{grid-template-columns:1fr 1fr}} +.diagnostics .fill{background:linear-gradient(90deg,#43c6d9,#9d8cff)}.diagnostic-chain{display:flex;align-items:stretch;gap:8px;overflow-x:auto;padding:3px 0}.diagnostic-node{min-width:150px;padding:11px;border:1px solid var(--line);border-radius:11px;background:var(--panel)}.diagnostic-node.bad{border-color:#71362f}.diagnostic-arrow{align-self:center;color:var(--muted)}.service-log{margin-top:10px;padding:9px 11px;border-left:2px solid var(--line);background:var(--bg);font:11px/1.5 ui-monospace,monospace;overflow-wrap:anywhere}.process-table{display:grid;gap:7px}.process-row{display:grid;grid-template-columns:minmax(140px,2fr) repeat(4,minmax(70px,1fr));gap:8px;padding:10px;border:1px solid var(--line);border-radius:10px;align-items:center}.process-row small{color:var(--muted);overflow-wrap:anywhere}@media(max-width:700px){.process-row{grid-template-columns:1fr 1fr}.agent-filter{overflow-x:auto}}
@@ -618,6 +619,10 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Topology
Карта homelab
узлов
VM / LXC
Сервисы / storage
Proxmox → VM/LXC → сервисы → storage →
+
@@ -730,8 +735,9 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Получение snapshots…
Получение задач…
Порт от локального адреса отбрасывается. Полученный IP сравнивается с адресами VM/LXC из Proxmox. Storage берётся из конфигурации каждой VM и LXC.
+
Связи строятся по VMID, IP, слушающему порту, PID systemd и портам Docker. Предполагаемые связи явно помечаются.
Ожидаем данные агентов…
Подключённые агенты появятся здесь.
-
+
@@ -743,7 +749,7 @@ footer{color:#657581;font-size:12px;margin-top:17px} - +
Порядок карточек
Перетаскивайте строки или используйте стрелки.
@@ -836,16 +842,22 @@ const taskLabel=type=>({vzdump:'Backup',qmstart:'Запуск VM',qmstop:'Ост function explainWarningBadges(){el('snapshotList').querySelectorAll('.badge.warn').forEach(v=>{v.tabIndex=0;v.dataset.reason='Snapshot старше 30 дней. Проверьте, нужен ли он: старые снимки могут занимать место в storage.'});el('taskList').querySelectorAll('.badge.bad').forEach(v=>{v.tabIndex=0;v.dataset.reason='Задача Proxmox завершилась не со статусом OK. Откройте её журнал в интерфейсе Proxmox для подробностей.'})} let currentTasks=[],currentTaskFilter='all'; function renderTasks(tasks,filter='all'){currentTasks=tasks;const shown=tasks.filter(v=>filter==='all'||filter==='error'&&v.status!=='OK'||filter==='qm'&&(/^(qm|vz)/.test(v.type))||v.type===filter);el('taskList').innerHTML=shown.length?shown.map(v=>'
'+safe(taskLabel(v.type))+(v.id?' · '+safe(v.id):'')+'
'+new Date(v.starttime*1000).toLocaleString('ru-RU')+' · '+safe(v.user||'—')+'
'+safe(v.status||'Выполняется')+'
Длительность'+uptime(v.duration||Math.max(0,Date.now()/1000-v.starttime))+'
Узел'+safe(v.node)+'
VMID'+safe(v.id||'—')+'
').join(''):'Задач этого типа нет.'} -function renderActivity(activity,guests,services,zfs){const snapshots=activity.snapshots||[],tasks=activity.tasks||[],old=snapshots.filter(v=>v.ageSeconds>30*86400),guestCount=new Set(snapshots.map(v=>v.vmid)).size,failed=tasks.filter(v=>v.status&&v.status!=='OK');el('snapshotCount').textContent=snapshots.length;el('oldSnapshots').textContent=old.length;el('snapshotGuests').textContent=guestCount;el('snapshotBar').style.width=Math.min(old.length*20,100)+'%';el('snapshotList').innerHTML=snapshots.length?snapshots.map(v=>'
'+safe(v.guestName||('VMID '+v.vmid))+' · '+safe(v.name)+'
VMID '+v.vmid+' · '+safe(v.guestType)+(v.createdAt?' · '+new Date(v.createdAt*1000).toLocaleString('ru-RU'):'')+'
'+(v.ageSeconds?uptime(v.ageSeconds):'Без даты')+'
Размер'+(v.sizeBytes?size(v.sizeBytes):'Storage не сообщает')+'
Состояние VM'+(v.running?'Запущена':'Остановлена')+'
Описание'+safe(v.description||'—')+'
').join(''):'Snapshots не найдены.';el('taskCount').textContent=tasks.length;el('failedTasks').textContent=failed.length;el('lastTask').textContent=tasks.length?taskLabel(tasks[0].type):'—';el('taskBar').style.width=Math.min(failed.length*20,100)+'%';renderTasks(tasks,currentTaskFilter);renderHomelabMap(guests,services,zfs)} +function renderActivity(activity,guests,services,zfs){const snapshots=activity.snapshots||[],tasks=activity.tasks||[],old=snapshots.filter(v=>v.ageSeconds>30*86400),guestCount=new Set(snapshots.map(v=>v.vmid)).size,failed=tasks.filter(v=>v.status&&v.status!=='OK');el('snapshotCount').textContent=snapshots.length;el('oldSnapshots').textContent=old.length;el('snapshotGuests').textContent=guestCount;el('snapshotBar').style.width=Math.min(old.length*20,100)+'%';el('snapshotList').innerHTML=snapshots.length?snapshots.map(v=>'
'+safe(v.guestName||('VMID '+v.vmid))+' · '+safe(v.name)+'
VMID '+v.vmid+' · '+safe(v.guestType)+(v.createdAt?' · '+new Date(v.createdAt*1000).toLocaleString('ru-RU'):'')+'
'+(v.ageSeconds?uptime(v.ageSeconds):'Без даты')+'
Размер'+(v.sizeBytes?size(v.sizeBytes):'Storage не сообщает')+'
Состояние VM'+(v.running?'Запущена':'Остановлена')+'
Описание'+safe(v.description||'—')+'
').join(''):'Snapshots не найдены.';el('taskCount').textContent=tasks.length;el('failedTasks').textContent=failed.length;el('lastTask').textContent=tasks.length?taskLabel(tasks[0].type):'—';el('taskBar').style.width=Math.min(failed.length*20,100)+'%';renderTasks(tasks,currentTaskFilter);renderHomelabMapV2(guests,services,zfs)} function topologyHost(address){if(!address)return '';try{return new URL(address.includes('://')?address:'http://'+address).hostname.replace(/^\[|\]$/g,'')}catch(e){return address.replace(/^[a-z]+:\/\//i,'').split('/')[0].replace(/:\d+$/,'')}} +function topologyPort(address){if(!address)return 0;try{const u=new URL(address.includes('://')?address:'http://'+address);return Number(u.port)||(u.protocol==='https:'?443:u.protocol==='http:'?80:0)}catch(e){const match=address.match(/:([0-9]+)(?:\/|$)/);return match?Number(match[1]):0}} +function correlateMonitoredService(service,agents){const address=service.localAddress||service.url||'',host=topologyHost(address),port=topologyPort(address),agent=(agents||[]).find(a=>(a.report?.interfaces||[]).some(i=>(i.addresses||[]).includes(host))),r=agent?.report||{},listener=(r.listenPorts||[]).find(v=>v.port===port),unit=(r.services||[]).find(v=>(v.listenPorts||[]).includes(port)||(listener?.pid&&v.mainPid===listener.pid)),container=(r.containers||[]).find(v=>(v.ports||[]).some(p=>Number(String(p).split(':')[0])===port)),nameHint=[...(r.services||[]),...(r.containers||[])].find(v=>String(service.name||'').toLowerCase().includes(String(v.name||'').replace(/\.service$/,'').toLowerCase())||String(v.name||'').toLowerCase().includes(String(service.name||'').toLowerCase()));return {agent,host,port,listener,component:unit||container||nameHint,confidence:agent?(unit||container||listener?100:nameHint?65:45):0}} +function renderUnifiedDiagnostics(agents,services){const serviceItems=services.services||[],links=serviceItems.map(s=>({service:s,link:correlateMonitoredService(s,agents)})),agentProblems=(agents||[]).filter(a=>!a.online||(a.report?.services||[]).some(v=>v.state==='failed'||v.flapping)||(a.report?.containers||[]).some(v=>v.state!=='running'||v.health==='unhealthy')||(a.report?.oomKills||[]).length),serviceProblems=serviceItems.filter(s=>!s.up),problems=agentProblems.length+serviceProblems.length;el('diagnosticProblems').textContent=problems;el('diagnosticLinks').textContent=links.filter(v=>v.link.agent).length+' / '+links.length;el('diagnosticAgents').textContent=agentProblems.length;el('diagnosticBar').style.width=Math.min(problems*20,100)+'%';const chainHTML=({service:s,link:l})=>{const component=l.component,componentName=component?.name||l.listener?.process||'Компонент не определён',bad=!s.up||!l.agent?.online||component?.state==='failed'||component?.health==='unhealthy';return '
'+safe(s.name)+'
'+safe(s.localAddress||s.url||'Адрес не указан')+'
'+(bad?'Есть проблема':l.confidence===100?'Связь подтверждена':'Предположение · '+l.confidence+'%')+'
VM / LXC'+safe(l.agent?.name||'Не сопоставлено')+'
'+(l.agent?'VMID '+l.agent.vmid+' · '+(l.agent.online?'онлайн':'агент offline'):safe(l.host||'IP неизвестен'))+'
Процесс / служба'+safe(componentName)+'
'+(l.listener?'PID '+(l.listener.pid||'—'):'По имени или порту')+'
Порт'+(l.port||'—')+'
'+safe(l.listener?.protocol||s.checkType||'')+'
Сервис'+(s.up?'Доступен':'Недоступен')+'
'+fixed(s.latencyMs||0)+' ms
'};const unmappedAgents=(agents||[]).filter(a=>!links.some(v=>v.link.agent?.id===a.id));el('diagnosticList').innerHTML=(links.length?links.map(chainHTML).join(''):'Добавьте отслеживаемые сервисы, чтобы построить цепочки.')+(unmappedAgents.length?'
Агенты без привязанных веб-сервисов
'+unmappedAgents.map(a=>''+safe(a.name)+' · VMID '+a.vmid+'').join('')+'
':'')} function renderHomelabMap(guests,services,zfs){const guestItems=(guests.guests||[]).filter(v=>!v.template),serviceItems=services.services||[],pools=zfs.pools||[],serviceOwners=new Map();serviceItems.forEach(s=>{const host=topologyHost(s.localAddress||'');const owner=guestItems.find(g=>(g.ipAddresses||[]).some(ip=>ip===host));serviceOwners.set(s.id,owner?.vmid||0)});const serviceChip=s=>{const healthy=(s.endpoints||[]).every(v=>v.up);return ''+safe(s.name)+''},storageChip=name=>{const pool=pools.find(p=>p.name===name),healthy=!pool||pool.health==='ONLINE';return ''+safe(name)+(pool?' · '+fixed(pool.capacityPercent)+'%':'')+''};const groups=guestItems.map(g=>{const owned=serviceItems.filter(s=>serviceOwners.get(s.id)===g.vmid),storage=[...new Set(g.storage||[])],running=g.status==='running';return '
'+safe(g.name||('VMID '+g.vmid))+'
VMID '+g.vmid+' · '+safe(g.type==='qemu'?'VM':'LXC')+' · '+safe((g.ipAddresses||[]).join(', ')||'IP не получен')+'
'+(running?'Работает':'Остановлена')+'
Сервисы
'+(owned.length?owned.map(serviceChip).join(''):'Не сопоставлены')+'
Storage
'+(storage.length?storage.map(storageChip).join(''):'Не определён')+'
'}).join(''),unassigned=serviceItems.filter(s=>!serviceOwners.get(s.id)),usedStorage=new Set(guestItems.flatMap(g=>g.storage||[])),unassignedPools=pools.filter(p=>!usedStorage.has(p.name));el('mapBoard').innerHTML='
Proxmox host'+safe(location.hostname)+' · '+guestItems.length+' VM/LXC
'+groups+'
'+((unassigned.length||unassignedPools.length)?'
Без автоматической привязкиПроверьте связи
Сервисы
'+unassigned.map(serviceChip).join('')+'
Остальные ZFS-пулы
'+unassignedPools.map(p=>storageChip(p.name)).join('')+'
':'');const storage=new Set([...usedStorage,...pools.map(p=>p.name)]);el('mapGuests').textContent=guestItems.length;el('mapResources').textContent=serviceItems.length+' / '+storage.size;el('mapNodes').textContent=1+guestItems.length+serviceItems.length+storage.size;el('mapBar').style.width='100%'} +function renderHomelabMapV2(guests,services,zfs){const guestItems=(guests.guests||[]).filter(v=>!v.template),serviceItems=services.services||[],pools=zfs.pools||[],agentByVM=new Map(currentAgents.map(a=>[a.vmid,a])),links=new Map(serviceItems.map(s=>[s.id,correlateMonitoredService(s,currentAgents)])),storageChip=name=>{const pool=pools.find(p=>p.name===name),healthy=!pool||pool.health==='ONLINE';return ''+safe(name)+(pool?' · '+fixed(pool.capacityPercent)+'%':'')+''};const groups=guestItems.map(g=>{const agent=agentByVM.get(g.vmid),owned=serviceItems.filter(s=>links.get(s.id).agent?.vmid===g.vmid||(g.ipAddresses||[]).includes(links.get(s.id).host)),storage=[...new Set(g.storage||[])],running=g.status==='running',servicesHTML=owned.map(s=>{const link=links.get(s.id),healthy=s.up,component=link.component?.name||link.listener?.process;return ''+safe(s.name)+(component?' ← '+safe(component):'')+(link.port?' · :'+link.port:'')+''}).join('');return '
'+safe(g.name||('VMID '+g.vmid))+'
VMID '+g.vmid+' · '+safe(g.type==='qemu'?'VM':'LXC')+' · '+safe((g.ipAddresses||[]).join(', ')||'IP не получен')+'
'+(!running?'Остановлена':agent&&!agent.online?'Агент offline':'Работает')+'
Службы / Docker → порт → сервис
'+(servicesHTML||'Не сопоставлены')+'
Storage
'+(storage.length?storage.map(storageChip).join(''):'Не определён')+'
'}).join(''),assigned=new Set(serviceItems.filter(s=>links.get(s.id).agent).map(s=>s.id)),unassigned=serviceItems.filter(s=>!assigned.has(s.id)),usedStorage=new Set(guestItems.flatMap(g=>g.storage||[])),unassignedPools=pools.filter(p=>!usedStorage.has(p.name));el('mapBoard').innerHTML='
Proxmox host'+safe(location.hostname)+' · '+guestItems.length+' VM/LXC · '+currentAgents.length+' агентов
'+groups+'
'+((unassigned.length||unassignedPools.length)?'
Без автоматической привязкиНедостаточно данных
Сервисы
'+unassigned.map(s=>''+safe(s.name)+'').join('')+'
Остальные ZFS-пулы
'+unassignedPools.map(p=>storageChip(p.name)).join('')+'
':'');const storage=new Set([...usedStorage,...pools.map(p=>p.name)]);el('mapGuests').textContent=guestItems.length;el('mapResources').textContent=serviceItems.length+' / '+storage.size;el('mapNodes').textContent=1+guestItems.length+serviceItems.length+storage.size;el('mapBar').style.width='100%'} function agentHTML(a,settings=false){ const r=a.report||{},containers=r.containers||[],services=r.services||[],issues=r.issues||[],failed=services.filter(v=>v.state==='failed'||v.subState==='failed'),interfaces=r.interfaces||[],memoryPercent=r.memoryTotal?r.memoryUsed/r.memoryTotal*100:0,diskPercent=r.rootTotal?r.rootUsed/r.rootTotal*100:0; return '
'+safe(a.name||a.hostname)+'
'+safe(a.hostname)+' · VMID '+(a.vmid||'не указан')+' · '+safe(r.osName||r.os||'Linux')+' · '+safe(r.kernel||'ядро не определено')+' · агент '+safe(a.version||'—')+'
'+(a.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':'')+'
Проблемы'+failed.length+' служб · '+issues.length+' ошибок
IP-адреса и интерфейсы
'+(interfaces.length?interfaces.map(v=>''+safe(v.name)+' · '+safe((v.addresses||[]).join(', '))+'').join(''):'IP-адреса не найдены')+'
'+(settings?'':'')+'
' } let currentAgents=[];function renderAgents(items){const agents=currentAgents=items||[],online=agents.filter(v=>v.online),containers=agents.flatMap(v=>(v.report&&v.report.containers)||[]),failedServices=agents.flatMap(v=>(v.report&&v.report.services)||[]).filter(v=>v.state==='failed'||v.subState==='failed'),problems=(agents.length-online.length)+failedServices.length+containers.filter(v=>v.state!=='running'||v.health==='unhealthy').length;el('agentOnline').textContent=online.length;el('agentTotal').textContent=' / '+agents.length;el('agentBar').style.width=(agents.length?online.length/agents.length*100:0)+'%';el('agentContainers').textContent=containers.length;el('agentProblems').textContent=problems;el('agentList').innerHTML=agents.length?agents.map(v=>agentHTML(v)).join(''):'Агенты пока не подключены. Создайте команду установки в разделе «Управление».';el('agentSettingsList').innerHTML=agents.length?agents.map(v=>agentHTML(v,true)).join(''):'Агентов пока нет.'} function renderAgentServices(services,filter='all'){const failed=v=>v.state==='failed'||v.subState==='failed',custom=v=>['added','local','runtime'].includes(v.origin),filtered=(services||[]).filter(v=>filter==='all'||filter==='failed'&&failed(v)||filter==='system'&&v.origin==='system'||filter==='custom'&&custom(v)),originLabel=v=>v.origin==='local'?'Локальная':v.origin==='added'?'Добавленный пакет':v.origin==='runtime'?'Временная':'Системная';el('agentServiceDetails').innerHTML='
'+(filtered.length?filtered.map(v=>'
'+safe(v.name)+'
'+safe(v.description||'Описание отсутствует')+'
'+originLabel(v)+(v.package?' · пакет '+safe(v.package):'')+(v.unitPath?' · '+safe(v.unitPath):'')+'
'+safe(v.subState||v.state)+'
Запущена'+(v.startedAt?new Date(v.startedAt*1000).toLocaleString('ru-RU'):'—')+'
Рестарты'+(v.restarts||0)+'
Exit code'+(v.exitCode||0)+'
Память'+(v.memoryBytes?size(v.memoryBytes):'Нет accounting')+'
CPU time'+(v.cpuSeconds?fixed(v.cpuSeconds)+' сек.':'Нет accounting')+'
').join(''):'Служб в этой группе нет.')+'
';el('agentServiceDetails').querySelectorAll('[data-agent-service-filter]').forEach(button=>button.onclick=()=>renderAgentServices(services,button.dataset.agentServiceFilter))} -function openAgentDetail(agent){const r=agent.report||{},services=r.services||[],issues=r.issues||[],containers=r.containers||[],interfaces=r.interfaces||[],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'):'ещё не собран')+'
';renderAgentServices(services);el('agentIssueDetails').innerHTML='
'+(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()} +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()} +const openAgentDetailBase=openAgentDetail;openAgentDetail=function(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)} function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{available:false,pools:[]},u=x.ups||{available:false},g=x.guests||{available:false,guests:[]},b=x.backups||{available:false,items:[]},mt=x.maintenance||{},alerts=x.alerts||[],disks=x.disks||[],avg=c.temperatures.length?c.temperatures.reduce((a,t)=>a+t.celsius,0)/c.temperatures.length:null; lastDashboardEvent=Date.now(); maintenanceGuests=(g.guests||[]).filter(v=>!v.template);applyMaintenanceState(x.maintenanceWindow||{}); @@ -873,7 +885,7 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av const svc=x.services||{services:[],up:0,total:0};renderServices(svc); const sh=x.systemHealth||{},issueCount=sh.systemErrors||0;el('systemIssues').textContent=issueCount;el('systemBar').style.width=Math.min(issueCount*10,100)+'%';el('failedUnits').textContent=sh.failedUnits??'—';el('kernelErrors').textContent=sh.kernelErrors??'—';el('dSystemErrors').textContent=issueCount;el('dKernelErrors').textContent=sh.kernelErrors||0;el('dFailedUnits').textContent=sh.failedUnits||0;renderSystemHealth(sh); renderNetworkGroups(n.interfaces||[]); - renderActivity(x.activity||{},g,svc,z);renderIOAttribution(disks);explainWarningBadges(); + renderActivity(x.activity||{},g,svc,z);renderUnifiedDiagnostics(x.agents||[],svc);renderIOAttribution(disks);explainWarningBadges(); el('dot').classList.add('live');el('status').textContent='Данные поступают'; } const percent=v=>fixed(v)+'%',temperature=v=>fixed(v)+' °C',megabytes=v=>fixed(v)+' MB/s'; @@ -899,7 +911,7 @@ el('backupList').onclick=async event=>{const button=event.target.closest('[data- el('alertList').onclick=async event=>{const button=event.target.closest('[data-ack]');if(!button)return;button.disabled=true;const response=await fetch('/api/alerts/acknowledge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({eventId:Number(button.dataset.ack)})});if(response.ok)button.closest('.alert-item').remove();else button.disabled=false}; async function loadAlertHistory(){const response=await fetch('/api/alerts/history');if(!response.ok)return;const events=await response.json()||[];el('alertHistory').innerHTML=events.length?events.map(a=>{const state=a.planned?'Плановые работы · '+safe(a.maintenanceReason||'Причина не указана')+(a.resolvedAt?' · завершено '+new Date(a.resolvedAt*1000).toLocaleString('ru-RU'):''):a.acknowledgedAt?'Принято · '+new Date(a.acknowledgedAt*1000).toLocaleString('ru-RU'):a.resolvedAt?'Завершено · '+new Date(a.resolvedAt*1000).toLocaleString('ru-RU'):'Активно';return '
'+safe(a.source)+' · '+new Date(a.firstSeen*1000).toLocaleString('ru-RU')+'
'+safe(a.title)+'
'+safe(a.message)+'
'+state+'
'}).join(''):'
История пока пустаНовые события появятся здесь автоматически.
'} document.querySelectorAll('[data-alert-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-alert-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-alert-panel]').forEach(p=>p.hidden=p.dataset.alertPanel!==tab.dataset.alertTab);if(tab.dataset.alertTab==='history')loadAlertHistory()}); -const cardNames=['alerts','cpu','memory','storage','network','zfs','ups','monitor','guests','agents','maintenance','backups','services','system','snapshots','tasks','map'],cardLabels={alerts:'Центр уведомлений',cpu:'CPU',memory:'Память',storage:'Диски',network:'Сеть',zfs:'ZFS',ups:'UPS',monitor:'Самоконтроль',guests:'VM и LXC',agents:'Агенты VM и LXC',maintenance:'Proxmox',backups:'Резервные копии',services:'Сервисы',system:'Система и ядро',snapshots:'Snapshots',tasks:'Задачи Proxmox',map:'Карта homelab'};let hiddenCards=[],cardOrder=[];try{hiddenCards=JSON.parse(localStorage.getItem('proxmox-hidden-cards')||'[]');cardOrder=JSON.parse(localStorage.getItem('proxmox-card-order')||'[]')}catch(e){}cardOrder=[...cardOrder.filter(x=>cardNames.includes(x)),...cardNames.filter(x=>!cardOrder.includes(x))]; +const cardNames=['alerts','cpu','memory','storage','network','zfs','ups','monitor','guests','agents','maintenance','backups','services','system','snapshots','tasks','map','diagnostics'],cardLabels={alerts:'Центр уведомлений',cpu:'CPU',memory:'Память',storage:'Диски',network:'Сеть',zfs:'ZFS',ups:'UPS',monitor:'Самоконтроль',guests:'VM и LXC',agents:'Агенты VM и LXC',maintenance:'Proxmox',backups:'Резервные копии',services:'Сервисы',system:'Система и ядро',snapshots:'Snapshots',tasks:'Задачи Proxmox',map:'Карта homelab',diagnostics:'Диагностика homelab'};let hiddenCards=[],cardOrder=[];try{hiddenCards=JSON.parse(localStorage.getItem('proxmox-hidden-cards')||'[]');cardOrder=JSON.parse(localStorage.getItem('proxmox-card-order')||'[]')}catch(e){}cardOrder=[...cardOrder.filter(x=>cardNames.includes(x)),...cardNames.filter(x=>!cardOrder.includes(x))]; function renderCardOrder(){el('cardOrder').innerHTML=cardOrder.map(name=>'
'+cardLabels[name]+'
').join('')} function saveCardOrder(){localStorage.setItem('proxmox-card-order',JSON.stringify(cardOrder));applyCardSettings();renderCardOrder()} function applyCardSettings(){const container=document.querySelector('.cards');cardOrder.forEach(name=>{const card=document.querySelector('[data-card="'+name+'"]');if(card)container.appendChild(card)});cardNames.forEach(name=>{const visible=!hiddenCards.includes(name),card=document.querySelector('[data-card="'+name+'"]'),toggle=document.querySelector('[data-card-toggle="'+name+'"]');if(card)card.hidden=!visible;if(toggle)toggle.checked=visible})}