From 20abff7f9042798cefad90950a93a408c45ec7ea Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sun, 5 Apr 2026 12:05:00 +0300 Subject: [PATCH] WIP: checkpoint current tree --- audit/internal/platform/benchmark.go | 3 - audit/internal/platform/benchmark_test.go | 15 ++ audit/internal/platform/install_to_ram.go | 35 +++ .../internal/platform/install_to_ram_test.go | 29 ++ audit/internal/platform/sat.go | 12 +- audit/internal/platform/services.go | 17 +- audit/internal/platform/types.go | 12 +- audit/internal/platform/types_test.go | 31 +++ audit/internal/webui/api.go | 66 +++++ audit/internal/webui/api_test.go | 36 +++ audit/internal/webui/kmsg_watcher.go | 2 +- audit/internal/webui/pages.go | 249 ++++++++++++++++-- audit/internal/webui/server.go | 2 + audit/internal/webui/server_test.go | 41 +++ audit/internal/webui/tasks.go | 48 ++-- 15 files changed, 539 insertions(+), 59 deletions(-) create mode 100644 audit/internal/platform/types_test.go diff --git a/audit/internal/platform/benchmark.go b/audit/internal/platform/benchmark.go index 1b401ee..372e3f6 100644 --- a/audit/internal/platform/benchmark.go +++ b/audit/internal/platform/benchmark.go @@ -274,9 +274,6 @@ func normalizeNvidiaBenchmarkOptionsForBenchmark(opts NvidiaBenchmarkOptions) Nv } opts.GPUIndices = dedupeSortedIndices(opts.GPUIndices) opts.ExcludeGPUIndices = dedupeSortedIndices(opts.ExcludeGPUIndices) - if !opts.RunNCCL { - opts.RunNCCL = true - } return opts } diff --git a/audit/internal/platform/benchmark_test.go b/audit/internal/platform/benchmark_test.go index 51120e7..b15d1f8 100644 --- a/audit/internal/platform/benchmark_test.go +++ b/audit/internal/platform/benchmark_test.go @@ -41,6 +41,21 @@ func TestResolveBenchmarkProfile(t *testing.T) { } } +func TestNormalizeNvidiaBenchmarkOptionsPreservesRunNCCLChoice(t *testing.T) { + t.Parallel() + + opts := normalizeNvidiaBenchmarkOptionsForBenchmark(NvidiaBenchmarkOptions{ + Profile: "stability", + RunNCCL: false, + }) + if opts.Profile != NvidiaBenchmarkProfileStability { + t.Fatalf("profile=%q want %q", opts.Profile, NvidiaBenchmarkProfileStability) + } + if opts.RunNCCL { + t.Fatalf("RunNCCL should stay false when explicitly disabled") + } +} + func TestParseBenchmarkBurnLog(t *testing.T) { t.Parallel() diff --git a/audit/internal/platform/install_to_ram.go b/audit/internal/platform/install_to_ram.go index 7b41846..1ff6db4 100644 --- a/audit/internal/platform/install_to_ram.go +++ b/audit/internal/platform/install_to_ram.go @@ -120,10 +120,45 @@ func (s *System) RunInstallToRAM(ctx context.Context, logFunc func(string)) erro log(fmt.Sprintf("Warning: rebind /run/live/medium failed: %v", err)) } + log("Verifying live medium now served from RAM...") + status := s.LiveBootSource() + if err := verifyInstallToRAMStatus(status); err != nil { + return err + } + log(fmt.Sprintf("Verification passed: live medium now served from %s.", describeLiveBootSource(status))) log("Done. Installation media can be safely disconnected.") return nil } +func verifyInstallToRAMStatus(status LiveBootSource) error { + if status.InRAM { + return nil + } + return fmt.Errorf("install to RAM verification failed: live medium still mounted from %s", describeLiveBootSource(status)) +} + +func describeLiveBootSource(status LiveBootSource) string { + source := strings.TrimSpace(status.Device) + if source == "" { + source = strings.TrimSpace(status.Source) + } + if source == "" { + source = "unknown source" + } + switch strings.TrimSpace(status.Kind) { + case "ram": + return "RAM" + case "usb": + return "USB (" + source + ")" + case "cdrom": + return "CD-ROM (" + source + ")" + case "disk": + return "disk (" + source + ")" + default: + return source + } +} + func copyFileLarge(ctx context.Context, src, dst string, logFunc func(string)) error { in, err := os.Open(src) if err != nil { diff --git a/audit/internal/platform/install_to_ram_test.go b/audit/internal/platform/install_to_ram_test.go index 18be1a3..236c6fb 100644 --- a/audit/internal/platform/install_to_ram_test.go +++ b/audit/internal/platform/install_to_ram_test.go @@ -3,6 +3,8 @@ package platform import "testing" func TestInferLiveBootKind(t *testing.T) { + t.Parallel() + tests := []struct { name string fsType string @@ -18,6 +20,7 @@ func TestInferLiveBootKind(t *testing.T) { {name: "unknown", source: "overlay", want: "unknown"}, } for _, tc := range tests { + tc := tc t.Run(tc.name, func(t *testing.T) { got := inferLiveBootKind(tc.fsType, tc.source, tc.deviceType, tc.transport) if got != tc.want { @@ -26,3 +29,29 @@ func TestInferLiveBootKind(t *testing.T) { }) } } + +func TestVerifyInstallToRAMStatus(t *testing.T) { + t.Parallel() + + if err := verifyInstallToRAMStatus(LiveBootSource{InRAM: true, Kind: "ram", Source: "tmpfs"}); err != nil { + t.Fatalf("expected success for RAM-backed status, got %v", err) + } + err := verifyInstallToRAMStatus(LiveBootSource{InRAM: false, Kind: "usb", Device: "/dev/sdb1"}) + if err == nil { + t.Fatal("expected verification failure when media is still on USB") + } + if got := err.Error(); got != "install to RAM verification failed: live medium still mounted from USB (/dev/sdb1)" { + t.Fatalf("error=%q", got) + } +} + +func TestDescribeLiveBootSource(t *testing.T) { + t.Parallel() + + if got := describeLiveBootSource(LiveBootSource{InRAM: true, Kind: "ram"}); got != "RAM" { + t.Fatalf("got %q want RAM", got) + } + if got := describeLiveBootSource(LiveBootSource{Kind: "unknown", Source: "/run/live/medium"}); got != "/run/live/medium" { + t.Fatalf("got %q want /run/live/medium", got) + } +} diff --git a/audit/internal/platform/sat.go b/audit/internal/platform/sat.go index a11466a..fba9127 100644 --- a/audit/internal/platform/sat.go +++ b/audit/internal/platform/sat.go @@ -12,11 +12,11 @@ import ( "os" "os/exec" "path/filepath" - "syscall" "sort" "strconv" "strings" "sync" + "syscall" "time" ) @@ -76,15 +76,15 @@ func streamExecOutput(cmd *exec.Cmd, logFunc func(string)) ([]byte, error) { // NvidiaGPU holds basic GPU info from nvidia-smi. type NvidiaGPU struct { - Index int - Name string - MemoryMB int + Index int `json:"index"` + Name string `json:"name"` + MemoryMB int `json:"memory_mb"` } // AMDGPUInfo holds basic info about an AMD GPU from rocm-smi. type AMDGPUInfo struct { - Index int - Name string + Index int `json:"index"` + Name string `json:"name"` } // DetectGPUVendor returns "nvidia" if /dev/nvidia0 exists, "amd" if /dev/kfd exists, or "" otherwise. diff --git a/audit/internal/platform/services.go b/audit/internal/platform/services.go index cbeb910..b21cb09 100644 --- a/audit/internal/platform/services.go +++ b/audit/internal/platform/services.go @@ -10,17 +10,30 @@ import ( func (s *System) ListBeeServices() ([]string, error) { seen := map[string]bool{} var out []string - for _, pattern := range []string{"/etc/systemd/system/bee-*.service", "/lib/systemd/system/bee-*.service"} { + for _, pattern := range []string{ + "/etc/systemd/system/bee-*.service", + "/lib/systemd/system/bee-*.service", + "/etc/systemd/system/bee-*.timer", + "/lib/systemd/system/bee-*.timer", + } { matches, err := filepath.Glob(pattern) if err != nil { return nil, err } for _, match := range matches { - name := strings.TrimSuffix(filepath.Base(match), ".service") + base := filepath.Base(match) + name := base + if strings.HasSuffix(base, ".service") { + name = strings.TrimSuffix(base, ".service") + } // Skip template units (e.g. bee-journal-mirror@) — they have no instances to query. if strings.HasSuffix(name, "@") { continue } + // bee-selfheal is timer-managed; showing the oneshot service as inactive is misleading. + if name == "bee-selfheal" && strings.HasSuffix(base, ".service") { + continue + } if !seen[name] { seen[name] = true out = append(out, name) diff --git a/audit/internal/platform/types.go b/audit/internal/platform/types.go index aec7b30..6acaa7c 100644 --- a/audit/internal/platform/types.go +++ b/audit/internal/platform/types.go @@ -44,12 +44,12 @@ type StaticIPv4Config struct { } type RemovableTarget struct { - Device string - FSType string - Size string - Label string - Model string - Mountpoint string + Device string `json:"device"` + FSType string `json:"fs_type"` + Size string `json:"size"` + Label string `json:"label"` + Model string `json:"model"` + Mountpoint string `json:"mountpoint"` } type ToolStatus struct { diff --git a/audit/internal/platform/types_test.go b/audit/internal/platform/types_test.go new file mode 100644 index 0000000..9cbc067 --- /dev/null +++ b/audit/internal/platform/types_test.go @@ -0,0 +1,31 @@ +package platform + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestRemovableTargetJSONUsesFrontendFieldNames(t *testing.T) { + t.Parallel() + + data, err := json.Marshal(RemovableTarget{ + Device: "/dev/sdb1", + FSType: "exfat", + Size: "1.8T", + Label: "USB", + Model: "Flash", + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + raw := string(data) + for _, key := range []string{`"device"`, `"fs_type"`, `"size"`, `"label"`, `"model"`} { + if !strings.Contains(raw, key) { + t.Fatalf("json missing key %s: %s", key, raw) + } + } + if strings.Contains(raw, `"Device"`) || strings.Contains(raw, `"FSType"`) { + t.Fatalf("json still contains Go field names: %s", raw) + } +} diff --git a/audit/internal/webui/api.go b/audit/internal/webui/api.go index c88cb54..d9a50d6 100644 --- a/audit/internal/webui/api.go +++ b/audit/internal/webui/api.go @@ -232,6 +232,54 @@ func (h *handler) handleAPISATRun(target string) http.HandlerFunc { } } +func (h *handler) handleAPIBenchmarkNvidiaRun(w http.ResponseWriter, r *http.Request) { + if h.opts.App == nil { + writeError(w, http.StatusServiceUnavailable, "app not configured") + return + } + + var body struct { + Profile string `json:"profile"` + SizeMB int `json:"size_mb"` + GPUIndices []int `json:"gpu_indices"` + ExcludeGPUIndices []int `json:"exclude_gpu_indices"` + RunNCCL *bool `json:"run_nccl"` + DisplayName string `json:"display_name"` + } + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + } + + runNCCL := true + if body.RunNCCL != nil { + runNCCL = *body.RunNCCL + } + t := &Task{ + ID: newJobID("benchmark-nvidia"), + Name: taskDisplayName("nvidia-benchmark", "", ""), + Target: "nvidia-benchmark", + Priority: 15, + Status: TaskPending, + CreatedAt: time.Now(), + params: taskParams{ + GPUIndices: body.GPUIndices, + ExcludeGPUIndices: body.ExcludeGPUIndices, + SizeMB: body.SizeMB, + BenchmarkProfile: body.Profile, + RunNCCL: runNCCL, + DisplayName: body.DisplayName, + }, + } + if strings.TrimSpace(body.DisplayName) != "" { + t.Name = body.DisplayName + } + globalQueue.enqueue(t) + writeJSON(w, map[string]string{"task_id": t.ID, "job_id": t.ID}) +} + func (h *handler) handleAPISATStream(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("job_id") if id == "" { @@ -491,6 +539,22 @@ func (h *handler) handleAPIExportUSBBundle(w http.ResponseWriter, r *http.Reques // ── GPU presence ────────────────────────────────────────────────────────────── +func (h *handler) handleAPIGNVIDIAGPUs(w http.ResponseWriter, _ *http.Request) { + if h.opts.App == nil { + writeError(w, http.StatusServiceUnavailable, "app not configured") + return + } + gpus, err := h.opts.App.ListNvidiaGPUs() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if gpus == nil { + gpus = []platform.NvidiaGPU{} + } + writeJSON(w, gpus) +} + func (h *handler) handleAPIGPUPresence(w http.ResponseWriter, r *http.Request) { if h.opts.App == nil { writeError(w, http.StatusServiceUnavailable, "app not configured") @@ -516,8 +580,10 @@ func (h *handler) handleAPIGPUTools(w http.ResponseWriter, _ *http.Request) { _, amdErr := os.Stat("/dev/kfd") nvidiaUp := nvidiaErr == nil amdUp := amdErr == nil + _, dcgmErr := exec.LookPath("dcgmi") writeJSON(w, []toolEntry{ {ID: "bee-gpu-burn", Available: nvidiaUp, Vendor: "nvidia"}, + {ID: "dcgm", Available: nvidiaUp && dcgmErr == nil, Vendor: "nvidia"}, {ID: "john", Available: nvidiaUp, Vendor: "nvidia"}, {ID: "nccl", Available: nvidiaUp, Vendor: "nvidia"}, {ID: "rvs", Available: amdUp, Vendor: "amd"}, diff --git a/audit/internal/webui/api_test.go b/audit/internal/webui/api_test.go index d324204..6a51d77 100644 --- a/audit/internal/webui/api_test.go +++ b/audit/internal/webui/api_test.go @@ -64,6 +64,42 @@ func TestHandleAPISATRunDecodesBodyWithoutContentLength(t *testing.T) { } } +func TestHandleAPIBenchmarkNvidiaRunQueuesSelectedGPUs(t *testing.T) { + globalQueue.mu.Lock() + originalTasks := globalQueue.tasks + globalQueue.tasks = nil + globalQueue.mu.Unlock() + t.Cleanup(func() { + globalQueue.mu.Lock() + globalQueue.tasks = originalTasks + globalQueue.mu.Unlock() + }) + + h := &handler{opts: HandlerOptions{App: &app.App{}}} + req := httptest.NewRequest("POST", "/api/benchmark/nvidia/run", strings.NewReader(`{"profile":"standard","gpu_indices":[1,3],"run_nccl":false}`)) + rec := httptest.NewRecorder() + + h.handleAPIBenchmarkNvidiaRun(rec, req) + + if rec.Code != 200 { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + globalQueue.mu.Lock() + defer globalQueue.mu.Unlock() + if len(globalQueue.tasks) != 1 { + t.Fatalf("tasks=%d want 1", len(globalQueue.tasks)) + } + task := globalQueue.tasks[0] + if task.Target != "nvidia-benchmark" { + t.Fatalf("target=%q want nvidia-benchmark", task.Target) + } + if got := task.params.GPUIndices; len(got) != 2 || got[0] != 1 || got[1] != 3 { + t.Fatalf("gpu indices=%v want [1 3]", got) + } + if task.params.RunNCCL { + t.Fatal("RunNCCL should reflect explicit false from request") + } +} func TestPushFanRingsTracksByNameAndCarriesForwardMissingSamples(t *testing.T) { h := &handler{} diff --git a/audit/internal/webui/kmsg_watcher.go b/audit/internal/webui/kmsg_watcher.go index ad413a2..57804b7 100644 --- a/audit/internal/webui/kmsg_watcher.go +++ b/audit/internal/webui/kmsg_watcher.go @@ -232,7 +232,7 @@ func truncate(s string, max int) string { // isSATTarget returns true for task targets that run hardware acceptance tests. func isSATTarget(target string) bool { switch target { - case "nvidia", "nvidia-stress", "memory", "memory-stress", "storage", + case "nvidia", "nvidia-benchmark", "nvidia-stress", "memory", "memory-stress", "storage", "cpu", "sat-stress", "amd", "amd-mem", "amd-bandwidth", "amd-stress", "platform-stress": return true diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go index cb2bdfc..9c8ec98 100644 --- a/audit/internal/webui/pages.go +++ b/audit/internal/webui/pages.go @@ -91,6 +91,7 @@ func layoutNav(active string, buildLabel string) string { {"audit", "Audit", "/audit", ""}, {"validate", "Validate", "/validate", ""}, {"burn", "Burn", "/burn", ""}, + {"benchmark", "Benchmark", "/benchmark", ""}, {"tasks", "Tasks", "/tasks", ""}, {"tools", "Tools", "/tools", ""}, } @@ -140,6 +141,10 @@ func renderPage(page string, opts HandlerOptions) string { pageID = "burn" title = "Burn" body = renderBurn() + case "benchmark": + pageID = "benchmark" + title = "Benchmark" + body = renderBenchmark() case "tasks": pageID = "tasks" title = "Tasks" @@ -781,6 +786,193 @@ func renderSATCard(id, label, extra string) string { label, extra, id, id) } +// ── Benchmark ───────────────────────────────────────────────────────────────── + +func renderBenchmark() string { + return `

Benchmark runs generate a human-readable TXT report and machine-readable result bundle. Tasks continue in the background — view progress in Tasks.

+ +
+
+
NVIDIA Benchmark
+
+
+ + +
+
+ +
+ + +
+
+

Loading NVIDIA GPUs...

+
+
+ +

Select one GPU for single-card benchmarking or several GPUs for a constrained multi-GPU run.

+ + +
+
+ +
+
Method
+
+

Each benchmark run performs warmup, sustained compute, telemetry capture, cooldown, and optional NCCL interconnect checks.

+ + + + + +
ProfilePurpose
StandardFast, repeatable performance check for server-to-server comparison.
StabilityLonger run for thermal drift, power caps, and clock instability.
OvernightExtended verification of long-run stability and late throttling.
+
+
+
+ + + + + +` +} + // ── Burn ────────────────────────────────────────────────────────────────────── func renderBurn() string { @@ -805,11 +997,12 @@ func renderBurn() string {
GPU Stress
-

Tests run on all GPUs in the system. Availability determined by driver status.

+

NVIDIA tools run on all discovered GPUs. DCGM is the official NVIDIA diagnostic path. NCCL exercises multi-GPU fabric and is not a full compute burn.

+ - +
@@ -881,17 +1074,18 @@ function streamTask(taskId, label) { } function runGPUStress() { - const ids = ['burn-gpu-bee','burn-gpu-john','burn-gpu-nccl','burn-gpu-rvs']; - const loaderMap = {'burn-gpu-bee':'builtin','burn-gpu-john':'john','burn-gpu-nccl':'nccl','burn-gpu-rvs':'rvs'}; - const targetMap = {'burn-gpu-bee':'nvidia-stress','burn-gpu-john':'nvidia-stress','burn-gpu-nccl':'nvidia-stress','burn-gpu-rvs':'amd-stress'}; - let last = null; - ids.filter(id => { - const el = document.getElementById(id); + const tasks = [ + {id:'burn-gpu-bee', target:'nvidia-stress', label:'bee-gpu-burn', extra:{loader:'builtin'}}, + {id:'burn-gpu-dcgm', target:'nvidia', label:'DCGM Diagnostics (Official NVIDIA)', extra:{display_name:'NVIDIA DCGM Diagnostics (Official)'}}, + {id:'burn-gpu-john', target:'nvidia-stress', label:'John GPU Stress', extra:{loader:'john'}}, + {id:'burn-gpu-nccl', target:'nvidia-stress', label:'NCCL Interconnect Stress', extra:{loader:'nccl', display_name:'NCCL Interconnect Stress'}}, + {id:'burn-gpu-rvs', target:'amd-stress', label:'RVS GST', extra:{}}, + ]; + tasks.filter(t => { + const el = document.getElementById(t.id); return el && el.checked && !el.disabled; - }).forEach(id => { - const target = targetMap[id]; - const extra = target === 'nvidia-stress' ? {loader: loaderMap[id]} : {}; - enqueueTask(target, extra).then(d => { last = d; streamTask(d.task_id, target + ' / ' + loaderMap[id]); }); + }).forEach(t => { + enqueueTask(t.target, t.extra).then(d => { streamTask(d.task_id, t.label); }); }); } @@ -928,13 +1122,15 @@ function runAll() { const done = () => { count++; status.textContent = count + ' tasks queued.'; }; // GPU tests - const gpuIds = ['burn-gpu-bee','burn-gpu-john','burn-gpu-nccl','burn-gpu-rvs']; - const loaderMap = {'burn-gpu-bee':'builtin','burn-gpu-john':'john','burn-gpu-nccl':'nccl','burn-gpu-rvs':'rvs'}; - const gpuTargetMap = {'burn-gpu-bee':'nvidia-stress','burn-gpu-john':'nvidia-stress','burn-gpu-nccl':'nvidia-stress','burn-gpu-rvs':'amd-stress'}; - gpuIds.filter(id => { const el = document.getElementById(id); return el && el.checked && !el.disabled; }).forEach(id => { - const target = gpuTargetMap[id]; - const extra = target === 'nvidia-stress' ? {loader: loaderMap[id]} : {}; - enqueueTask(target, extra).then(d => { streamTask(d.task_id, target); done(); }); + const gpuTasks = [ + {id:'burn-gpu-bee', target:'nvidia-stress', label:'bee-gpu-burn', extra:{loader:'builtin'}}, + {id:'burn-gpu-dcgm', target:'nvidia', label:'DCGM Diagnostics (Official NVIDIA)', extra:{display_name:'NVIDIA DCGM Diagnostics (Official)'}}, + {id:'burn-gpu-john', target:'nvidia-stress', label:'John GPU Stress', extra:{loader:'john'}}, + {id:'burn-gpu-nccl', target:'nvidia-stress', label:'NCCL Interconnect Stress', extra:{loader:'nccl', display_name:'NCCL Interconnect Stress'}}, + {id:'burn-gpu-rvs', target:'amd-stress', label:'RVS GST', extra:{}}, + ]; + gpuTasks.filter(t => { const el = document.getElementById(t.id); return el && el.checked && !el.disabled; }).forEach(t => { + enqueueTask(t.target, t.extra).then(d => { streamTask(d.task_id, t.label); done(); }); }); // Compute tests @@ -955,17 +1151,19 @@ function runAll() { // Load GPU tool availability fetch('/api/gpu/tools').then(r => r.json()).then(tools => { - const nvidiaMap = {'bee-gpu-burn':'burn-gpu-bee','john':'burn-gpu-john','nccl':'burn-gpu-nccl','rvs':'burn-gpu-rvs'}; - const noteMap = {'bee-gpu-burn':'note-bee','john':'note-john','nccl':'note-nccl','rvs':'note-rvs'}; + const nvidiaMap = {'bee-gpu-burn':'burn-gpu-bee','dcgm':'burn-gpu-dcgm','john':'burn-gpu-john','nccl':'burn-gpu-nccl','rvs':'burn-gpu-rvs'}; + const noteMap = {'bee-gpu-burn':'note-bee','dcgm':'note-dcgm','john':'note-john','nccl':'note-nccl','rvs':'note-rvs'}; tools.forEach(t => { const cb = document.getElementById(nvidiaMap[t.id]); const note = document.getElementById(noteMap[t.id]); if (!cb) return; if (t.available) { cb.disabled = false; - if (t.id === 'bee-gpu-burn') cb.checked = true; + if (t.id === 'bee-gpu-burn' || t.id === 'dcgm') cb.checked = true; } else { - const reason = t.vendor === 'nvidia' ? 'NVIDIA driver not running' : 'AMD driver not running'; + let reason = t.vendor === 'nvidia' ? 'NVIDIA driver not running' : 'AMD driver not running'; + if (t.id === 'dcgm' && t.vendor === 'nvidia') reason = 'dcgmi not available or NVIDIA driver not running'; + if (t.id === 'nccl' && t.vendor === 'nvidia') reason = 'NCCL interconnect tool unavailable or NVIDIA driver not running'; if (note) note.textContent = '— ' + reason; } }); @@ -1125,7 +1323,8 @@ func renderNetwork() string { // ── Services ────────────────────────────────────────────────────────────────── func renderServicesInline() string { - return `
+ return `

` + html.EscapeString(`bee-selfheal.timer is expected to be active; the oneshot bee-selfheal.service itself is not shown as a long-running service.`) + `

+

Loading...