From 20f834aa96690a0cb54f584193d41baf076e2f24 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 31 Mar 2026 10:13:31 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20v3.4=20=E2=80=94=20boot=20reliability,?= =?UTF-8?q?=20log=20readability,=20USB=20export,=20screen=20resolution,=20?= =?UTF-8?q?GRUB=20UEFI=20fix,=20memtest,=20KVM=20console=20stability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web UI / logs: - Strip ANSI escape codes and handle \r (progress bars) in task log output - Add USB export API + UI card on Export page (list removable devices, write audit JSON or support bundle) - Add Display Resolution card in Tools (xrandr-based, per-output mode selector) - Dashboard: audit status banner with auto-reload when audit task completes Boot & install: - bee-web starts immediately with no dependencies (was blocked by audit + network) - bee-audit.service redesigned: waits for bee-web healthz, sleeps 60s, enqueues audit via /api/audit/run (task system) - bee-install: fix GRUB UEFI — grub-install exit code was silently ignored (|| true); add --no-nvram fallback; always copy EFI/BOOT/BOOTX64.EFI fallback path - Add grub-efi-amd64, grub-pc, grub-efi-amd64-signed, shim-signed to package list (grub-install requires these, not just -bin variants) - memtest hook: fix binary/boot/ not created before cp; handle both Debian (no extension) and upstream (x64.efi) naming - bee-openbox-session: increase healthz wait from 30s to 120s KVM console stability: - runCmdJob: syscall.Setpriority(PRIO_PROCESS, pid, 10) on all stress subprocesses - lightdm.service.d: Nice=-5 so X server preempts stress processes Packages: add btop Co-Authored-By: Claude Sonnet 4.6 --- audit/internal/webui/api.go | 154 +++++++++++++- audit/internal/webui/pages.go | 195 +++++++++++++++++- audit/internal/webui/server.go | 7 + .../hooks/normal/9100-memtest.hook.binary | 31 ++- .../config/package-lists/bee.list.chroot | 7 + .../etc/systemd/system/bee-audit.service | 21 +- .../etc/systemd/system/bee-web.service | 5 +- .../system/lightdm.service.d/bee-limits.conf | 3 + iso/overlay/usr/local/bin/bee-install | 54 ++++- iso/overlay/usr/local/bin/bee-openbox-session | 7 +- 10 files changed, 458 insertions(+), 26 deletions(-) diff --git a/audit/internal/webui/api.go b/audit/internal/webui/api.go index a88aabc..beb0aeb 100644 --- a/audit/internal/webui/api.go +++ b/audit/internal/webui/api.go @@ -9,14 +9,18 @@ import ( "net/http" "os/exec" "path/filepath" + "regexp" "strings" "sync/atomic" + "syscall" "time" "bee/audit/internal/app" "bee/audit/internal/platform" ) +var ansiEscapeRE = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b[()][A-Z0-9]|\x1b[DABC]`) + // ── Job ID counter ──────────────────────────────────────────────────────────── var jobCounter atomic.Uint64 @@ -91,11 +95,25 @@ func runCmdJob(j *jobState, cmd *exec.Cmd) { j.finish(err.Error()) return } + // Lower the CPU scheduling priority of stress/audit subprocesses to nice+10 + // so the X server and kernel interrupt handling remain responsive under load + // (prevents KVM/IPMI graphical console from freezing during GPU stress tests). + if cmd.Process != nil { + _ = syscall.Setpriority(syscall.PRIO_PROCESS, cmd.Process.Pid, 10) + } go func() { scanner := bufio.NewScanner(pr) for scanner.Scan() { - j.append(scanner.Text()) + // Split on \r to handle progress-bar style output (e.g. \r overwrites) + // and strip ANSI escape codes so logs are readable in the browser. + parts := strings.Split(scanner.Text(), "\r") + for _, part := range parts { + line := ansiEscapeRE.ReplaceAllString(part, "") + if line != "" { + j.append(line) + } + } } }() @@ -405,6 +423,58 @@ func (h *handler) handleAPIExportBundle(w http.ResponseWriter, r *http.Request) }) } +func (h *handler) handleAPIExportUSBTargets(w http.ResponseWriter, _ *http.Request) { + if h.opts.App == nil { + writeError(w, http.StatusServiceUnavailable, "app not configured") + return + } + targets, err := h.opts.App.ListRemovableTargets() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if targets == nil { + targets = []platform.RemovableTarget{} + } + writeJSON(w, targets) +} + +func (h *handler) handleAPIExportUSBAudit(w http.ResponseWriter, r *http.Request) { + if h.opts.App == nil { + writeError(w, http.StatusServiceUnavailable, "app not configured") + return + } + var target platform.RemovableTarget + if err := json.NewDecoder(r.Body).Decode(&target); err != nil || target.Device == "" { + writeError(w, http.StatusBadRequest, "device is required") + return + } + result, err := h.opts.App.ExportLatestAuditResult(target) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, map[string]string{"status": "ok", "message": result.Body}) +} + +func (h *handler) handleAPIExportUSBBundle(w http.ResponseWriter, r *http.Request) { + if h.opts.App == nil { + writeError(w, http.StatusServiceUnavailable, "app not configured") + return + } + var target platform.RemovableTarget + if err := json.NewDecoder(r.Body).Decode(&target); err != nil || target.Device == "" { + writeError(w, http.StatusBadRequest, "device is required") + return + } + result, err := h.opts.App.ExportSupportBundleResult(target) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, map[string]string{"status": "ok", "message": result.Body}) +} + // ── GPU presence ────────────────────────────────────────────────────────────── func (h *handler) handleAPIGPUPresence(w http.ResponseWriter, r *http.Request) { @@ -790,3 +860,85 @@ func (h *handler) rollbackPendingNetworkChange() error { } return nil } + +// ── Display / Screen Resolution ─────────────────────────────────────────────── + +type displayMode struct { + Output string `json:"output"` + Mode string `json:"mode"` + Current bool `json:"current"` +} + +type displayInfo struct { + Output string `json:"output"` + Modes []displayMode `json:"modes"` + Current string `json:"current"` +} + +var xrandrOutputRE = regexp.MustCompile(`^(\S+)\s+connected`) +var xrandrModeRE = regexp.MustCompile(`^\s{3}(\d+x\d+)\s`) +var xrandrCurrentRE = regexp.MustCompile(`\*`) + +func parseXrandrOutput(out string) []displayInfo { + var infos []displayInfo + var cur *displayInfo + for _, line := range strings.Split(out, "\n") { + if m := xrandrOutputRE.FindStringSubmatch(line); m != nil { + if cur != nil { + infos = append(infos, *cur) + } + cur = &displayInfo{Output: m[1]} + continue + } + if cur == nil { + continue + } + if m := xrandrModeRE.FindStringSubmatch(line); m != nil { + isCurrent := xrandrCurrentRE.MatchString(line) + mode := displayMode{Output: cur.Output, Mode: m[1], Current: isCurrent} + cur.Modes = append(cur.Modes, mode) + if isCurrent { + cur.Current = m[1] + } + } + } + if cur != nil { + infos = append(infos, *cur) + } + return infos +} + +func (h *handler) handleAPIDisplayResolutions(w http.ResponseWriter, _ *http.Request) { + out, err := exec.Command("xrandr").Output() + if err != nil { + writeError(w, http.StatusInternalServerError, "xrandr: "+err.Error()) + return + } + writeJSON(w, parseXrandrOutput(string(out))) +} + +func (h *handler) handleAPIDisplaySet(w http.ResponseWriter, r *http.Request) { + var req struct { + Output string `json:"output"` + Mode string `json:"mode"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Output == "" || req.Mode == "" { + writeError(w, http.StatusBadRequest, "output and mode are required") + return + } + // Validate mode looks like WxH to prevent injection + if !regexp.MustCompile(`^\d+x\d+$`).MatchString(req.Mode) { + writeError(w, http.StatusBadRequest, "invalid mode format") + return + } + // Validate output name (no special chars) + if !regexp.MustCompile(`^[A-Za-z0-9_\-]+$`).MatchString(req.Output) { + writeError(w, http.StatusBadRequest, "invalid output name") + return + } + if out, err := exec.Command("xrandr", "--output", req.Output, "--mode", req.Mode).CombinedOutput(); err != nil { + writeError(w, http.StatusInternalServerError, "xrandr: "+strings.TrimSpace(string(out))) + return + } + writeJSON(w, map[string]string{"status": "ok", "output": req.Output, "mode": req.Mode}) +} diff --git a/audit/internal/webui/pages.go b/audit/internal/webui/pages.go index 924035e..e04039b 100644 --- a/audit/internal/webui/pages.go +++ b/audit/internal/webui/pages.go @@ -205,12 +205,83 @@ document.querySelectorAll('.terminal').forEach(function(t){ func renderDashboard(opts HandlerOptions) string { var b strings.Builder + b.WriteString(renderAuditStatusBanner(opts)) b.WriteString(renderHardwareSummaryCard(opts)) b.WriteString(renderHealthCard(opts)) b.WriteString(renderMetrics()) return b.String() } +// renderAuditStatusBanner shows a live progress banner when an audit task is +// running and auto-reloads the page when it completes. +func renderAuditStatusBanner(opts HandlerOptions) string { + // If audit data already exists, no banner needed — data is fresh. + // We still inject the polling script so a newly-triggered audit also reloads. + hasData := false + if _, err := loadSnapshot(opts.AuditPath); err == nil { + hasData = true + } + _ = hasData + + return ` +` +} + func renderAudit() string { return `
Audit Viewer
` } @@ -845,12 +916,79 @@ func renderExport(exportDir string) string { return `
Support Bundle

Creates a tar.gz archive of all audit files, SAT results, and logs.

-⬇ Download Support Bundle +↓ Download Support Bundle
Export Files
` + rows.String() + `
File
-
` + + +
+
Export to USB + +
+
+

Write audit JSON or support bundle directly to a removable USB drive.

+
Scanning for USB devices...
+
+
+
+
+` } func listExportFiles(exportDir string) ([]string, error) { @@ -876,6 +1014,56 @@ func listExportFiles(exportDir string) ([]string, error) { return entries, nil } +// ── Display Resolution ──────────────────────────────────────────────────────── + +func renderDisplayInline() string { + return `
Loading displays...
+
+` +} + // ── Tools ───────────────────────────────────────────────────────────────────── func renderTools() string { @@ -927,6 +1115,9 @@ function installToRAM() {
Services
` + renderServicesInline() + `
+
Display Resolution
` + + renderDisplayInline() + `
+