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='
Все · '+services.length+' Системные · '+services.filter(v=>v.origin==='system').length+' Добавленные · '+services.filter(custom).length+' Упали · '+services.filter(failed).length+'
'+(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='Все · '+services.length+' Системные · '+services.filter(v=>v.origin==='system').length+' Добавленные · '+services.filter(custom).length+' Упали · '+services.filter(failed).length+' Перезапускаются · '+services.filter(v=>v.flapping).length+'
'+(filtered.length?filtered.map(v=>'
'+safe(v.name)+'
'+safe(v.description||'Описание отсутствует')+'
'+originLabel(v)+(v.package?' · пакет '+safe(v.package):'')+(v.unitPath?' · '+safe(v.unitPath):'')+'
'+(v.flapping?'Частые рестарты':safe(v.subState||v.state))+' Запущена '+(v.startedAt?new Date(v.startedAt*1000).toLocaleString('ru-RU'):'—')+'
PID / команда '+(v.mainPid||'—')+' · '+safe(v.execStart||'—')+'
Рестарты / exit '+(v.restarts||0)+' / '+(v.exitCode||0)+'
Память / CPU time '+(v.memoryBytes?size(v.memoryBytes):'Нет accounting')+' / '+(v.cpuSeconds?fixed(v.cpuSeconds)+' сек.':'—')+'
Порты и зависимости
'+(v.listenPorts||[]).map(p=>chip('порт',p)).join('')+(v.requires||[]).map(d=>chip('requires',d)).join('')+(v.after||[]).slice(0,6).map(d=>chip('after',d)).join('')+'
'+((v.logs||[]).length?'Последние сообщения журнала
'+v.logs.map(l=>'
'+(l.time?new Date(l.time*1000).toLocaleString('ru-RU')+' · ':'')+safe(l.message)+'
').join('')+'
':'')+'').join(''):'
Служб в этой группе нет. ')+'
';el('agentServiceDetails').querySelectorAll('[data-agent-service-filter]').forEach(button=>button.onclick=()=>renderAgentServicesV2(services,button.dataset.agentServiceFilter))}
function openAgentDetail(agent){const r=agent.report||{},services=r.services||[],issues=r.issues||[],containers=r.containers||[],interfaces=r.interfaces||[],filesystems=r.filesystems||[],processes=r.processes||[],ports=r.listenPorts||[],oom=r.oomKills||[],pressure=r.pressure||{},memoryPercent=r.memoryTotal?r.memoryUsed/r.memoryTotal*100:0,diskPercent=r.rootTotal?r.rootUsed/r.rootTotal*100:0;el('agentDetailTitle').textContent=agent.name||agent.hostname;el('agentDetailMeta').textContent=(agent.hostname||'')+' · VMID '+(agent.vmid||'не указан')+' · '+(r.osName||r.os||'Linux')+' · '+(r.kernel||'ядро не определено')+' · агент '+(agent.version||'—');el('agentOverview').innerHTML='Состояние '+(agent.online?'Онлайн':'Не отвечает')+'
CPU / Load '+fixed(r.cpuPercent||0)+'% / '+fixed(r.load1||0)+'
Память '+size(r.memoryUsed)+' · '+fixed(memoryPercent)+'%
Раздел / '+size(r.rootUsed)+' · '+fixed(diskPercent)+'%
Uptime '+uptime(r.uptimeSeconds||0)+'
Обновления '+(r.updatesAvailable||0)+' пакетов'+(r.rebootRequired?' · нужен reboot':'')+'
IP-адреса
'+(interfaces.length?interfaces.map(v=>''+safe(v.name)+' · '+safe((v.addresses||[]).join(', '))+' ').join(''):'Не найдены ')+'
Последний heartbeat: '+new Date(agent.lastSeen*1000).toLocaleString('ru-RU')+' · системный инвентарь: '+(r.inventoryAt?new Date(r.inventoryAt*1000).toLocaleString('ru-RU'):'ещё не собран')+'
';renderAgentServicesV2(services);el('agentResourceDetails').innerHTML='CPU pressure · 10/60/300с '+fixed(pressure.cpu?.avg10||0)+' / '+fixed(pressure.cpu?.avg60||0)+' / '+fixed(pressure.cpu?.avg300||0)+'
Memory pressure '+fixed(pressure.memory?.avg10||0)+' / '+fixed(pressure.memory?.avg60||0)+' / '+fixed(pressure.memory?.avg300||0)+'
I/O pressure '+fixed(pressure.io?.avg10||0)+' / '+fixed(pressure.io?.avg60||0)+' / '+fixed(pressure.io?.avg300||0)+'
'+(filesystems.length?filesystems.map(v=>'
'+safe(v.mountpoint)+'
'+safe(v.device)+' · '+safe(v.type)+'
'+fixed(v.usedPercent)+'% Занято / всего '+size(v.usedBytes)+' / '+size(v.totalBytes)+'
Inode '+fixed(v.inodesPercent||0)+'% · '+(v.inodesUsed||0)+' / '+(v.inodesTotal||0)+'
').join(''):'
Файловые системы ещё не получены. ')+'
';el('agentProcessDetails').innerHTML='Слушающие порты
'+(ports.length?ports.map(v=>''+safe(v.protocol)+' · '+safe(v.address)+':'+v.port+' · '+safe(v.process||'PID '+(v.pid||'—'))+' ').join(''):'Не найдены или ss недоступен. ')+'
Топ процессов
'+(processes.length?processes.map(v=>'
'+safe(v.name)+' PID '+v.pid+' · '+safe(v.command)+'
CPU '+fixed(v.cpuPercent)+'% RAM '+size(v.memoryBytes)+' Read '+size(v.readBytes)+' Write '+size(v.writeBytes)+' ').join(''):'
Процессы ещё не получены. ')+'
';el('agentIssueDetails').innerHTML=''+(oom.map(v=>'
'+new Date(v.time*1000).toLocaleString('ru-RU')+' · '+safe(v.message)+'
').join(''))+(issues.length?issues.map(v=>'
'+safe(v.unit||'system')+'
'+(v.time?new Date(v.time*1000).toLocaleString('ru-RU'):'Время не указано')+'
Ошибка '+safe(v.message)+'
').join(''):'
Ошибок уровня err и выше за 24 часа нет. ')+'
';el('agentDockerDetails').innerHTML=''+(containers.length?containers.map(v=>'
'+safe(v.name)+'
'+safe(v.image)+' · '+safe(v.id)+'
'+safe(v.health||v.state)+' Статус '+safe(v.status)+'
CPU '+fixed(v.cpuPercent||0)+'%
Память '+size(v.memoryUsage||0)+'
Рестарты '+v.restarts+'
Порты '+safe((v.ports||[]).join(', ')||'—')+'
').join(''):'
Docker-контейнеры не обнаружены. ')+'
';const first=document.querySelector('[data-agent-tab="overview"]');document.querySelectorAll('[data-agent-tab]').forEach(t=>t.classList.toggle('active',t===first));document.querySelectorAll('[data-agent-panel]').forEach(p=>p.hidden=p.dataset.agentPanel!=='overview');el('agentDetailDialog').showModal()}
-let currentDetailAgent=null;const openAgentDetailBase=openAgentDetail;openAgentDetail=function(agent){currentDetailAgent=agent;openAgentDetailBase(agent);const r=agent.report||{},processes=r.processes||[],oom=r.oomKills||[],summary='Swap '+size(r.swapUsed||0)+' / '+size(r.swapTotal||0)+'
Процессы в D-state '+processes.filter(v=>String(v.state||'').startsWith('D')).length+'
OOM за 24 часа '+oom.length+'
';el('agentResourceDetails').insertAdjacentHTML('afterbegin',summary);addAgentActionButtons(agent);loadAgentHistory(agent,24)}
-function addAgentActionButtons(agent){el('agentOverview').insertAdjacentHTML('beforeend','Проверить обновления ОС Установить обновления ОС
');el('agentServiceDetails').querySelectorAll('.disk-item').forEach(article=>{const name=article.querySelector('.disk-model')?.textContent;if(!name)return;article.insertAdjacentHTML('beforeend','Перезапустить Сбросить failed
')});el('agentDockerDetails').querySelectorAll('.disk-item').forEach((article,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','Перезапустить Сбросить failed
')});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)+'%';