diff --git a/README.md b/README.md index 8215313..302eb26 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,11 @@ APT-обновления и необходимость перезагрузки. health-check, число перезапусков, CPU и память. Агент сам подключается к Dashboard каждые 10 секунд; открывать входящий порт внутри гостевой системы не нужно. +В подробностях агента systemd-службы разделяются на системные, установленные +дополнительно и локальные unit-файлы. Для каждой службы показываются пакет и путь +unit-файла, время запуска, число перезапусков, последний exit code, память и +накопленное CPU-время, когда соответствующий accounting доступен в systemd. + Откройте `Настройки → Агенты`, укажите адрес Dashboard, доступный из VM/LXC, и VMID. Кнопка создаст готовую команду установки. Одноразовый токен действует 15 минут и после регистрации заменяется индивидуальным секретом агента. diff --git a/agent.go b/agent.go index f136bbd..45c79cc 100644 --- a/agent.go +++ b/agent.go @@ -58,10 +58,18 @@ type AgentInterface struct { } type AgentService struct { - Name string `json:"name"` - State string `json:"state"` - SubState string `json:"subState"` - Description string `json:"description"` + 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"` } type AgentIssue struct { @@ -595,7 +603,7 @@ func runAgentCommand(timeout time.Duration, name string, args ...string) string ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() output, err := exec.CommandContext(ctx, name, args...).Output() - if err != nil { + if err != nil && len(output) == 0 { return "" } return string(output) @@ -622,7 +630,99 @@ func collectAgentServices() []AgentService { break } } - return result + return enrichAgentServices(result) +} + +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"} + for _, service := range services { + args = append(args, service.Name) + } + show := runAgentCommand(8*time.Second, "systemctl", args...) + properties := map[string]map[string]string{} + paths := map[string]string{} + for _, record := range strings.Split(show, "\n\n") { + values := map[string]string{} + for _, line := range strings.Split(record, "\n") { + key, value, ok := strings.Cut(line, "=") + if ok { + values[key] = value + } + } + if id := values["Id"]; id != "" { + properties[id] = values + paths[id] = values["FragmentPath"] + } + } + pathArgs := []string{"-S"} + for _, service := range services { + if path := paths[service.Name]; path != "" { + pathArgs = append(pathArgs, path) + } + } + owners := map[string]string{} + packages := []string{} + if len(pathArgs) > 1 { + output := runAgentCommand(8*time.Second, "dpkg-query", pathArgs...) + for _, line := range strings.Split(output, "\n") { + left, path, ok := strings.Cut(line, ": ") + if !ok { + continue + } + pkg := strings.TrimSpace(strings.Split(left, ",")[0]) + path = strings.TrimSpace(path) + if pkg != "" && path != "" { + owners[path] = pkg + packages = append(packages, pkg) + } + } + } + priorities := map[string]string{} + if len(packages) > 0 { + packageArgs := []string{"-W", "-f=${binary:Package}\t${Priority}\n"} + packageArgs = append(packageArgs, packages...) + output := runAgentCommand(8*time.Second, "dpkg-query", packageArgs...) + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 { + priorities[fields[0]] = fields[1] + } + } + } + for i := range services { + service := &services[i] + service.UnitPath = paths[service.Name] + service.Package = owners[service.UnitPath] + service.Origin = classifyAgentService(service.UnitPath, service.Package, priorities[service.Package]) + values := properties[service.Name] + service.Restarts, _ = strconv.ParseUint(values["NRestarts"], 10, 64) + exit, _ := strconv.ParseInt(values["ExecMainStatus"], 10, 32) + service.ExitCode = int(exit) + started, _ := strconv.ParseInt(values["ActiveEnterTimestampUSec"], 10, 64) + service.StartedAt = started / 1_000_000 + service.MemoryBytes, _ = strconv.ParseUint(values["MemoryCurrent"], 10, 64) + cpu, _ := strconv.ParseUint(values["CPUUsageNSec"], 10, 64) + service.CPUSeconds = float64(cpu) / 1_000_000_000 + } + return services +} + +func classifyAgentService(path, pkg, priority string) string { + switch { + case strings.HasPrefix(path, "/etc/systemd/") || strings.HasPrefix(path, "/usr/local/"): + return "local" + case priority == "required" || priority == "important" || priority == "standard": + return "system" + case pkg != "": + return "added" + case strings.HasPrefix(path, "/run/systemd/"): + return "runtime" + default: + return "system" + } } func collectAgentIssues() []AgentIssue { diff --git a/agent_test.go b/agent_test.go index 3c8baee..3d3b230 100644 --- a/agent_test.go +++ b/agent_test.go @@ -60,6 +60,15 @@ func TestParseAgentIssues(t *testing.T) { } } +func TestClassifyAgentService(t *testing.T) { + cases := []struct{ path, pkg, priority, want string }{{"/lib/systemd/system/cron.service", "cron", "important", "system"}, {"/lib/systemd/system/jellyfin.service", "jellyfin", "optional", "added"}, {"/etc/systemd/system/my.service", "", "", "local"}, {"/run/systemd/system/transient.service", "", "", "runtime"}} + for _, tc := range cases { + if got := classifyAgentService(tc.path, tc.pkg, tc.priority); got != tc.want { + t.Errorf("%s: got %s want %s", tc.path, got, tc.want) + } + } +} + func TestAgentReportAuthenticationAndOfflineState(t *testing.T) { store, err := openStore(":memory:") if err != nil { diff --git a/web.go b/web.go index 0ec00d3..10a9136 100644 --- a/web.go +++ b/web.go @@ -844,7 +844,7 @@ function agentHTML(a,settings=false){ 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 filtered=(services||[]).filter(v=>filter==='all'||(filter==='failed'?(v.state==='failed'||v.subState==='failed'):(v.state!=='failed'&&v.subState!=='failed')));el('agentServiceDetails').innerHTML='
'+(filtered.length?filtered.map(v=>'
'+safe(v.name)+'
'+safe(v.description||'Описание отсутствует')+'
'+safe(v.subState||v.state)+'
').join(''):'Служб в этой группе нет.')+'
';el('agentServiceDetails').querySelectorAll('[data-agent-service-filter]').forEach(button=>button.onclick=()=>renderAgentServices(services,button.dataset.agentServiceFilter))} +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 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();