diff --git a/audit/bee b/audit/bee new file mode 100755 index 0000000..86279d6 Binary files /dev/null and b/audit/bee differ diff --git a/audit/internal/app/app.go b/audit/internal/app/app.go index 95c1580..836108d 100644 --- a/audit/internal/app/app.go +++ b/audit/internal/app/app.go @@ -1023,3 +1023,62 @@ func (a *App) ListInstallDisks() ([]platform.InstallDisk, error) { func (a *App) InstallToDisk(ctx context.Context, device string, logFile string) error { return a.installer.InstallToDisk(ctx, device, logFile) } + +func formatSATDetail(raw string) string { + var b strings.Builder + kv := parseKeyValueSummary(raw) + + if t, ok := kv["run_at_utc"]; ok { + fmt.Fprintf(&b, "Run: %s\n\n", t) + } + + lines := strings.Split(raw, "\n") + var stepKeys []string + seenStep := map[string]bool{} + for _, line := range lines { + if idx := strings.Index(line, "_status="); idx >= 0 { + key := line[:idx] + if !seenStep[key] && key != "overall" { + seenStep[key] = true + stepKeys = append(stepKeys, key) + } + } + } + + for _, key := range stepKeys { + status := kv[key+"_status"] + display := cleanSummaryKey(key) + switch status { + case "OK": + fmt.Fprintf(&b, "PASS %s\n", display) + case "FAILED": + fmt.Fprintf(&b, "FAIL %s\n", display) + case "UNSUPPORTED": + fmt.Fprintf(&b, "SKIP %s\n", display) + default: + fmt.Fprintf(&b, "? %s\n", display) + } + } + + if overall, ok := kv["overall_status"]; ok { + ok2 := kv["job_ok"] + failed := kv["job_failed"] + fmt.Fprintf(&b, "\nOverall: %s (ok=%s failed=%s)", overall, ok2, failed) + } + + return strings.TrimSpace(b.String()) +} + +func cleanSummaryKey(key string) string { + idx := strings.Index(key, "-") + if idx <= 0 { + return key + } + prefix := key[:idx] + for _, c := range prefix { + if c < '0' || c > '9' { + return key + } + } + return key[idx+1:] +} diff --git a/audit/internal/app/panel.go b/audit/internal/app/panel.go deleted file mode 100644 index d771213..0000000 --- a/audit/internal/app/panel.go +++ /dev/null @@ -1,387 +0,0 @@ -package app - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "bee/audit/internal/schema" -) - -// ComponentRow is one line in the hardware panel. -type ComponentRow struct { - Key string // "CPU", "MEM", "GPU", "DISK", "PSU" - Status string // "PASS", "FAIL", "CANCEL", "N/A" - Detail string // compact one-liner -} - -// HardwarePanelData holds everything the TUI right panel needs. -type HardwarePanelData struct { - Header []string - Rows []ComponentRow -} - -// LoadHardwarePanel reads the latest audit JSON and SAT summaries. -// Returns empty panel if no audit data exists yet. -func (a *App) LoadHardwarePanel() HardwarePanelData { - raw, err := os.ReadFile(DefaultAuditJSONPath) - if err != nil { - return HardwarePanelData{Header: []string{"No audit data — run audit first."}} - } - var snap schema.HardwareIngestRequest - if err := json.Unmarshal(raw, &snap); err != nil { - return HardwarePanelData{Header: []string{"Audit data unreadable."}} - } - - statuses := satStatuses() - - var header []string - if sys := formatSystemLine(snap.Hardware.Board); sys != "" { - header = append(header, sys) - } - for _, fw := range snap.Hardware.Firmware { - if fw.DeviceName == "BIOS" && fw.Version != "" { - header = append(header, "BIOS: "+fw.Version) - } - if fw.DeviceName == "BMC" && fw.Version != "" { - header = append(header, "BMC: "+fw.Version) - } - } - if ip := formatIPLine(a.network.ListInterfaces); ip != "" { - header = append(header, ip) - } - - var rows []ComponentRow - - if cpu := formatCPULine(snap.Hardware.CPUs); cpu != "" { - rows = append(rows, ComponentRow{ - Key: "CPU", - Status: statuses["cpu"], - Detail: strings.TrimPrefix(cpu, "CPU: "), - }) - } - if mem := formatMemoryLine(snap.Hardware.Memory); mem != "" { - rows = append(rows, ComponentRow{ - Key: "MEM", - Status: statuses["memory"], - Detail: strings.TrimPrefix(mem, "Memory: "), - }) - } - if gpu := formatGPULine(snap.Hardware.PCIeDevices); gpu != "" { - rows = append(rows, ComponentRow{ - Key: "GPU", - Status: statuses["gpu"], - Detail: strings.TrimPrefix(gpu, "GPU: "), - }) - } - if disk := formatStorageLine(snap.Hardware.Storage); disk != "" { - rows = append(rows, ComponentRow{ - Key: "DISK", - Status: statuses["storage"], - Detail: strings.TrimPrefix(disk, "Storage: "), - }) - } - if psu := formatPSULine(snap.Hardware.PowerSupplies); psu != "" { - rows = append(rows, ComponentRow{ - Key: "PSU", - Status: "N/A", - Detail: psu, - }) - } - - return HardwarePanelData{Header: header, Rows: rows} -} - -// ComponentDetailResult returns detail text for a component shown in the panel. -func (a *App) ComponentDetailResult(key string) ActionResult { - switch key { - case "CPU": - return a.cpuDetailResult(false) - case "MEM": - return a.satDetailResult("memory", "memory-", "MEM detail") - case "GPU": - // Prefer whichever GPU SAT was run most recently. - nv, _ := filepath.Glob(filepath.Join(DefaultSATBaseDir, "gpu-nvidia-*/summary.txt")) - am, _ := filepath.Glob(filepath.Join(DefaultSATBaseDir, "gpu-amd-*/summary.txt")) - sort.Strings(nv) - sort.Strings(am) - latestNV := "" - if len(nv) > 0 { - latestNV = nv[len(nv)-1] - } - latestAM := "" - if len(am) > 0 { - latestAM = am[len(am)-1] - } - if latestAM > latestNV { - return a.satDetailResult("gpu", "gpu-amd-", "GPU detail") - } - return a.satDetailResult("gpu", "gpu-nvidia-", "GPU detail") - case "DISK": - return a.satDetailResult("storage", "storage-", "DISK detail") - case "PSU": - return a.psuDetailResult() - default: - return ActionResult{Title: key, Body: "No detail available."} - } -} - -func (a *App) cpuDetailResult(satOnly bool) ActionResult { - var b strings.Builder - - // Show latest SAT summary if available. - satResult := a.satDetailResult("cpu", "cpu-", "CPU SAT") - if satResult.Body != "No test results found. Run a test first." { - fmt.Fprintln(&b, "=== Last SAT ===") - fmt.Fprintln(&b, satResult.Body) - fmt.Fprintln(&b) - } - - if satOnly { - body := strings.TrimSpace(b.String()) - if body == "" { - body = "No CPU SAT results found. Run a test first." - } - return ActionResult{Title: "CPU SAT", Body: body} - } - - raw, err := os.ReadFile(DefaultAuditJSONPath) - if err != nil { - return ActionResult{Title: "CPU", Body: strings.TrimSpace(b.String())} - } - var snap schema.HardwareIngestRequest - if err := json.Unmarshal(raw, &snap); err != nil { - return ActionResult{Title: "CPU", Body: strings.TrimSpace(b.String())} - } - if len(snap.Hardware.CPUs) == 0 { - return ActionResult{Title: "CPU", Body: strings.TrimSpace(b.String())} - } - fmt.Fprintln(&b, "=== Audit ===") - for i, cpu := range snap.Hardware.CPUs { - fmt.Fprintf(&b, "CPU %d\n", i) - if cpu.Model != nil { - fmt.Fprintf(&b, " Model: %s\n", *cpu.Model) - } - if cpu.Manufacturer != nil { - fmt.Fprintf(&b, " Vendor: %s\n", *cpu.Manufacturer) - } - if cpu.Cores != nil { - fmt.Fprintf(&b, " Cores: %d\n", *cpu.Cores) - } - if cpu.Threads != nil { - fmt.Fprintf(&b, " Threads: %d\n", *cpu.Threads) - } - if cpu.MaxFrequencyMHz != nil { - fmt.Fprintf(&b, " Max freq: %d MHz\n", *cpu.MaxFrequencyMHz) - } - if cpu.TemperatureC != nil { - fmt.Fprintf(&b, " Temp: %.1f°C\n", *cpu.TemperatureC) - } - if cpu.Throttled != nil { - fmt.Fprintf(&b, " Throttled: %v\n", *cpu.Throttled) - } - if cpu.CorrectableErrorCount != nil && *cpu.CorrectableErrorCount > 0 { - fmt.Fprintf(&b, " ECC correctable: %d\n", *cpu.CorrectableErrorCount) - } - if cpu.UncorrectableErrorCount != nil && *cpu.UncorrectableErrorCount > 0 { - fmt.Fprintf(&b, " ECC uncorrectable: %d\n", *cpu.UncorrectableErrorCount) - } - if i < len(snap.Hardware.CPUs)-1 { - fmt.Fprintln(&b) - } - } - return ActionResult{Title: "CPU", Body: strings.TrimSpace(b.String())} -} - -func (a *App) satDetailResult(statusKey, prefix, title string) ActionResult { - matches, err := filepath.Glob(filepath.Join(DefaultSATBaseDir, prefix+"*/summary.txt")) - if err != nil || len(matches) == 0 { - return ActionResult{Title: title, Body: "No test results found. Run a test first."} - } - sort.Strings(matches) - raw, err := os.ReadFile(matches[len(matches)-1]) - if err != nil { - return ActionResult{Title: title, Body: "Could not read test results."} - } - return ActionResult{Title: title, Body: formatSATDetail(strings.TrimSpace(string(raw)))} -} - -// formatSATDetail converts raw summary.txt key=value content to a human-readable per-step display. -func formatSATDetail(raw string) string { - var b strings.Builder - kv := parseKeyValueSummary(raw) - - if t, ok := kv["run_at_utc"]; ok { - fmt.Fprintf(&b, "Run: %s\n\n", t) - } - - // Collect step names in order they appear in the file - lines := strings.Split(raw, "\n") - var stepKeys []string - seenStep := map[string]bool{} - for _, line := range lines { - if idx := strings.Index(line, "_status="); idx >= 0 { - key := line[:idx] - if !seenStep[key] && key != "overall" { - seenStep[key] = true - stepKeys = append(stepKeys, key) - } - } - } - - for _, key := range stepKeys { - status := kv[key+"_status"] - display := cleanSummaryKey(key) - switch status { - case "OK": - fmt.Fprintf(&b, "PASS %s\n", display) - case "FAILED": - fmt.Fprintf(&b, "FAIL %s\n", display) - case "UNSUPPORTED": - fmt.Fprintf(&b, "SKIP %s\n", display) - default: - fmt.Fprintf(&b, "? %s\n", display) - } - } - - if overall, ok := kv["overall_status"]; ok { - ok2 := kv["job_ok"] - failed := kv["job_failed"] - fmt.Fprintf(&b, "\nOverall: %s (ok=%s failed=%s)", overall, ok2, failed) - } - - return strings.TrimSpace(b.String()) -} - -// cleanSummaryKey strips the leading numeric prefix from a SAT step key. -// "1-lscpu" → "lscpu", "3-stress-ng" → "stress-ng" -func cleanSummaryKey(key string) string { - idx := strings.Index(key, "-") - if idx <= 0 { - return key - } - prefix := key[:idx] - for _, c := range prefix { - if c < '0' || c > '9' { - return key - } - } - return key[idx+1:] -} - -func (a *App) psuDetailResult() ActionResult { - raw, err := os.ReadFile(DefaultAuditJSONPath) - if err != nil { - return ActionResult{Title: "PSU", Body: "No audit data."} - } - var snap schema.HardwareIngestRequest - if err := json.Unmarshal(raw, &snap); err != nil { - return ActionResult{Title: "PSU", Body: "Audit data unreadable."} - } - if len(snap.Hardware.PowerSupplies) == 0 { - return ActionResult{Title: "PSU", Body: "No PSU data in last audit."} - } - var b strings.Builder - for i, psu := range snap.Hardware.PowerSupplies { - fmt.Fprintf(&b, "PSU %d\n", i) - if psu.Model != nil { - fmt.Fprintf(&b, " Model: %s\n", *psu.Model) - } - if psu.Vendor != nil { - fmt.Fprintf(&b, " Vendor: %s\n", *psu.Vendor) - } - if psu.WattageW != nil { - fmt.Fprintf(&b, " Rated: %d W\n", *psu.WattageW) - } - if psu.InputPowerW != nil { - fmt.Fprintf(&b, " Input: %.1f W\n", *psu.InputPowerW) - } - if psu.OutputPowerW != nil { - fmt.Fprintf(&b, " Output: %.1f W\n", *psu.OutputPowerW) - } - if psu.TemperatureC != nil { - fmt.Fprintf(&b, " Temp: %.1f°C\n", *psu.TemperatureC) - } - if i < len(snap.Hardware.PowerSupplies)-1 { - fmt.Fprintln(&b) - } - } - return ActionResult{Title: "PSU", Body: strings.TrimSpace(b.String())} -} - -// satStatuses reads the latest summary.txt for each SAT type and returns -// a map of component key ("gpu","memory","storage") → status ("PASS","FAIL","CANCEL","N/A"). -func satStatuses() map[string]string { - result := map[string]string{ - "gpu": "N/A", - "memory": "N/A", - "storage": "N/A", - "cpu": "N/A", - } - patterns := []struct { - key string - prefix string - }{ - {"gpu", "gpu-nvidia-"}, - {"gpu", "gpu-amd-"}, - {"memory", "memory-"}, - {"storage", "storage-"}, - {"cpu", "cpu-"}, - } - for _, item := range patterns { - matches, err := filepath.Glob(filepath.Join(DefaultSATBaseDir, item.prefix+"*/summary.txt")) - if err != nil || len(matches) == 0 { - continue - } - sort.Strings(matches) - raw, err := os.ReadFile(matches[len(matches)-1]) - if err != nil { - continue - } - values := parseKeyValueSummary(string(raw)) - switch strings.ToUpper(strings.TrimSpace(values["overall_status"])) { - case "OK": - result[item.key] = "PASS" - case "FAILED": - result[item.key] = "FAIL" - case "CANCELED", "CANCELLED": - result[item.key] = "CANCEL" - } - } - return result -} - -func formatPSULine(psus []schema.HardwarePowerSupply) string { - var present []schema.HardwarePowerSupply - for _, psu := range psus { - if psu.Present != nil && !*psu.Present { - continue - } - present = append(present, psu) - } - if len(present) == 0 { - return "" - } - firstW := 0 - if present[0].WattageW != nil { - firstW = *present[0].WattageW - } - allSame := firstW > 0 - for _, p := range present[1:] { - w := 0 - if p.WattageW != nil { - w = *p.WattageW - } - if w != firstW { - allSame = false - break - } - } - if allSame && firstW > 0 { - return fmt.Sprintf("%dx %dW", len(present), firstW) - } - return fmt.Sprintf("%d PSU", len(present)) -} diff --git a/audit/internal/platform/gpu_metrics.go b/audit/internal/platform/gpu_metrics.go index 8cb1de7..03eef28 100644 --- a/audit/internal/platform/gpu_metrics.go +++ b/audit/internal/platform/gpu_metrics.go @@ -334,7 +334,7 @@ const ( ) // RenderGPUTerminalChart returns ANSI line charts (asciigraph-style) per GPU. -// Suitable for display in the TUI screenOutput. +// Used in SAT stress-test logs. func RenderGPUTerminalChart(rows []GPUMetricRow) string { seen := make(map[int]bool) var order []int @@ -377,162 +377,6 @@ func RenderGPUTerminalChart(rows []GPUMetricRow) string { return strings.TrimRight(b.String(), "\n") } -// RenderGPULiveChart renders all GPU metrics on a single combined chart per GPU. -// Each series is normalised to its own min–max and drawn in a different colour. -// chartWidth controls the width of the plot area (Y-axis label uses 5 extra chars). -func RenderGPULiveChart(rows []GPUMetricRow, chartWidth int) string { - if chartWidth < 20 { - chartWidth = 70 - } - const chartHeight = 14 - - seen := make(map[int]bool) - var order []int - gpuMap := make(map[int][]GPUMetricRow) - for _, r := range rows { - if !seen[r.GPUIndex] { - seen[r.GPUIndex] = true - order = append(order, r.GPUIndex) - } - gpuMap[r.GPUIndex] = append(gpuMap[r.GPUIndex], r) - } - - type seriesDef struct { - label string - color string - unit string - fn func(GPUMetricRow) float64 - } - defs := []seriesDef{ - {"Usage", ansiBlue, "%", func(r GPUMetricRow) float64 { return r.UsagePct }}, - {"Temp", ansiRed, "°C", func(r GPUMetricRow) float64 { return r.TempC }}, - {"Power", ansiGreen, "W", func(r GPUMetricRow) float64 { return r.PowerW }}, - } - - var b strings.Builder - for _, gpuIdx := range order { - gr := gpuMap[gpuIdx] - if len(gr) == 0 { - continue - } - elapsed := gr[len(gr)-1].ElapsedSec - - // Build value slices for each series. - type seriesData struct { - seriesDef - vals []float64 - mn float64 - mx float64 - } - var series []seriesData - for _, d := range defs { - vals := extractGPUField(gr, d.fn) - mn, mx := gpuMinMax(vals) - if mn == mx { - mx = mn + 1 - } - series = append(series, seriesData{d, vals, mn, mx}) - } - - // Shared character grid: row 0 = top (max), row chartHeight = bottom (min). - type cell struct { - ch rune - color string - } - grid := make([][]cell, chartHeight+1) - for r := range grid { - grid[r] = make([]cell, chartWidth) - for c := range grid[r] { - grid[r][c] = cell{' ', ""} - } - } - - // Plot each series onto the shared grid. - for _, s := range series { - w := chartWidth - if len(s.vals) < w { - w = len(s.vals) - } - data := gpuDownsample(s.vals, w) - prevRow := -1 - for x, v := range data { - row := chartHeight - int(math.Round((v-s.mn)/(s.mx-s.mn)*float64(chartHeight))) - if row < 0 { - row = 0 - } - if row > chartHeight { - row = chartHeight - } - if prevRow < 0 || prevRow == row { - grid[row][x] = cell{'─', s.color} - } else { - lo, hi := prevRow, row - if lo > hi { - lo, hi = hi, lo - } - for y := lo + 1; y < hi; y++ { - grid[y][x] = cell{'│', s.color} - } - if prevRow < row { - grid[prevRow][x] = cell{'╮', s.color} - grid[row][x] = cell{'╰', s.color} - } else { - grid[prevRow][x] = cell{'╯', s.color} - grid[row][x] = cell{'╭', s.color} - } - } - prevRow = row - } - } - - // Render: Y axis + data rows. - fmt.Fprintf(&b, "GPU %d (%.0fs) each series normalised to its range\n", gpuIdx, elapsed) - for r := 0; r <= chartHeight; r++ { - // Y axis label: 100% at top, 50% in middle, 0% at bottom. - switch r { - case 0: - fmt.Fprintf(&b, "%4s┤", "100%") - case chartHeight / 2: - fmt.Fprintf(&b, "%4s┤", "50%") - case chartHeight: - fmt.Fprintf(&b, "%4s┤", "0%") - default: - fmt.Fprintf(&b, "%4s│", "") - } - for c := 0; c < chartWidth; c++ { - cl := grid[r][c] - if cl.color != "" { - b.WriteString(cl.color) - b.WriteRune(cl.ch) - b.WriteString(ansiReset) - } else { - b.WriteRune(' ') - } - } - b.WriteRune('\n') - } - // Bottom axis. - b.WriteString(" └") - b.WriteString(strings.Repeat("─", chartWidth)) - b.WriteRune('\n') - - // Legend with current (last) values. - b.WriteString(" ") - for i, s := range series { - last := s.vals[len(s.vals)-1] - b.WriteString(s.color) - fmt.Fprintf(&b, "▐ %s: %.0f%s", s.label, last, s.unit) - b.WriteString(ansiReset) - if i < len(series)-1 { - b.WriteString(" ") - } - } - b.WriteRune('\n') - } - - return strings.TrimRight(b.String(), "\n") -} - // renderLineChart draws a single time-series line chart using box-drawing characters. // Produces output in the style of asciigraph: ╭─╮ │ ╰─╯ with a Y axis and caption. func renderLineChart(vals []float64, color, caption string, height, width int) string { diff --git a/iso/overlay/etc/motd b/iso/overlay/etc/motd index d7b05cd..29e4cac 100644 --- a/iso/overlay/etc/motd +++ b/iso/overlay/etc/motd @@ -12,6 +12,6 @@ Export dir: /appdata/bee/export Self-check: /appdata/bee/export/runtime-health.json - Open TUI: bee-tui + Web UI: http:/// SSH access: key auth (developers) or bee/eeb (password fallback) diff --git a/iso/overlay/etc/systemd/journald.conf.d/bee.conf b/iso/overlay/etc/systemd/journald.conf.d/bee.conf index 942ee34..6a9232d 100644 --- a/iso/overlay/etc/systemd/journald.conf.d/bee.conf +++ b/iso/overlay/etc/systemd/journald.conf.d/bee.conf @@ -1,4 +1,4 @@ [Journal] -# Do not forward service logs to the console — bee-tui runs on tty1 -# and log spam makes the screen unusable on physical monitors. +# Do not forward service logs to the console — prevents log spam on +# physical monitors and the local openbox desktop. ForwardToConsole=no