- New tui/sat_progress.go: polls {DefaultSATBaseDir}/{prefix}-*/verbose.log every 300ms and parses completed/in-progress steps
- Busy screen now shows each step as PASS lscpu (234ms) / FAIL stress-ng (60.0s) / ... sensors-after instead of just "Working..."
2. Test results shown on screen (instead of just "Archive written to /path")
- RunCPUAcceptancePackResult, RunMemoryAcceptancePackResult, RunStorageAcceptancePackResult, RunAMDAcceptancePackResult now read summary.txt from the run directory and return a formatted per-step result:
Run: 2025-03-25T10:00:00Z
PASS lscpu
PASS sensors-before
FAIL stress-ng
PASS sensors-after
Overall: FAILED (ok=3 failed=1)
3. AMD GPU SAT with auto-detection
- platform.System.DetectGPUVendor(): checks /dev/nvidia0 → "nvidia", /dev/kfd → "amd"
- platform.System.RunAMDAcceptancePack(): runs rocm-smi, rocm-smi --showallinfo, dmidecode
- GPU SAT (G key / GPU row enter) automatically routes to AMD or NVIDIA based on detected vendor
- "Run All" also auto-detects vendor
4. Panel detail view
- GPU detail now shows the most recent (NVIDIA or AMD) SAT result, whichever is newer
- All SAT detail views use the same human-readable formatSATDetail format
388 lines
11 KiB
Go
388 lines
11 KiB
Go
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))
|
|
}
|