BMC: - collector/board.go: collectBMCFirmware() via ipmitool mc info, graceful skip if /dev/ipmi0 absent - collector/collector.go: append BMC firmware record to snap.Firmware - app/panel.go: show BMC version in TUI right-panel header alongside BIOS CPU SAT: - platform/sat.go: RunCPUAcceptancePack(baseDir, durationSec) — lscpu + sensors before/after + stress-ng - app/app.go: RunCPUAcceptancePack + RunCPUAcceptancePackResult methods, satRunner interface updated - app/panel.go: CPU row now reads real PASS/FAIL from cpu-*/summary.txt via satStatuses(); cpuDetailResult shows last SAT summary + audit data - tui/types.go: actionRunCPUSAT, confirmBody for CPU test with mode label - tui/screen_health_check.go: hcCPUDurations [60,300,900]s; hcRunSingle(CPU)→confirm screen; executeRunAll uses RunCPUAcceptancePackResult - tui/forms.go: actionRunCPUSAT → RunCPUAcceptancePackResult with mode duration - cmd/bee/main.go: bee sat cpu [--duration N] subcommand Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
308 lines
8.5 KiB
Go
308 lines
8.5 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":
|
|
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: strings.TrimSpace(string(raw))}
|
|
}
|
|
|
|
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-"},
|
|
{"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))
|
|
}
|