Classify and enrich guest services
This commit is contained in:
@@ -342,6 +342,11 @@ APT-обновления и необходимость перезагрузки.
|
||||
health-check, число перезапусков, CPU и память. Агент сам подключается к Dashboard
|
||||
каждые 10 секунд; открывать входящий порт внутри гостевой системы не нужно.
|
||||
|
||||
В подробностях агента systemd-службы разделяются на системные, установленные
|
||||
дополнительно и локальные unit-файлы. Для каждой службы показываются пакет и путь
|
||||
unit-файла, время запуска, число перезапусков, последний exit code, память и
|
||||
накопленное CPU-время, когда соответствующий accounting доступен в systemd.
|
||||
|
||||
Откройте `Настройки → Агенты`, укажите адрес Dashboard, доступный из VM/LXC, и
|
||||
VMID. Кнопка создаст готовую команду установки. Одноразовый токен действует 15
|
||||
минут и после регистрации заменяется индивидуальным секретом агента.
|
||||
|
||||
104
agent.go
104
agent.go
@@ -62,6 +62,14 @@ type AgentService struct {
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
2
web.go
2
web.go
@@ -844,7 +844,7 @@ function agentHTML(a,settings=false){
|
||||
return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(a.name||a.hostname)+'</div><div class="disk-meta">'+safe(a.hostname)+' · VMID '+(a.vmid||'не указан')+' · '+safe(r.osName||r.os||'Linux')+' · '+safe(r.kernel||'ядро не определено')+' · агент '+safe(a.version||'—')+'</div></div><span class="badge '+(a.online?'':'bad')+'">'+(a.online?'Онлайн':'Не отвечает')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>CPU / Load</span><b>'+fixed(r.cpuPercent||0)+'% / '+fixed(r.load1||0)+'</b></div><div class="disk-stat"><span>Память</span><b>'+size(r.memoryUsed)+' · '+fixed(memoryPercent)+'%</b></div><div class="disk-stat"><span>Раздел /</span><b>'+size(r.rootUsed)+' · '+fixed(diskPercent)+'%</b></div><div class="disk-stat"><span>Uptime</span><b>'+uptime(r.uptimeSeconds||0)+'</b></div><div class="disk-stat"><span>Обновления</span><b>'+(r.updatesAvailable||0)+' пакетов'+(r.rebootRequired?' · нужен reboot':'')+'</b></div><div class="disk-stat"><span>Проблемы</span><b>'+failed.length+' служб · '+issues.length+' ошибок</b></div></div><div class="topology-section"><div class="topology-section-title">IP-адреса и интерфейсы</div><div class="topology-items">'+(interfaces.length?interfaces.map(v=>'<span class="topology-chip">'+safe(v.name)+' · '+safe((v.addresses||[]).join(', '))+'</span>').join(''):'<span class="empty">IP-адреса не найдены</span>')+'</div></div><div class="service-actions"><button data-agent-detail="'+safe(a.id)+'">Подробнее</button>'+(settings?'<button data-agent-delete="'+safe(a.id)+'">Удалить агент</button>':'')+'</div></article>'
|
||||
}
|
||||
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(''):'<span class="empty">Агенты пока не подключены. Создайте команду установки в разделе «Управление».</span>';el('agentSettingsList').innerHTML=agents.length?agents.map(v=>agentHTML(v,true)).join(''):'<span class="empty">Агентов пока нет.</span>'}
|
||||
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='<div class="agent-filter"><button data-agent-service-filter="all" class="'+(filter==='all'?'active':'')+'">Все · '+services.length+'</button><button data-agent-service-filter="running" class="'+(filter==='running'?'active':'')+'">Работают · '+services.filter(v=>v.state!=='failed'&&v.subState!=='failed').length+'</button><button data-agent-service-filter="failed" class="'+(filter==='failed'?'active':'')+'">Упали · '+services.filter(v=>v.state==='failed'||v.subState==='failed').length+'</button></div><div class="disk-list" style="padding:0">'+(filtered.length?filtered.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name)+'</div><div class="disk-meta">'+safe(v.description||'Описание отсутствует')+'</div></div><span class="badge '+(v.state==='failed'||v.subState==='failed'?'bad':'')+'">'+safe(v.subState||v.state)+'</span></div></article>').join(''):'<span class="empty">Служб в этой группе нет.</span>')+'</div>';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='<div class="agent-filter"><button data-agent-service-filter="all" class="'+(filter==='all'?'active':'')+'">Все · '+services.length+'</button><button data-agent-service-filter="system" class="'+(filter==='system'?'active':'')+'">Системные · '+services.filter(v=>v.origin==='system').length+'</button><button data-agent-service-filter="custom" class="'+(filter==='custom'?'active':'')+'">Добавленные · '+services.filter(custom).length+'</button><button data-agent-service-filter="failed" class="'+(filter==='failed'?'active':'')+'">Упали · '+services.filter(failed).length+'</button></div><div class="disk-list" style="padding:0">'+(filtered.length?filtered.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name)+'</div><div class="disk-meta">'+safe(v.description||'Описание отсутствует')+'</div><div class="disk-meta">'+originLabel(v)+(v.package?' · пакет '+safe(v.package):'')+(v.unitPath?' · '+safe(v.unitPath):'')+'</div></div><span class="badge '+(failed(v)?'bad':'')+'">'+safe(v.subState||v.state)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Запущена</span><b>'+(v.startedAt?new Date(v.startedAt*1000).toLocaleString('ru-RU'):'—')+'</b></div><div class="disk-stat"><span>Рестарты</span><b>'+(v.restarts||0)+'</b></div><div class="disk-stat"><span>Exit code</span><b>'+(v.exitCode||0)+'</b></div><div class="disk-stat"><span>Память</span><b>'+(v.memoryBytes?size(v.memoryBytes):'Нет accounting')+'</b></div><div class="disk-stat"><span>CPU time</span><b>'+(v.cpuSeconds?fixed(v.cpuSeconds)+' сек.':'Нет accounting')+'</b></div></div></article>').join(''):'<span class="empty">Служб в этой группе нет.</span>')+'</div>';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='<div class="agent-detail-summary"><div><span class="label">Состояние</span><b>'+(agent.online?'Онлайн':'Не отвечает')+'</b></div><div><span class="label">CPU / Load</span><b>'+fixed(r.cpuPercent||0)+'% / '+fixed(r.load1||0)+'</b></div><div><span class="label">Память</span><b>'+size(r.memoryUsed)+' · '+fixed(memoryPercent)+'%</b></div><div><span class="label">Раздел /</span><b>'+size(r.rootUsed)+' · '+fixed(diskPercent)+'%</b></div><div><span class="label">Uptime</span><b>'+uptime(r.uptimeSeconds||0)+'</b></div><div><span class="label">Обновления</span><b>'+(r.updatesAvailable||0)+' пакетов'+(r.rebootRequired?' · нужен reboot':'')+'</b></div></div><div class="topology-section"><div class="topology-section-title">IP-адреса</div><div class="topology-items">'+(interfaces.length?interfaces.map(v=>'<span class="topology-chip">'+safe(v.name)+' · '+safe((v.addresses||[]).join(', '))+'</span>').join(''):'<span class="empty">Не найдены</span>')+'</div></div><div class="settings-note">Последний heartbeat: '+new Date(agent.lastSeen*1000).toLocaleString('ru-RU')+' · системный инвентарь: '+(r.inventoryAt?new Date(r.inventoryAt*1000).toLocaleString('ru-RU'):'ещё не собран')+'</div>';renderAgentServices(services);el('agentIssueDetails').innerHTML='<div class="disk-list" style="padding:0">'+(issues.length?issues.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.unit||'system')+'</div><div class="disk-meta">'+(v.time?new Date(v.time*1000).toLocaleString('ru-RU'):'Время не указано')+'</div></div><span class="badge bad">Ошибка</span></div><div class="settings-note issue-message">'+safe(v.message)+'</div></article>').join(''):'<span class="empty">Ошибок уровня err и выше за 24 часа нет.</span>')+'</div>';el('agentDockerDetails').innerHTML='<div class="disk-list" style="padding:0">'+(containers.length?containers.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name)+'</div><div class="disk-meta">'+safe(v.image)+' · '+safe(v.id)+'</div></div><span class="badge '+(v.state==='running'&&v.health!=='unhealthy'?'':'bad')+'">'+safe(v.health||v.state)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Статус</span><b>'+safe(v.status)+'</b></div><div class="disk-stat"><span>CPU</span><b>'+fixed(v.cpuPercent||0)+'%</b></div><div class="disk-stat"><span>Память</span><b>'+size(v.memoryUsage||0)+'</b></div><div class="disk-stat"><span>Рестарты</span><b>'+v.restarts+'</b></div><div class="disk-stat"><span>Порты</span><b>'+safe((v.ports||[]).join(', ')||'—')+'</b></div></div></article>').join(''):'<span class="empty">Docker-контейнеры не обнаружены.</span>')+'</div>';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();
|
||||
|
||||
Reference in New Issue
Block a user