diff --git a/agent.go b/agent.go index ab4c6e3..4f49aba 100644 --- a/agent.go +++ b/agent.go @@ -41,18 +41,22 @@ 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"` - Compose bool `json:"compose"` - UpdateAvailable bool `json:"updateAvailable"` + 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"` + CurrentVersion string `json:"currentVersion,omitempty"` + CurrentDigest string `json:"currentDigest,omitempty"` + AvailableVersion string `json:"availableVersion,omitempty"` + AvailableDigest string `json:"availableDigest,omitempty"` } type AgentInterface struct { @@ -99,6 +103,7 @@ type AgentReport struct { UptimeSeconds int64 `json:"uptimeSeconds"` Load1 float64 `json:"load1"` CPUPercent float64 `json:"cpuPercent"` + CPUCount int `json:"cpuCount"` MemoryTotal uint64 `json:"memoryTotal"` MemoryUsed uint64 `json:"memoryUsed"` SwapTotal uint64 `json:"swapTotal"` @@ -394,7 +399,9 @@ func runGuestAgent(opts agentOptions) error { if requestErr != nil { fmt.Fprintf(os.Stderr, "agent: %v\n", requestErr) } - time.Sleep(10 * time.Second) + // Короткий интервал нужен для интерактивных команд из панели. Тяжёлый + // системный инвентарь отдельно кэшируется и не запускается каждые 3 секунды. + time.Sleep(3 * time.Second) } } @@ -530,7 +537,7 @@ func saveAgentConfig(path string, c agentConfig) error { func collectAgentReport() AgentReport { hostname, _ := os.Hostname() machineID, _ := os.ReadFile("/etc/machine-id") - report := AgentReport{Hostname: hostname, MachineID: strings.TrimSpace(string(machineID)), Version: version, OS: runtime.GOOS, Arch: runtime.GOARCH, CollectedAt: time.Now().Unix()} + report := AgentReport{Hostname: hostname, MachineID: strings.TrimSpace(string(machineID)), Version: version, OS: runtime.GOOS, Arch: runtime.GOARCH, CPUCount: runtime.NumCPU(), CollectedAt: time.Now().Unix()} if data, err := os.ReadFile("/proc/uptime"); err == nil { fields := strings.Fields(string(data)) if len(fields) > 0 { @@ -929,14 +936,10 @@ func collectDockerContainers() ([]AgentContainer, string) { 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() - } + item.CurrentVersion, item.CurrentDigest, _ = inspectDockerImage(client, detail.Image, v.Image) + var availableID string + item.AvailableVersion, item.AvailableDigest, availableID = inspectDockerImage(client, v.Image, v.Image) + item.UpdateAvailable = detail.Image != "" && availableID != "" && detail.Image != availableID } inspect.Body.Close() } @@ -987,6 +990,46 @@ func collectDockerContainers() ([]AgentContainer, string) { } return result, "" } + +func inspectDockerImage(client *http.Client, reference, fallback string) (string, string, string) { + if reference == "" { + return "", "", "" + } + response, err := client.Get("http://docker/images/" + url.PathEscape(reference) + "/json") + if err != nil { + return "", "", "" + } + defer response.Body.Close() + var detail struct { + ID string + RepoTags []string + RepoDigests []string + Config struct{ Labels map[string]string } + } + if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&detail) != nil { + return "", "", "" + } + version := firstNonEmpty(detail.Config.Labels["org.opencontainers.image.version"], detail.Config.Labels["version"], firstString(detail.RepoTags), fallback, shortDockerDigest(detail.ID)) + digest := "" + if len(detail.RepoDigests) > 0 { + digest = detail.RepoDigests[0] + if _, value, found := strings.Cut(digest, "@"); found { + digest = value + } + } + if digest == "" { + digest = detail.ID + } + return version, shortDockerDigest(digest), detail.ID +} + +func shortDockerDigest(value string) string { + value = strings.TrimPrefix(value, "sha256:") + if len(value) > 12 { + value = value[:12] + } + return value +} func firstString(v []string) string { if len(v) > 0 { return v[0] diff --git a/agent_actions.go b/agent_actions.go index 561be56..75de612 100644 --- a/agent_actions.go +++ b/agent_actions.go @@ -63,10 +63,10 @@ func (s *Store) QueueAgentAction(agentID, action, target string) (AgentAction, e } } } - case "docker-restart", "docker-check", "docker-update": + case "docker-restart", "docker-check", "docker-update", "docker-remove": if safeContainerName.MatchString(target) { for _, container := range report.Containers { - if container.ID == target || container.Name == target { + if (container.ID == target || container.Name == target) && (action != "docker-remove" || container.State != "running") { valid = true break } @@ -168,6 +168,8 @@ func executeAgentCommand(command AgentCommand) AgentActionResult { output = updateAgentContainer(command.Target, true) case "docker-check": output = updateAgentContainer(command.Target, false) + case "docker-remove": + output = removeAgentContainer(command.Target) case "system-update": output = updateAgentSystem() case "updates-refresh": @@ -189,6 +191,24 @@ func executeAgentCommand(command AgentCommand) AgentActionResult { return result } +func removeAgentContainer(target string) 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() + request, _ := http.NewRequest(http.MethodDelete, "http://docker/containers/"+target+"?v=false&force=false", nil) + response, err := (&http.Client{Transport: transport, Timeout: 20 * time.Second}).Do(request) + if err != nil { + return "ERROR: " + err.Error() + } + defer response.Body.Close() + if response.StatusCode != http.StatusNoContent { + message, _ := io.ReadAll(io.LimitReader(response.Body, 2048)) + return fmt.Sprintf("ERROR: Docker вернул %s: %s", response.Status, strings.TrimSpace(string(message))) + } + return "Остановленный контейнер удалён; volumes сохранены" +} + func updateAgentSystem() string { if output := runAgentCombinedCommand(3*time.Minute, "apt-get", "update"); strings.HasPrefix(output, "ERROR: ") { return output diff --git a/agent_actions_test.go b/agent_actions_test.go index c7eb45b..d398a5d 100644 --- a/agent_actions_test.go +++ b/agent_actions_test.go @@ -12,7 +12,7 @@ func TestAgentActionAllowlistAndLifecycle(t *testing.T) { t.Fatal(err) } defer store.Close() - report, _ := json.Marshal(AgentReport{Services: []AgentService{{Name: "gitea.service"}}, Containers: []AgentContainer{{ID: "abc123", Name: "gitea"}}}) + report, _ := json.Marshal(AgentReport{Services: []AgentService{{Name: "gitea.service"}}, Containers: []AgentContainer{{ID: "abc123", Name: "gitea", State: "running"}, {ID: "old123", Name: "old", State: "exited"}}}) _, err = store.db.Exec(`INSERT INTO agents(id,name,secret_hash,enrolled_at,last_seen,report_json) VALUES('a','agent','x',?,?,?)`, time.Now().Unix(), time.Now().Unix(), string(report)) if err != nil { t.Fatal(err) @@ -26,9 +26,12 @@ func TestAgentActionAllowlistAndLifecycle(t *testing.T) { 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 = store.QueueAgentAction("a", "docker-remove", "gitea"); err == nil { + t.Fatal("running/unknown-state container accepted for removal") + } + action, err := store.QueueAgentAction("a", "docker-remove", "old") if err != nil { - t.Fatal(err) + t.Fatalf("stopped container rejected for removal: %v", err) } commands, err := store.DispatchAgentActions("a") if err != nil || len(commands) != 3 || commands[2].ID != action.ID { diff --git a/agent_diagnostics.go b/agent_diagnostics.go index db28873..45fb6ad 100644 --- a/agent_diagnostics.go +++ b/agent_diagnostics.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "regexp" + "runtime" "sort" "strconv" "strings" @@ -25,6 +26,8 @@ type AgentProcess struct { Name string `json:"name"` State string `json:"state"` CPUPercent float64 `json:"cpuPercent"` + CPUCores float64 `json:"cpuCores"` + HostPercent float64 `json:"hostPercent"` MemoryBytes uint64 `json:"memoryBytes"` ReadBytes uint64 `json:"readBytes"` WriteBytes uint64 `json:"writeBytes"` @@ -174,7 +177,11 @@ func collectAgentProcesses() []AgentProcess { 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:], " ")} + cpuCount := runtime.NumCPU() + if cpuCount < 1 { + cpuCount = 1 + } + item := AgentProcess{PID: pid, Name: fields[1], State: fields[2], CPUPercent: cpu, CPUCores: cpu / 100, HostPercent: cpu / float64(cpuCount), MemoryBytes: rss * 1024, Elapsed: elapsed, Command: strings.Join(fields[6:], " ")} item.ReadBytes, item.WriteBytes = readAgentProcessIO(pid) result = append(result, item) added++ diff --git a/agent_test.go b/agent_test.go index e414c3d..010be8c 100644 --- a/agent_test.go +++ b/agent_test.go @@ -53,6 +53,12 @@ func TestAgentOnlyInstallsNewerVersion(t *testing.T) { } } +func TestShortDockerDigest(t *testing.T) { + if got := shortDockerDigest("sha256:1234567890abcdef"); got != "1234567890ab" { + t.Fatalf("unexpected short digest: %q", got) + } +} + func TestParseAgentIssues(t *testing.T) { issues := parseAgentIssues(`{"MESSAGE":"disk error","_SYSTEMD_UNIT":"worker.service","__REALTIME_TIMESTAMP":"1722960000000000"}` + "\n" + `{"MESSAGE":"network error","__REALTIME_TIMESTAMP":"1722960001000000"}`) if len(issues) != 2 || issues[0].Unit != "worker.service" || issues[1].Unit != "system" || issues[0].Time != 1722960000 { diff --git a/web.go b/web.go index 5ca4e91..934288c 100644 --- a/web.go +++ b/web.go @@ -895,6 +895,7 @@ const size=n=>{if(!n)return '0 B';const u=['B','KB','MB','GB','TB'],i=Math.min(M const rate=n=>size(n)+'/s'; const safe=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const uptime=s=>{const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);return d?d+'д '+h+'ч':h?h+'ч '+m+'м':m+'м'}; +const processCPU=v=>fixed(v.cpuCores??((v.cpuPercent||0)/100))+' ядра · '+fixed(v.hostPercent??v.cpuPercent??0)+'% хоста'; let alertSettings={backupMaxAgeHours:48}; let maintenanceWindow={},maintenanceGuests=[],maintenanceTargetSignature=''; let lastDashboardEvent=0; @@ -932,9 +933,10 @@ const renderAgentsBase=renderAgents;renderAgents=function(items){renderAgentsBas 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 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,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)} +let currentDetailAgent=null;const openAgentDetailBase=openAgentDetail;openAgentDetail=function(agent){currentDetailAgent=agent;el('agentActionStatus')?.remove();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);const actionStatus=el('agentActionStatus');actionStatus.style.margin='12px 24px';el('agentDetailDialog').querySelector('.system-tabs').after(actionStatus);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,index)=>{const container=(agent.report?.containers||[])[index],name=article.querySelector('.disk-model')?.textContent;if(!name)return;const current=(container?.currentVersion||container?.image||'не определена')+(container?.currentDigest?' · '+container.currentDigest:''),available=(container?.availableVersion||container?.image||'не определена')+(container?.availableDigest?' · '+container.availableDigest:'');article.insertAdjacentHTML('beforeend','
Версия Docker-образа
Запущена'+safe(current)+'
Последняя проверенная'+safe(available)+'
'+(container?.updateAvailable?'Доступен новый образ':'Запущен текущий проверенный образ')+'
'+(container?.compose?'':'Обновление из панели доступно для Docker Compose')+(container?.state!=='running'?'':'')+'
')})} +async function queueAgentAction(action,target){const labels={'system-update':'установить обновления операционной системы','updates-refresh':'проверить обновления операционной системы','docker-check':'проверить и загрузить свежий Docker-образ','docker-update':'пересоздать Compose-контейнер на свежем образе','docker-restart':'перезапустить Docker-контейнер','docker-remove':'удалить остановленный Docker-контейнер без удаления volumes','systemd-restart':'перезапустить службу','systemd-reset-failed':'сбросить состояние failed'};if(!currentDetailAgent||!confirm('Выполнить действие «'+(labels[action]||action)+'» для '+(target||currentDetailAgent.name)+'?'))return;const status=el('agentActionStatus');status.hidden=false;status.className='settings-note';status.textContent='Отправляем команду агенту…';document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=true);const response=await fetch('/api/agent-actions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agentId:currentDetailAgent.id,action,target})});if(!response.ok){status.className='settings-note status-down';status.textContent=await response.text();document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=false);return}const queued=await response.json();status.textContent='Команда в очереди · агент проверяет команды каждые 3 секунды';await watchAgentAction(queued.id,status)} +async function watchAgentAction(id,status){for(let attempt=0;attempt<180;attempt++){await new Promise(resolve=>setTimeout(resolve,1000));const response=await fetch('/api/agent-actions');if(!response.ok)continue;const items=await response.json()||[],item=items.find(v=>v.id===id);if(!item)continue;status.textContent=item.status==='queued'?'Ожидаем агента…':item.status==='running'?'Выполняется на агенте…':item.status==='completed'?'Готово: '+(item.result||'успешно'):'Ошибка: '+(item.result||'команда не выполнена');if(item.status==='completed'||item.status==='failed'){status.className='settings-note '+(item.status==='failed'?'status-down':'status-up');document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=false);await loadAgentActions();return}}status.className='settings-note status-down';status.textContent='Агент не вернул результат за 3 минуты. Проверьте его состояние в истории действий.';document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=false)} 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; @@ -946,7 +948,7 @@ 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=>''+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('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 '+processCPU(v)+'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)+'%';