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)) }