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 '