feat(tui): rebuild TUI around hardware diagnostics (Health Check + two-column layout)
- Replace 12-item flat menu with 4-item main menu: Health Check, Export support bundle, Settings, Exit - Add Health Check screen (Lenovo-style): per-component checkboxes (GPU/MEM/DISK/CPU), Quick/Standard/Express modes, Run All, letter hotkeys G/M/S/C/R/A/1/2/3 - Add two-column main screen: left = menu, right = hardware panel with colored PASS/FAIL/CANCEL/N/A status per component; Tab/→ switches focus, Enter opens component detail - Add app.LoadHardwarePanel() + ComponentDetailResult() reading audit JSON and SAT summary.txt files - Move Network/Services/audit actions into Settings submenu - Export: support bundle only (remove separate audit JSON export) - Delete screen_acceptance.go; add screen_health_check.go, screen_settings.go, app/panel.go - Add BMC + CPU stress-ng tests to backlog - Update bible submodule - Rewrite tui_test.go for new screen/action structure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,11 +5,12 @@ go 1.24.0
|
|||||||
replace reanimator/chart => ../internal/chart
|
replace reanimator/chart => ../internal/chart
|
||||||
|
|
||||||
require github.com/charmbracelet/bubbletea v1.3.4
|
require github.com/charmbracelet/bubbletea v1.3.4
|
||||||
|
require github.com/charmbracelet/lipgloss v1.0.0
|
||||||
require reanimator/chart v0.0.0
|
require reanimator/chart v0.0.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/lipgloss v1.0.0 // indirect
|
github.com/charmbracelet/lipgloss v1.0.0 // promoted to direct — used for TUI colors
|
||||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
|||||||
285
audit/internal/app/panel.go
Normal file
285
audit/internal/app/panel.go
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
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)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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: "N/A",
|
||||||
|
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()
|
||||||
|
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() ActionResult {
|
||||||
|
raw, err := os.ReadFile(DefaultAuditJSONPath)
|
||||||
|
if err != nil {
|
||||||
|
return ActionResult{Title: "CPU", Body: "No audit data."}
|
||||||
|
}
|
||||||
|
var snap schema.HardwareIngestRequest
|
||||||
|
if err := json.Unmarshal(raw, &snap); err != nil {
|
||||||
|
return ActionResult{Title: "CPU", Body: "Audit data unreadable."}
|
||||||
|
}
|
||||||
|
if len(snap.Hardware.CPUs) == 0 {
|
||||||
|
return ActionResult{Title: "CPU", Body: "No CPU data in last audit."}
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
patterns := []struct {
|
||||||
|
key string
|
||||||
|
prefix string
|
||||||
|
}{
|
||||||
|
{"gpu", "gpu-nvidia-"},
|
||||||
|
{"memory", "memory-"},
|
||||||
|
{"storage", "storage-"},
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.pendingAction = actionNone
|
m.pendingAction = actionNone
|
||||||
return m, nil
|
return m, nil
|
||||||
case "enter":
|
case "enter":
|
||||||
if m.cursor == 1 {
|
if m.cursor == 1 { // Cancel
|
||||||
m.screen = m.confirmCancelTarget()
|
m.screen = m.confirmCancelTarget()
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
m.pendingAction = actionNone
|
m.pendingAction = actionNone
|
||||||
@@ -71,13 +71,6 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.busy = true
|
m.busy = true
|
||||||
switch m.pendingAction {
|
switch m.pendingAction {
|
||||||
case actionExportAudit:
|
|
||||||
m.busyTitle = "Export audit"
|
|
||||||
target := *m.selectedTarget
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result, err := m.app.ExportLatestAuditResult(target)
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenMain}
|
|
||||||
}
|
|
||||||
case actionExportBundle:
|
case actionExportBundle:
|
||||||
m.busyTitle = "Export support bundle"
|
m.busyTitle = "Export support bundle"
|
||||||
target := *m.selectedTarget
|
target := *m.selectedTarget
|
||||||
@@ -85,23 +78,19 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
result, err := m.app.ExportSupportBundleResult(target)
|
result, err := m.app.ExportSupportBundleResult(target)
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenMain}
|
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenMain}
|
||||||
}
|
}
|
||||||
case actionRunNvidiaSAT:
|
case actionRunAll:
|
||||||
m.busyTitle = "NVIDIA SAT"
|
return m.executeRunAll()
|
||||||
return m, func() tea.Msg {
|
|
||||||
result, err := m.app.RunNvidiaAcceptancePackResult("")
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenAcceptance}
|
|
||||||
}
|
|
||||||
case actionRunMemorySAT:
|
case actionRunMemorySAT:
|
||||||
m.busyTitle = "Memory SAT"
|
m.busyTitle = "Memory test"
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
result, err := m.app.RunMemoryAcceptancePackResult("")
|
result, err := m.app.RunMemoryAcceptancePackResult("")
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenAcceptance}
|
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenHealthCheck}
|
||||||
}
|
}
|
||||||
case actionRunStorageSAT:
|
case actionRunStorageSAT:
|
||||||
m.busyTitle = "Storage SAT"
|
m.busyTitle = "Storage test"
|
||||||
return m, func() tea.Msg {
|
return m, func() tea.Msg {
|
||||||
result, err := m.app.RunStorageAcceptancePackResult("")
|
result, err := m.app.RunStorageAcceptancePackResult("")
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenAcceptance}
|
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenHealthCheck}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
@@ -112,16 +101,10 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
func (m model) confirmCancelTarget() screen {
|
func (m model) confirmCancelTarget() screen {
|
||||||
switch m.pendingAction {
|
switch m.pendingAction {
|
||||||
case actionExportAudit:
|
|
||||||
return screenExportTargets
|
|
||||||
case actionExportBundle:
|
case actionExportBundle:
|
||||||
return screenExportTargets
|
return screenExportTargets
|
||||||
case actionRunNvidiaSAT:
|
case actionRunAll, actionRunMemorySAT, actionRunStorageSAT:
|
||||||
fallthrough
|
return screenHealthCheck
|
||||||
case actionRunMemorySAT:
|
|
||||||
fallthrough
|
|
||||||
case actionRunStorageSAT:
|
|
||||||
return screenAcceptance
|
|
||||||
default:
|
default:
|
||||||
return screenMain
|
return screenMain
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import "bee/audit/internal/platform"
|
import (
|
||||||
|
"bee/audit/internal/app"
|
||||||
|
"bee/audit/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
type resultMsg struct {
|
type resultMsg struct {
|
||||||
title string
|
title string
|
||||||
@@ -24,8 +27,8 @@ type exportTargetsMsg struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
type bannerMsg struct {
|
type panelMsg struct {
|
||||||
text string
|
data app.HardwarePanelData
|
||||||
}
|
}
|
||||||
|
|
||||||
type nvidiaGPUsMsg struct {
|
type nvidiaGPUsMsg struct {
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import tea "github.com/charmbracelet/bubbletea"
|
|
||||||
|
|
||||||
func (m model) handleAcceptanceMenu() (tea.Model, tea.Cmd) {
|
|
||||||
if m.cursor == 3 {
|
|
||||||
m.screen = screenMain
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
switch m.cursor {
|
|
||||||
case 0:
|
|
||||||
return m.enterNvidiaSATSetup()
|
|
||||||
case 1:
|
|
||||||
m.pendingAction = actionRunMemorySAT
|
|
||||||
case 2:
|
|
||||||
m.pendingAction = actionRunStorageSAT
|
|
||||||
}
|
|
||||||
m.screen = screenConfirm
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
@@ -4,17 +4,11 @@ import tea "github.com/charmbracelet/bubbletea"
|
|||||||
|
|
||||||
func (m model) handleExportTargetsMenu() (tea.Model, tea.Cmd) {
|
func (m model) handleExportTargetsMenu() (tea.Model, tea.Cmd) {
|
||||||
if len(m.targets) == 0 {
|
if len(m.targets) == 0 {
|
||||||
title := "Export audit"
|
return m, resultCmd("Export support bundle", "No removable filesystems found", nil, screenMain)
|
||||||
if m.pendingAction == actionExportBundle {
|
|
||||||
title = "Export support bundle"
|
|
||||||
}
|
|
||||||
return m, resultCmd(title, "No removable filesystems found", nil, screenMain)
|
|
||||||
}
|
}
|
||||||
target := m.targets[m.cursor]
|
target := m.targets[m.cursor]
|
||||||
m.selectedTarget = &target
|
m.selectedTarget = &target
|
||||||
if m.pendingAction == actionNone {
|
m.pendingAction = actionExportBundle
|
||||||
m.pendingAction = actionExportAudit
|
|
||||||
}
|
|
||||||
m.screen = screenConfirm
|
m.screen = screenConfirm
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|||||||
284
audit/internal/tui/screen_health_check.go
Normal file
284
audit/internal/tui/screen_health_check.go
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Component indices.
|
||||||
|
const (
|
||||||
|
hcGPU = 0
|
||||||
|
hcMemory = 1
|
||||||
|
hcStorage = 2
|
||||||
|
hcCPU = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cursor positions in Health Check screen.
|
||||||
|
const (
|
||||||
|
hcCurGPU = 0
|
||||||
|
hcCurMemory = 1
|
||||||
|
hcCurStorage = 2
|
||||||
|
hcCurCPU = 3
|
||||||
|
hcCurSelectAll = 4
|
||||||
|
hcCurModeQuick = 5
|
||||||
|
hcCurModeStd = 6
|
||||||
|
hcCurModeExpr = 7
|
||||||
|
hcCurRunAll = 8
|
||||||
|
hcCurTotal = 9
|
||||||
|
)
|
||||||
|
|
||||||
|
// hcModeDurations maps mode index (0=Quick,1=Standard,2=Express) to GPU stress seconds.
|
||||||
|
var hcModeDurations = [3]int{600, 3600, 28800}
|
||||||
|
|
||||||
|
func (m model) enterHealthCheck() (tea.Model, tea.Cmd) {
|
||||||
|
m.screen = screenHealthCheck
|
||||||
|
if !m.hcInitialized {
|
||||||
|
m.hcSel = [4]bool{true, true, true, true}
|
||||||
|
m.hcMode = 0
|
||||||
|
m.hcCursor = 0
|
||||||
|
m.hcInitialized = true
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) updateHealthCheck(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
if m.hcCursor > 0 {
|
||||||
|
m.hcCursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if m.hcCursor < hcCurTotal-1 {
|
||||||
|
m.hcCursor++
|
||||||
|
}
|
||||||
|
case " ":
|
||||||
|
switch m.hcCursor {
|
||||||
|
case hcCurGPU, hcCurMemory, hcCurStorage, hcCurCPU:
|
||||||
|
m.hcSel[m.hcCursor] = !m.hcSel[m.hcCursor]
|
||||||
|
case hcCurSelectAll:
|
||||||
|
allOn := m.hcSel[0] && m.hcSel[1] && m.hcSel[2] && m.hcSel[3]
|
||||||
|
for i := range m.hcSel {
|
||||||
|
m.hcSel[i] = !allOn
|
||||||
|
}
|
||||||
|
case hcCurModeQuick, hcCurModeStd, hcCurModeExpr:
|
||||||
|
m.hcMode = m.hcCursor - hcCurModeQuick
|
||||||
|
}
|
||||||
|
case "enter":
|
||||||
|
switch m.hcCursor {
|
||||||
|
case hcCurGPU, hcCurMemory, hcCurStorage, hcCurCPU:
|
||||||
|
return m.hcRunSingle(m.hcCursor)
|
||||||
|
case hcCurSelectAll:
|
||||||
|
allOn := m.hcSel[0] && m.hcSel[1] && m.hcSel[2] && m.hcSel[3]
|
||||||
|
for i := range m.hcSel {
|
||||||
|
m.hcSel[i] = !allOn
|
||||||
|
}
|
||||||
|
case hcCurModeQuick, hcCurModeStd, hcCurModeExpr:
|
||||||
|
m.hcMode = m.hcCursor - hcCurModeQuick
|
||||||
|
case hcCurRunAll:
|
||||||
|
return m.hcRunAll()
|
||||||
|
}
|
||||||
|
case "g", "G":
|
||||||
|
return m.hcRunSingle(hcGPU)
|
||||||
|
case "m", "M":
|
||||||
|
return m.hcRunSingle(hcMemory)
|
||||||
|
case "s", "S":
|
||||||
|
return m.hcRunSingle(hcStorage)
|
||||||
|
case "c", "C":
|
||||||
|
return m.hcRunSingle(hcCPU)
|
||||||
|
case "r", "R":
|
||||||
|
return m.hcRunAll()
|
||||||
|
case "a", "A":
|
||||||
|
allOn := m.hcSel[0] && m.hcSel[1] && m.hcSel[2] && m.hcSel[3]
|
||||||
|
for i := range m.hcSel {
|
||||||
|
m.hcSel[i] = !allOn
|
||||||
|
}
|
||||||
|
case "1":
|
||||||
|
m.hcMode = 0
|
||||||
|
case "2":
|
||||||
|
m.hcMode = 1
|
||||||
|
case "3":
|
||||||
|
m.hcMode = 2
|
||||||
|
case "esc":
|
||||||
|
m.screen = screenMain
|
||||||
|
m.cursor = 0
|
||||||
|
case "q", "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) hcRunSingle(idx int) (tea.Model, tea.Cmd) {
|
||||||
|
switch idx {
|
||||||
|
case hcGPU:
|
||||||
|
m.nvidiaDurIdx = m.hcMode
|
||||||
|
return m.enterNvidiaSATSetup()
|
||||||
|
case hcMemory:
|
||||||
|
m.pendingAction = actionRunMemorySAT
|
||||||
|
m.screen = screenConfirm
|
||||||
|
m.cursor = 0
|
||||||
|
return m, nil
|
||||||
|
case hcStorage:
|
||||||
|
m.pendingAction = actionRunStorageSAT
|
||||||
|
m.screen = screenConfirm
|
||||||
|
m.cursor = 0
|
||||||
|
return m, nil
|
||||||
|
case hcCPU:
|
||||||
|
m.busy = true
|
||||||
|
m.busyTitle = "CPU"
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
r := m.app.ComponentDetailResult("CPU")
|
||||||
|
return resultMsg{title: r.Title, body: r.Body, back: screenHealthCheck}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) hcRunAll() (tea.Model, tea.Cmd) {
|
||||||
|
for _, sel := range m.hcSel {
|
||||||
|
if sel {
|
||||||
|
m.pendingAction = actionRunAll
|
||||||
|
m.screen = screenConfirm
|
||||||
|
m.cursor = 0
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) executeRunAll() (tea.Model, tea.Cmd) {
|
||||||
|
durationSec := hcModeDurations[m.hcMode]
|
||||||
|
sel := m.hcSel
|
||||||
|
app := m.app
|
||||||
|
m.busy = true
|
||||||
|
m.busyTitle = "Health Check"
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
var parts []string
|
||||||
|
if sel[hcGPU] {
|
||||||
|
gpus, err := app.ListNvidiaGPUs()
|
||||||
|
if err != nil || len(gpus) == 0 {
|
||||||
|
parts = append(parts, "=== GPU ===\nNo NVIDIA GPUs detected or driver not loaded.")
|
||||||
|
} else {
|
||||||
|
var indices []int
|
||||||
|
sizeMB := 0
|
||||||
|
for _, g := range gpus {
|
||||||
|
indices = append(indices, g.Index)
|
||||||
|
if sizeMB == 0 || g.MemoryMB < sizeMB {
|
||||||
|
sizeMB = g.MemoryMB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sizeMB == 0 {
|
||||||
|
sizeMB = 64
|
||||||
|
}
|
||||||
|
r, err := app.RunNvidiaAcceptancePackWithOptions(context.Background(), "", durationSec, sizeMB, indices)
|
||||||
|
body := r.Body
|
||||||
|
if err != nil {
|
||||||
|
body += "\nERROR: " + err.Error()
|
||||||
|
}
|
||||||
|
parts = append(parts, "=== GPU ===\n"+body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sel[hcMemory] {
|
||||||
|
r, err := app.RunMemoryAcceptancePackResult("")
|
||||||
|
body := r.Body
|
||||||
|
if err != nil {
|
||||||
|
body += "\nERROR: " + err.Error()
|
||||||
|
}
|
||||||
|
parts = append(parts, "=== MEMORY ===\n"+body)
|
||||||
|
}
|
||||||
|
if sel[hcStorage] {
|
||||||
|
r, err := app.RunStorageAcceptancePackResult("")
|
||||||
|
body := r.Body
|
||||||
|
if err != nil {
|
||||||
|
body += "\nERROR: " + err.Error()
|
||||||
|
}
|
||||||
|
parts = append(parts, "=== STORAGE ===\n"+body)
|
||||||
|
}
|
||||||
|
if sel[hcCPU] {
|
||||||
|
r := app.ComponentDetailResult("CPU")
|
||||||
|
parts = append(parts, "=== CPU ===\n"+r.Body)
|
||||||
|
}
|
||||||
|
combined := strings.Join(parts, "\n\n")
|
||||||
|
if combined == "" {
|
||||||
|
combined = "No components selected."
|
||||||
|
}
|
||||||
|
return resultMsg{title: "Health Check", body: combined, back: screenHealthCheck}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderHealthCheck(m model) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
fmt.Fprintln(&b, "HEALTH CHECK")
|
||||||
|
fmt.Fprintln(&b)
|
||||||
|
fmt.Fprintln(&b, " Diagnostics:")
|
||||||
|
fmt.Fprintln(&b)
|
||||||
|
|
||||||
|
type comp struct{ name, desc, key string }
|
||||||
|
comps := []comp{
|
||||||
|
{"GPU", "nvidia-smi + bee-gpu-stress", "G"},
|
||||||
|
{"MEMORY", "memtester", "M"},
|
||||||
|
{"STORAGE", "smartctl + NVMe self-test", "S"},
|
||||||
|
{"CPU", "audit diagnostics", "C"},
|
||||||
|
}
|
||||||
|
for i, c := range comps {
|
||||||
|
pfx := " "
|
||||||
|
if m.hcCursor == i {
|
||||||
|
pfx = "> "
|
||||||
|
}
|
||||||
|
ch := "[ ]"
|
||||||
|
if m.hcSel[i] {
|
||||||
|
ch = "[x]"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%s%s %-8s %-28s [%s]\n", pfx, ch, c.name, c.desc, c.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(&b, " ─────────────────────────────────────────────────")
|
||||||
|
{
|
||||||
|
pfx := " "
|
||||||
|
if m.hcCursor == hcCurSelectAll {
|
||||||
|
pfx = "> "
|
||||||
|
}
|
||||||
|
allOn := m.hcSel[0] && m.hcSel[1] && m.hcSel[2] && m.hcSel[3]
|
||||||
|
ch := "[ ]"
|
||||||
|
if allOn {
|
||||||
|
ch = "[x]"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%s%s Select / Deselect All [A]\n", pfx, ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(&b)
|
||||||
|
fmt.Fprintln(&b, " Mode:")
|
||||||
|
modes := []struct{ label, key string }{
|
||||||
|
{"Quick", "1"},
|
||||||
|
{"Standard", "2"},
|
||||||
|
{"Express", "3"},
|
||||||
|
}
|
||||||
|
for i, mode := range modes {
|
||||||
|
pfx := " "
|
||||||
|
if m.hcCursor == hcCurModeQuick+i {
|
||||||
|
pfx = "> "
|
||||||
|
}
|
||||||
|
radio := "( )"
|
||||||
|
if m.hcMode == i {
|
||||||
|
radio = "(*)"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%s%s %-10s [%s]\n", pfx, radio, mode.label, mode.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(&b)
|
||||||
|
{
|
||||||
|
pfx := " "
|
||||||
|
if m.hcCursor == hcCurRunAll {
|
||||||
|
pfx = "> "
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%s[ RUN ALL [R] ]\n", pfx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(&b)
|
||||||
|
fmt.Fprintln(&b, "─────────────────────────────────────────────────────────────────")
|
||||||
|
fmt.Fprint(&b, "[↑↓] move [space/enter] toggle [letter] single test [R] run all [Esc] back")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -6,44 +6,9 @@ import (
|
|||||||
|
|
||||||
func (m model) handleMainMenu() (tea.Model, tea.Cmd) {
|
func (m model) handleMainMenu() (tea.Model, tea.Cmd) {
|
||||||
switch m.cursor {
|
switch m.cursor {
|
||||||
case 0:
|
case 0: // Health Check
|
||||||
m.screen = screenNetwork
|
return m.enterHealthCheck()
|
||||||
m.cursor = 0
|
case 1: // Export support bundle
|
||||||
return m, nil
|
|
||||||
case 1:
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Services"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
services, err := m.app.ListBeeServices()
|
|
||||||
return servicesMsg{services: services, err: err}
|
|
||||||
}
|
|
||||||
case 2:
|
|
||||||
m.screen = screenAcceptance
|
|
||||||
m.cursor = 0
|
|
||||||
return m, nil
|
|
||||||
case 3:
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Run audit"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result, err := m.app.RunAuditNow(m.runtimeMode)
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenMain}
|
|
||||||
}
|
|
||||||
case 4:
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Run self-check"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result, err := m.app.RunRuntimePreflightResult()
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenMain}
|
|
||||||
}
|
|
||||||
case 5:
|
|
||||||
m.pendingAction = actionExportAudit
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Export audit"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
targets, err := m.app.ListRemovableTargets()
|
|
||||||
return exportTargetsMsg{targets: targets, err: err}
|
|
||||||
}
|
|
||||||
case 6:
|
|
||||||
m.pendingAction = actionExportBundle
|
m.pendingAction = actionExportBundle
|
||||||
m.busy = true
|
m.busy = true
|
||||||
m.busyTitle = "Export support bundle"
|
m.busyTitle = "Export support bundle"
|
||||||
@@ -51,35 +16,11 @@ func (m model) handleMainMenu() (tea.Model, tea.Cmd) {
|
|||||||
targets, err := m.app.ListRemovableTargets()
|
targets, err := m.app.ListRemovableTargets()
|
||||||
return exportTargetsMsg{targets: targets, err: err}
|
return exportTargetsMsg{targets: targets, err: err}
|
||||||
}
|
}
|
||||||
case 7:
|
case 2: // Settings
|
||||||
m.busy = true
|
m.screen = screenSettings
|
||||||
m.busyTitle = "Required tools"
|
m.cursor = 0
|
||||||
return m, func() tea.Msg {
|
return m, nil
|
||||||
result := m.app.ToolCheckResult([]string{"dmidecode", "smartctl", "nvme", "ipmitool", "lspci", "ethtool", "bee", "nvidia-smi", "bee-gpu-stress", "memtester", "dhclient", "lsblk", "mount"})
|
case 3: // Exit
|
||||||
return resultMsg{title: result.Title, body: result.Body, back: screenMain}
|
|
||||||
}
|
|
||||||
case 8:
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Health summary"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result := m.app.HealthSummaryResult()
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, back: screenMain}
|
|
||||||
}
|
|
||||||
case 9:
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Runtime issues"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result := m.app.RuntimeHealthResult()
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, back: screenMain}
|
|
||||||
}
|
|
||||||
case 10:
|
|
||||||
m.busy = true
|
|
||||||
m.busyTitle = "Audit logs"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
result := m.app.AuditLogTailResult()
|
|
||||||
return resultMsg{title: result.Title, body: result.Body, back: screenMain}
|
|
||||||
}
|
|
||||||
case 11:
|
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func (m model) handleNetworkMenu() (tea.Model, tea.Cmd) {
|
|||||||
return interfacesMsg{ifaces: ifaces, err: err}
|
return interfacesMsg{ifaces: ifaces, err: err}
|
||||||
}
|
}
|
||||||
case 4:
|
case 4:
|
||||||
m.screen = screenMain
|
m.screen = screenSettings
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func (m model) handleNvidiaGPUsMsg(msg nvidiaGPUsMsg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.title = "NVIDIA SAT"
|
m.title = "NVIDIA SAT"
|
||||||
m.body = fmt.Sprintf("Failed to list GPUs: %v", msg.err)
|
m.body = fmt.Sprintf("Failed to list GPUs: %v", msg.err)
|
||||||
m.prevScreen = screenAcceptance
|
m.prevScreen = screenHealthCheck
|
||||||
m.screen = screenOutput
|
m.screen = screenOutput
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -90,11 +90,11 @@ func (m model) updateNvidiaSATSetup(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case m.nvidiaSATCursor == startIdx:
|
case m.nvidiaSATCursor == startIdx:
|
||||||
return m.startNvidiaSAT()
|
return m.startNvidiaSAT()
|
||||||
case m.nvidiaSATCursor == cancelIdx:
|
case m.nvidiaSATCursor == cancelIdx:
|
||||||
m.screen = screenAcceptance
|
m.screen = screenHealthCheck
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
}
|
}
|
||||||
case "esc":
|
case "esc":
|
||||||
m.screen = screenAcceptance
|
m.screen = screenHealthCheck
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
case "ctrl+c", "q":
|
case "ctrl+c", "q":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -173,7 +173,7 @@ func (m model) updateNvidiaSATRunning(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.nvidiaSATCancel = nil
|
m.nvidiaSATCancel = nil
|
||||||
}
|
}
|
||||||
m.nvidiaSATAborted = true
|
m.nvidiaSATAborted = true
|
||||||
m.screen = screenAcceptance
|
m.screen = screenHealthCheck
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
func (m model) handleServicesMenu() (tea.Model, tea.Cmd) {
|
func (m model) handleServicesMenu() (tea.Model, tea.Cmd) {
|
||||||
if len(m.services) == 0 {
|
if len(m.services) == 0 {
|
||||||
return m, resultCmd("Services", "No bee-* services found.", nil, screenMain)
|
return m, resultCmd("Services", "No bee-* services found.", nil, screenSettings)
|
||||||
}
|
}
|
||||||
m.selectedService = m.services[m.cursor]
|
m.selectedService = m.services[m.cursor]
|
||||||
m.screen = screenServiceAction
|
m.screen = screenServiceAction
|
||||||
|
|||||||
64
audit/internal/tui/screen_settings.go
Normal file
64
audit/internal/tui/screen_settings.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
func (m model) handleSettingsMenu() (tea.Model, tea.Cmd) {
|
||||||
|
switch m.cursor {
|
||||||
|
case 0: // Network
|
||||||
|
m.screen = screenNetwork
|
||||||
|
m.cursor = 0
|
||||||
|
return m, nil
|
||||||
|
case 1: // Services
|
||||||
|
m.busy = true
|
||||||
|
m.busyTitle = "Services"
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
services, err := m.app.ListBeeServices()
|
||||||
|
return servicesMsg{services: services, err: err}
|
||||||
|
}
|
||||||
|
case 2: // Re-run audit
|
||||||
|
m.busy = true
|
||||||
|
m.busyTitle = "Re-run audit"
|
||||||
|
runtimeMode := m.runtimeMode
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
result, err := m.app.RunAuditNow(runtimeMode)
|
||||||
|
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenSettings}
|
||||||
|
}
|
||||||
|
case 3: // Run self-check
|
||||||
|
m.busy = true
|
||||||
|
m.busyTitle = "Self-check"
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
result, err := m.app.RunRuntimePreflightResult()
|
||||||
|
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenSettings}
|
||||||
|
}
|
||||||
|
case 4: // Runtime issues
|
||||||
|
m.busy = true
|
||||||
|
m.busyTitle = "Runtime issues"
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
result := m.app.RuntimeHealthResult()
|
||||||
|
return resultMsg{title: result.Title, body: result.Body, back: screenSettings}
|
||||||
|
}
|
||||||
|
case 5: // Audit logs
|
||||||
|
m.busy = true
|
||||||
|
m.busyTitle = "Audit logs"
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
result := m.app.AuditLogTailResult()
|
||||||
|
return resultMsg{title: result.Title, body: result.Body, back: screenSettings}
|
||||||
|
}
|
||||||
|
case 6: // Check tools
|
||||||
|
m.busy = true
|
||||||
|
m.busyTitle = "Check tools"
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
result := m.app.ToolCheckResult([]string{
|
||||||
|
"dmidecode", "smartctl", "nvme", "ipmitool", "lspci",
|
||||||
|
"ethtool", "bee", "nvidia-smi", "bee-gpu-stress",
|
||||||
|
"memtester", "dhclient", "lsblk", "mount",
|
||||||
|
})
|
||||||
|
return resultMsg{title: result.Title, body: result.Body, back: screenSettings}
|
||||||
|
}
|
||||||
|
case 7: // Back
|
||||||
|
m.screen = screenMain
|
||||||
|
m.cursor = 0
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
@@ -53,11 +53,10 @@ func TestUpdateMainMenuEnterActions(t *testing.T) {
|
|||||||
wantBusy bool
|
wantBusy bool
|
||||||
wantCmd bool
|
wantCmd bool
|
||||||
}{
|
}{
|
||||||
{name: "network", cursor: 0, wantScreen: screenNetwork},
|
{name: "health_check", cursor: 0, wantScreen: screenHealthCheck},
|
||||||
{name: "services", cursor: 1, wantScreen: screenMain, wantBusy: true, wantCmd: true},
|
{name: "export", cursor: 1, wantScreen: screenMain, wantBusy: true, wantCmd: true},
|
||||||
{name: "acceptance", cursor: 2, wantScreen: screenAcceptance},
|
{name: "settings", cursor: 2, wantScreen: screenSettings},
|
||||||
{name: "run audit", cursor: 3, wantScreen: screenMain, wantBusy: true, wantCmd: true},
|
{name: "exit", cursor: 3, wantScreen: screenMain, wantCmd: true},
|
||||||
{name: "export", cursor: 4, wantScreen: screenMain, wantBusy: true, wantCmd: true},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
@@ -89,7 +88,7 @@ func TestUpdateConfirmCancelViaKeys(t *testing.T) {
|
|||||||
|
|
||||||
m := newTestModel()
|
m := newTestModel()
|
||||||
m.screen = screenConfirm
|
m.screen = screenConfirm
|
||||||
m.pendingAction = actionRunNvidiaSAT
|
m.pendingAction = actionRunMemorySAT
|
||||||
|
|
||||||
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight})
|
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||||
got := next.(model)
|
got := next.(model)
|
||||||
@@ -99,8 +98,8 @@ func TestUpdateConfirmCancelViaKeys(t *testing.T) {
|
|||||||
|
|
||||||
next, _ = got.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
next, _ = got.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
got = next.(model)
|
got = next.(model)
|
||||||
if got.screen != screenAcceptance {
|
if got.screen != screenHealthCheck {
|
||||||
t.Fatalf("screen=%q want %q", got.screen, screenAcceptance)
|
t.Fatalf("screen=%q want %q", got.screen, screenHealthCheck)
|
||||||
}
|
}
|
||||||
if got.cursor != 0 {
|
if got.cursor != 0 {
|
||||||
t.Fatalf("cursor=%d want 0 after cancel", got.cursor)
|
t.Fatalf("cursor=%d want 0 after cancel", got.cursor)
|
||||||
@@ -115,8 +114,8 @@ func TestMainMenuSimpleTransitions(t *testing.T) {
|
|||||||
cursor int
|
cursor int
|
||||||
wantScreen screen
|
wantScreen screen
|
||||||
}{
|
}{
|
||||||
{name: "network", cursor: 0, wantScreen: screenNetwork},
|
{name: "health_check", cursor: 0, wantScreen: screenHealthCheck},
|
||||||
{name: "acceptance", cursor: 2, wantScreen: screenAcceptance},
|
{name: "settings", cursor: 2, wantScreen: screenSettings},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
@@ -143,57 +142,42 @@ func TestMainMenuSimpleTransitions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMainMenuAsyncActionsSetBusy(t *testing.T) {
|
func TestMainMenuExportSetsBusy(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cursor int
|
|
||||||
}{
|
|
||||||
{name: "services", cursor: 1},
|
|
||||||
{name: "run audit", cursor: 3},
|
|
||||||
{name: "export", cursor: 4},
|
|
||||||
{name: "check tools", cursor: 5},
|
|
||||||
{name: "health summary", cursor: 6},
|
|
||||||
{name: "log tail", cursor: 7},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
test := test
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.cursor = test.cursor
|
|
||||||
|
|
||||||
next, cmd := m.handleMainMenu()
|
|
||||||
got := next.(model)
|
|
||||||
|
|
||||||
if !got.busy {
|
|
||||||
t.Fatalf("busy=false for %s", test.name)
|
|
||||||
}
|
|
||||||
if cmd == nil {
|
|
||||||
t.Fatalf("expected async cmd for %s", test.name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMainViewIncludesBanner(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m := newTestModel()
|
m := newTestModel()
|
||||||
m.banner = "System: Test Server | S/N ABC123\nIP: 10.0.0.10"
|
m.cursor = 1 // Export support bundle
|
||||||
|
|
||||||
|
next, cmd := m.handleMainMenu()
|
||||||
|
got := next.(model)
|
||||||
|
|
||||||
|
if !got.busy {
|
||||||
|
t.Fatal("busy=false for export")
|
||||||
|
}
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected async cmd for export")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainViewRendersTwoColumns(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m := newTestModel()
|
||||||
|
m.cursor = 1
|
||||||
|
|
||||||
view := m.View()
|
view := m.View()
|
||||||
if !strings.Contains(view, "System: Test Server | S/N ABC123") {
|
for _, want := range []string{
|
||||||
t.Fatalf("view missing system banner:\n%s", view)
|
"bee",
|
||||||
}
|
"Health Check",
|
||||||
if !strings.Contains(view, "IP: 10.0.0.10") {
|
"> Export support bundle",
|
||||||
t.Fatalf("view missing ip banner:\n%s", view)
|
"Settings",
|
||||||
}
|
"Exit",
|
||||||
if !strings.Contains(view, "Select action") {
|
"│",
|
||||||
t.Fatalf("view missing menu subtitle:\n%s", view)
|
"[↑↓] move",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(view, want) {
|
||||||
|
t.Fatalf("view missing %q\nview:\n%s", want, view)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,9 +189,9 @@ func TestEscapeNavigation(t *testing.T) {
|
|||||||
screen screen
|
screen screen
|
||||||
wantScreen screen
|
wantScreen screen
|
||||||
}{
|
}{
|
||||||
{name: "network to main", screen: screenNetwork, wantScreen: screenMain},
|
{name: "network to settings", screen: screenNetwork, wantScreen: screenSettings},
|
||||||
{name: "services to main", screen: screenServices, wantScreen: screenMain},
|
{name: "services to settings", screen: screenServices, wantScreen: screenSettings},
|
||||||
{name: "acceptance to main", screen: screenAcceptance, wantScreen: screenMain},
|
{name: "settings to main", screen: screenSettings, wantScreen: screenMain},
|
||||||
{name: "service action to services", screen: screenServiceAction, wantScreen: screenServices},
|
{name: "service action to services", screen: screenServiceAction, wantScreen: screenServices},
|
||||||
{name: "export targets to main", screen: screenExportTargets, wantScreen: screenMain},
|
{name: "export targets to main", screen: screenExportTargets, wantScreen: screenMain},
|
||||||
{name: "interface pick to network", screen: screenInterfacePick, wantScreen: screenNetwork},
|
{name: "interface pick to network", screen: screenInterfacePick, wantScreen: screenNetwork},
|
||||||
@@ -235,6 +219,24 @@ func TestEscapeNavigation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHealthCheckEscReturnsToMain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m := newTestModel()
|
||||||
|
m.screen = screenHealthCheck
|
||||||
|
m.hcCursor = 3
|
||||||
|
|
||||||
|
next, _ := m.updateHealthCheck(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
|
got := next.(model)
|
||||||
|
|
||||||
|
if got.screen != screenMain {
|
||||||
|
t.Fatalf("screen=%q want %q", got.screen, screenMain)
|
||||||
|
}
|
||||||
|
if got.cursor != 0 {
|
||||||
|
t.Fatalf("cursor=%d want 0", got.cursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOutputScreenReturnsToPreviousScreen(t *testing.T) {
|
func TestOutputScreenReturnsToPreviousScreen(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -255,14 +257,15 @@ func TestOutputScreenReturnsToPreviousScreen(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAcceptanceNvidiaSATOpensSetup(t *testing.T) {
|
func TestHealthCheckGPUOpensNvidiaSATSetup(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
m := newTestModel()
|
m := newTestModel()
|
||||||
m.screen = screenAcceptance
|
m.screen = screenHealthCheck
|
||||||
m.cursor = 0
|
m.hcInitialized = true
|
||||||
|
m.hcSel = [4]bool{true, true, true, true}
|
||||||
|
|
||||||
next, cmd := m.handleAcceptanceMenu()
|
next, cmd := m.hcRunSingle(hcGPU)
|
||||||
got := next.(model)
|
got := next.(model)
|
||||||
|
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
@@ -272,34 +275,37 @@ func TestAcceptanceNvidiaSATOpensSetup(t *testing.T) {
|
|||||||
t.Fatalf("screen=%q want %q", got.screen, screenNvidiaSATSetup)
|
t.Fatalf("screen=%q want %q", got.screen, screenNvidiaSATSetup)
|
||||||
}
|
}
|
||||||
|
|
||||||
// esc from setup returns to acceptance
|
// esc from setup returns to health check
|
||||||
next, _ = got.updateNvidiaSATSetup(tea.KeyMsg{Type: tea.KeyEsc})
|
next, _ = got.updateNvidiaSATSetup(tea.KeyMsg{Type: tea.KeyEsc})
|
||||||
got = next.(model)
|
got = next.(model)
|
||||||
if got.screen != screenAcceptance {
|
if got.screen != screenHealthCheck {
|
||||||
t.Fatalf("screen after esc=%q want %q", got.screen, screenAcceptance)
|
t.Fatalf("screen after esc=%q want %q", got.screen, screenHealthCheck)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAcceptanceMenuMapsNewTargets(t *testing.T) {
|
func TestHealthCheckRunSingleMapsActions(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
cursor int
|
idx int
|
||||||
want actionKind
|
want actionKind
|
||||||
}{
|
}{
|
||||||
{cursor: 1, want: actionRunMemorySAT},
|
{idx: hcMemory, want: actionRunMemorySAT},
|
||||||
{cursor: 2, want: actionRunStorageSAT},
|
{idx: hcStorage, want: actionRunStorageSAT},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
m := newTestModel()
|
m := newTestModel()
|
||||||
m.screen = screenAcceptance
|
m.screen = screenHealthCheck
|
||||||
m.cursor = test.cursor
|
m.hcInitialized = true
|
||||||
|
|
||||||
next, _ := m.handleAcceptanceMenu()
|
next, _ := m.hcRunSingle(test.idx)
|
||||||
got := next.(model)
|
got := next.(model)
|
||||||
if got.pendingAction != test.want {
|
if got.pendingAction != test.want {
|
||||||
t.Fatalf("cursor=%d pendingAction=%q want %q", test.cursor, got.pendingAction, test.want)
|
t.Fatalf("idx=%d pendingAction=%q want %q", test.idx, got.pendingAction, test.want)
|
||||||
|
}
|
||||||
|
if got.screen != screenConfirm {
|
||||||
|
t.Fatalf("idx=%d screen=%q want %q", test.idx, got.screen, screenConfirm)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,8 +326,8 @@ func TestExportTargetSelectionOpensConfirm(t *testing.T) {
|
|||||||
if got.screen != screenConfirm {
|
if got.screen != screenConfirm {
|
||||||
t.Fatalf("screen=%q want %q", got.screen, screenConfirm)
|
t.Fatalf("screen=%q want %q", got.screen, screenConfirm)
|
||||||
}
|
}
|
||||||
if got.pendingAction != actionExportAudit {
|
if got.pendingAction != actionExportBundle {
|
||||||
t.Fatalf("pendingAction=%q want %q", got.pendingAction, actionExportAudit)
|
t.Fatalf("pendingAction=%q want %q", got.pendingAction, actionExportBundle)
|
||||||
}
|
}
|
||||||
if got.selectedTarget == nil || got.selectedTarget.Device != "/dev/sdb1" {
|
if got.selectedTarget == nil || got.selectedTarget.Device != "/dev/sdb1" {
|
||||||
t.Fatalf("selectedTarget=%+v want /dev/sdb1", got.selectedTarget)
|
t.Fatalf("selectedTarget=%+v want /dev/sdb1", got.selectedTarget)
|
||||||
@@ -374,14 +380,24 @@ func TestConfirmCancelTarget(t *testing.T) {
|
|||||||
|
|
||||||
m := newTestModel()
|
m := newTestModel()
|
||||||
|
|
||||||
m.pendingAction = actionExportAudit
|
m.pendingAction = actionExportBundle
|
||||||
if got := m.confirmCancelTarget(); got != screenExportTargets {
|
if got := m.confirmCancelTarget(); got != screenExportTargets {
|
||||||
t.Fatalf("export cancel target=%q want %q", got, screenExportTargets)
|
t.Fatalf("export cancel target=%q want %q", got, screenExportTargets)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.pendingAction = actionRunNvidiaSAT
|
m.pendingAction = actionRunAll
|
||||||
if got := m.confirmCancelTarget(); got != screenAcceptance {
|
if got := m.confirmCancelTarget(); got != screenHealthCheck {
|
||||||
t.Fatalf("sat cancel target=%q want %q", got, screenAcceptance)
|
t.Fatalf("run all cancel target=%q want %q", got, screenHealthCheck)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.pendingAction = actionRunMemorySAT
|
||||||
|
if got := m.confirmCancelTarget(); got != screenHealthCheck {
|
||||||
|
t.Fatalf("memory sat cancel target=%q want %q", got, screenHealthCheck)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.pendingAction = actionRunStorageSAT
|
||||||
|
if got := m.confirmCancelTarget(); got != screenHealthCheck {
|
||||||
|
t.Fatalf("storage sat cancel target=%q want %q", got, screenHealthCheck)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.pendingAction = actionNone
|
m.pendingAction = actionNone
|
||||||
@@ -390,28 +406,6 @@ func TestConfirmCancelTarget(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestViewMainMenuRendersSelectedItem(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
m := newTestModel()
|
|
||||||
m.cursor = 1
|
|
||||||
|
|
||||||
view := m.View()
|
|
||||||
|
|
||||||
for _, want := range []string{
|
|
||||||
"bee",
|
|
||||||
"Select action",
|
|
||||||
" Network",
|
|
||||||
"> Services",
|
|
||||||
"Acceptance tests",
|
|
||||||
"[↑/↓] move [enter] select [esc] back [ctrl+c] quit",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(view, want) {
|
|
||||||
t.Fatalf("view missing %q\nview:\n%s", want, view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestViewBusyStateIsMinimal(t *testing.T) {
|
func TestViewBusyStateIsMinimal(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -430,12 +424,12 @@ func TestViewBusyStateUsesBusyTitle(t *testing.T) {
|
|||||||
|
|
||||||
m := newTestModel()
|
m := newTestModel()
|
||||||
m.busy = true
|
m.busy = true
|
||||||
m.busyTitle = "Export audit"
|
m.busyTitle = "Export support bundle"
|
||||||
|
|
||||||
view := m.View()
|
view := m.View()
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"Export audit",
|
"Export support bundle",
|
||||||
"Working...",
|
"Working...",
|
||||||
"[ctrl+c] quit",
|
"[ctrl+c] quit",
|
||||||
} {
|
} {
|
||||||
@@ -484,7 +478,7 @@ func TestViewExportTargetsRendersDeviceMetadata(t *testing.T) {
|
|||||||
view := m.View()
|
view := m.View()
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"Export audit",
|
"Export support bundle",
|
||||||
"Select removable filesystem",
|
"Select removable filesystem",
|
||||||
"> /dev/sdb1 [vfat 29G] label=BEEUSB mounted=/media/bee",
|
"> /dev/sdb1 [vfat 29G] label=BEEUSB mounted=/media/bee",
|
||||||
} {
|
} {
|
||||||
@@ -527,14 +521,14 @@ func TestViewConfirmScreenMatchesPendingExport(t *testing.T) {
|
|||||||
|
|
||||||
m := newTestModel()
|
m := newTestModel()
|
||||||
m.screen = screenConfirm
|
m.screen = screenConfirm
|
||||||
m.pendingAction = actionExportAudit
|
m.pendingAction = actionExportBundle
|
||||||
m.selectedTarget = &platform.RemovableTarget{Device: "/dev/sdb1"}
|
m.selectedTarget = &platform.RemovableTarget{Device: "/dev/sdb1"}
|
||||||
|
|
||||||
view := m.View()
|
view := m.View()
|
||||||
|
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"Export audit",
|
"Export support bundle",
|
||||||
"Copy latest audit JSON to /dev/sdb1?",
|
"Copy support bundle to /dev/sdb1?",
|
||||||
"> Confirm",
|
"> Confirm",
|
||||||
" Cancel",
|
" Cancel",
|
||||||
} {
|
} {
|
||||||
@@ -549,11 +543,11 @@ func TestResultMsgClearsBusyAndPendingAction(t *testing.T) {
|
|||||||
|
|
||||||
m := newTestModel()
|
m := newTestModel()
|
||||||
m.busy = true
|
m.busy = true
|
||||||
m.busyTitle = "Export audit"
|
m.busyTitle = "Export support bundle"
|
||||||
m.pendingAction = actionExportAudit
|
m.pendingAction = actionExportBundle
|
||||||
m.screen = screenConfirm
|
m.screen = screenConfirm
|
||||||
|
|
||||||
next, _ := m.Update(resultMsg{title: "Export audit", body: "done", back: screenMain})
|
next, _ := m.Update(resultMsg{title: "Export support bundle", body: "done", back: screenMain})
|
||||||
got := next.(model)
|
got := next.(model)
|
||||||
|
|
||||||
if got.busy {
|
if got.busy {
|
||||||
@@ -572,7 +566,7 @@ func TestResultMsgErrorWithoutBodyFormatsCleanly(t *testing.T) {
|
|||||||
|
|
||||||
m := newTestModel()
|
m := newTestModel()
|
||||||
|
|
||||||
next, _ := m.Update(resultMsg{title: "Export audit", err: assertErr("boom"), back: screenMain})
|
next, _ := m.Update(resultMsg{title: "Export support bundle", err: assertErr("boom"), back: screenMain})
|
||||||
got := next.(model)
|
got := next.(model)
|
||||||
|
|
||||||
if got.body != "ERROR: boom" {
|
if got.body != "ERROR: boom" {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"strings"
|
||||||
|
|
||||||
"bee/audit/internal/app"
|
"bee/audit/internal/app"
|
||||||
"bee/audit/internal/platform"
|
"bee/audit/internal/platform"
|
||||||
"bee/audit/internal/runtimeenv"
|
"bee/audit/internal/runtimeenv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
@@ -15,11 +14,12 @@ type screen string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
screenMain screen = "main"
|
screenMain screen = "main"
|
||||||
|
screenHealthCheck screen = "health_check"
|
||||||
|
screenSettings screen = "settings"
|
||||||
screenNetwork screen = "network"
|
screenNetwork screen = "network"
|
||||||
screenInterfacePick screen = "interface_pick"
|
screenInterfacePick screen = "interface_pick"
|
||||||
screenServices screen = "services"
|
screenServices screen = "services"
|
||||||
screenServiceAction screen = "service_action"
|
screenServiceAction screen = "service_action"
|
||||||
screenAcceptance screen = "acceptance"
|
|
||||||
screenExportTargets screen = "export_targets"
|
screenExportTargets screen = "export_targets"
|
||||||
screenOutput screen = "output"
|
screenOutput screen = "output"
|
||||||
screenStaticForm screen = "static_form"
|
screenStaticForm screen = "static_form"
|
||||||
@@ -34,9 +34,8 @@ const (
|
|||||||
actionNone actionKind = ""
|
actionNone actionKind = ""
|
||||||
actionDHCPOne actionKind = "dhcp_one"
|
actionDHCPOne actionKind = "dhcp_one"
|
||||||
actionStaticIPv4 actionKind = "static_ipv4"
|
actionStaticIPv4 actionKind = "static_ipv4"
|
||||||
actionExportAudit actionKind = "export_audit"
|
|
||||||
actionExportBundle actionKind = "export_bundle"
|
actionExportBundle actionKind = "export_bundle"
|
||||||
actionRunNvidiaSAT actionKind = "run_nvidia_sat"
|
actionRunAll actionKind = "run_all"
|
||||||
actionRunMemorySAT actionKind = "run_memory_sat"
|
actionRunMemorySAT actionKind = "run_memory_sat"
|
||||||
actionRunStorageSAT actionKind = "run_storage_sat"
|
actionRunStorageSAT actionKind = "run_storage_sat"
|
||||||
)
|
)
|
||||||
@@ -52,8 +51,8 @@ type model struct {
|
|||||||
busyTitle string
|
busyTitle string
|
||||||
title string
|
title string
|
||||||
body string
|
body string
|
||||||
banner string
|
|
||||||
mainMenu []string
|
mainMenu []string
|
||||||
|
settingsMenu []string
|
||||||
networkMenu []string
|
networkMenu []string
|
||||||
serviceMenu []string
|
serviceMenu []string
|
||||||
|
|
||||||
@@ -68,14 +67,25 @@ type model struct {
|
|||||||
formFields []formField
|
formFields []formField
|
||||||
formIndex int
|
formIndex int
|
||||||
|
|
||||||
|
// Hardware panel (right column)
|
||||||
|
panel app.HardwarePanelData
|
||||||
|
panelFocus bool
|
||||||
|
panelCursor int
|
||||||
|
|
||||||
|
// Health Check screen
|
||||||
|
hcSel [4]bool
|
||||||
|
hcMode int
|
||||||
|
hcCursor int
|
||||||
|
hcInitialized bool
|
||||||
|
|
||||||
// NVIDIA SAT setup
|
// NVIDIA SAT setup
|
||||||
nvidiaGPUs []platform.NvidiaGPU
|
nvidiaGPUs []platform.NvidiaGPU
|
||||||
nvidiaGPUSel []bool
|
nvidiaGPUSel []bool
|
||||||
nvidiaDurIdx int // index into nvidiaDurationOptions
|
nvidiaDurIdx int
|
||||||
nvidiaSATCursor int
|
nvidiaSATCursor int
|
||||||
|
|
||||||
// NVIDIA SAT running
|
// NVIDIA SAT running
|
||||||
nvidiaSATCancel context.CancelFunc
|
nvidiaSATCancel func()
|
||||||
nvidiaSATAborted bool
|
nvidiaSATAborted bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,18 +110,20 @@ func newModel(application *app.App, runtimeMode runtimeenv.Mode) model {
|
|||||||
runtimeMode: runtimeMode,
|
runtimeMode: runtimeMode,
|
||||||
screen: screenMain,
|
screen: screenMain,
|
||||||
mainMenu: []string{
|
mainMenu: []string{
|
||||||
|
"Health Check",
|
||||||
|
"Export support bundle",
|
||||||
|
"Settings",
|
||||||
|
"Exit",
|
||||||
|
},
|
||||||
|
settingsMenu: []string{
|
||||||
"Network",
|
"Network",
|
||||||
"Services",
|
"Services",
|
||||||
"Acceptance tests",
|
"Re-run audit",
|
||||||
"Run audit",
|
|
||||||
"Run self-check",
|
"Run self-check",
|
||||||
"Export audit",
|
"Runtime issues",
|
||||||
"Export support bundle",
|
"Audit logs",
|
||||||
"Check tools",
|
"Check tools",
|
||||||
"Show health summary",
|
"Back",
|
||||||
"Show runtime issues",
|
|
||||||
"Show audit logs",
|
|
||||||
"Exit",
|
|
||||||
},
|
},
|
||||||
networkMenu: []string{
|
networkMenu: []string{
|
||||||
"Show status",
|
"Show status",
|
||||||
@@ -132,6 +144,36 @@ func newModel(application *app.App, runtimeMode runtimeenv.Mode) model {
|
|||||||
|
|
||||||
func (m model) Init() tea.Cmd {
|
func (m model) Init() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
return bannerMsg{text: strings.TrimSpace(m.app.MainBanner())}
|
return panelMsg{data: m.app.LoadHardwarePanel()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) confirmBody() (string, string) {
|
||||||
|
switch m.pendingAction {
|
||||||
|
case actionExportBundle:
|
||||||
|
if m.selectedTarget == nil {
|
||||||
|
return "Export support bundle", "No target selected"
|
||||||
|
}
|
||||||
|
return "Export support bundle", "Copy support bundle to " + m.selectedTarget.Device + "?"
|
||||||
|
case actionRunAll:
|
||||||
|
modes := []string{"Quick", "Standard", "Express"}
|
||||||
|
mode := modes[m.hcMode]
|
||||||
|
var sel []string
|
||||||
|
names := []string{"GPU", "Memory", "Storage", "CPU"}
|
||||||
|
for i, on := range m.hcSel {
|
||||||
|
if on {
|
||||||
|
sel = append(sel, names[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(sel) == 0 {
|
||||||
|
return "Health Check", "No components selected."
|
||||||
|
}
|
||||||
|
return "Health Check", "Run: " + strings.Join(sel, " + ") + "\nMode: " + mode
|
||||||
|
case actionRunMemorySAT:
|
||||||
|
return "Memory test", "Run memtester?"
|
||||||
|
case actionRunStorageSAT:
|
||||||
|
return "Storage test", "Run storage diagnostic pack?"
|
||||||
|
default:
|
||||||
|
return "Confirm", "Proceed?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if m.busy {
|
if m.busy {
|
||||||
switch msg.String() {
|
if msg.String() == "ctrl+c" {
|
||||||
case "ctrl+c":
|
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
default:
|
|
||||||
return m, nil
|
|
||||||
}
|
}
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
return m.updateKey(msg)
|
return m.updateKey(msg)
|
||||||
case resultMsg:
|
case resultMsg:
|
||||||
@@ -48,7 +46,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.title = "Services"
|
m.title = "Services"
|
||||||
m.body = msg.err.Error()
|
m.body = msg.err.Error()
|
||||||
m.prevScreen = screenMain
|
m.prevScreen = screenSettings
|
||||||
m.screen = screenOutput
|
m.screen = screenOutput
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -62,7 +60,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.title = "interfaces"
|
m.title = "interfaces"
|
||||||
m.body = msg.err.Error()
|
m.body = msg.err.Error()
|
||||||
m.prevScreen = screenMain
|
m.prevScreen = screenNetwork
|
||||||
m.screen = screenOutput
|
m.screen = screenOutput
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -84,13 +82,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.screen = screenExportTargets
|
m.screen = screenExportTargets
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
return m, nil
|
return m, nil
|
||||||
case bannerMsg:
|
case panelMsg:
|
||||||
m.banner = strings.TrimSpace(msg.text)
|
m.panel = msg.data
|
||||||
return m, nil
|
return m, nil
|
||||||
case nvidiaGPUsMsg:
|
case nvidiaGPUsMsg:
|
||||||
return m.handleNvidiaGPUsMsg(msg)
|
return m.handleNvidiaGPUsMsg(msg)
|
||||||
case nvtopClosedMsg:
|
case nvtopClosedMsg:
|
||||||
// nvtop closed — stay on running screen (or result if SAT is already done)
|
|
||||||
return m, nil
|
return m, nil
|
||||||
case nvidiaSATDoneMsg:
|
case nvidiaSATDoneMsg:
|
||||||
if m.nvidiaSATAborted {
|
if m.nvidiaSATAborted {
|
||||||
@@ -100,7 +97,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.nvidiaSATCancel()
|
m.nvidiaSATCancel()
|
||||||
m.nvidiaSATCancel = nil
|
m.nvidiaSATCancel = nil
|
||||||
}
|
}
|
||||||
m.prevScreen = screenAcceptance
|
m.prevScreen = screenHealthCheck
|
||||||
m.screen = screenOutput
|
m.screen = screenOutput
|
||||||
m.title = msg.title
|
m.title = msg.title
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
@@ -115,22 +112,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch m.screen {
|
switch m.screen {
|
||||||
case screenMain:
|
case screenMain:
|
||||||
return m.updateMenu(msg, len(m.mainMenu), m.handleMainMenu)
|
return m.updateMain(msg)
|
||||||
|
case screenHealthCheck:
|
||||||
|
return m.updateHealthCheck(msg)
|
||||||
|
case screenSettings:
|
||||||
|
return m.updateMenu(msg, len(m.settingsMenu), m.handleSettingsMenu)
|
||||||
case screenNetwork:
|
case screenNetwork:
|
||||||
return m.updateMenu(msg, len(m.networkMenu), m.handleNetworkMenu)
|
return m.updateMenu(msg, len(m.networkMenu), m.handleNetworkMenu)
|
||||||
case screenServices:
|
case screenServices:
|
||||||
return m.updateMenu(msg, len(m.services), m.handleServicesMenu)
|
return m.updateMenu(msg, len(m.services), m.handleServicesMenu)
|
||||||
case screenServiceAction:
|
case screenServiceAction:
|
||||||
return m.updateMenu(msg, len(m.serviceMenu), m.handleServiceActionMenu)
|
return m.updateMenu(msg, len(m.serviceMenu), m.handleServiceActionMenu)
|
||||||
case screenAcceptance:
|
|
||||||
return m.updateMenu(msg, 4, m.handleAcceptanceMenu)
|
|
||||||
case screenNvidiaSATSetup:
|
case screenNvidiaSATSetup:
|
||||||
return m.updateNvidiaSATSetup(msg)
|
return m.updateNvidiaSATSetup(msg)
|
||||||
case screenNvidiaSATRunning:
|
case screenNvidiaSATRunning:
|
||||||
@@ -146,6 +144,10 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.body = ""
|
m.body = ""
|
||||||
m.title = ""
|
m.title = ""
|
||||||
m.pendingAction = actionNone
|
m.pendingAction = actionNone
|
||||||
|
// Refresh panel when returning to main screen.
|
||||||
|
if m.prevScreen == screenMain {
|
||||||
|
return m, func() tea.Msg { return panelMsg{data: m.app.LoadHardwarePanel()} }
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -155,13 +157,54 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case screenConfirm:
|
case screenConfirm:
|
||||||
return m.updateConfirm(msg)
|
return m.updateConfirm(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.String() == "ctrl+c" {
|
if msg.String() == "ctrl+c" {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateMain handles keys on the main (two-column) screen.
|
||||||
|
func (m model) updateMain(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if m.panelFocus {
|
||||||
|
return m.updateMainPanel(msg)
|
||||||
|
}
|
||||||
|
// Switch focus to right panel.
|
||||||
|
if (msg.String() == "tab" || msg.String() == "right" || msg.String() == "l") && len(m.panel.Rows) > 0 {
|
||||||
|
m.panelFocus = true
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.updateMenu(msg, len(m.mainMenu), m.handleMainMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateMainPanel handles keys when right panel has focus.
|
||||||
|
func (m model) updateMainPanel(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "up", "k":
|
||||||
|
if m.panelCursor > 0 {
|
||||||
|
m.panelCursor--
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if m.panelCursor < len(m.panel.Rows)-1 {
|
||||||
|
m.panelCursor++
|
||||||
|
}
|
||||||
|
case "enter":
|
||||||
|
if m.panelCursor < len(m.panel.Rows) {
|
||||||
|
key := m.panel.Rows[m.panelCursor].Key
|
||||||
|
m.busy = true
|
||||||
|
m.busyTitle = key
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
r := m.app.ComponentDetailResult(key)
|
||||||
|
return resultMsg{title: r.Title, body: r.Body, back: screenMain}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "tab", "left", "h", "esc":
|
||||||
|
m.panelFocus = false
|
||||||
|
case "q", "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) updateMenu(msg tea.KeyMsg, size int, onEnter func() (tea.Model, tea.Cmd)) (tea.Model, tea.Cmd) {
|
func (m model) updateMenu(msg tea.KeyMsg, size int, onEnter func() (tea.Model, tea.Cmd)) (tea.Model, tea.Cmd) {
|
||||||
if size == 0 {
|
if size == 0 {
|
||||||
size = 1
|
size = 1
|
||||||
@@ -179,7 +222,10 @@ func (m model) updateMenu(msg tea.KeyMsg, size int, onEnter func() (tea.Model, t
|
|||||||
return onEnter()
|
return onEnter()
|
||||||
case "esc":
|
case "esc":
|
||||||
switch m.screen {
|
switch m.screen {
|
||||||
case screenNetwork, screenServices, screenAcceptance:
|
case screenNetwork, screenServices:
|
||||||
|
m.screen = screenSettings
|
||||||
|
m.cursor = 0
|
||||||
|
case screenSettings:
|
||||||
m.screen = screenMain
|
m.screen = screenMain
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
case screenServiceAction:
|
case screenServiceAction:
|
||||||
|
|||||||
@@ -6,9 +6,33 @@ import (
|
|||||||
|
|
||||||
"bee/audit/internal/platform"
|
"bee/audit/internal/platform"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Column widths for two-column main layout.
|
||||||
|
const leftColWidth = 30
|
||||||
|
|
||||||
|
var (
|
||||||
|
stylePass = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // bright green
|
||||||
|
styleFail = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // bright red
|
||||||
|
styleCancel = lipgloss.NewStyle().Foreground(lipgloss.Color("11")) // bright yellow
|
||||||
|
styleNA = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark gray
|
||||||
|
)
|
||||||
|
|
||||||
|
func colorStatus(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "PASS":
|
||||||
|
return stylePass.Render("PASS")
|
||||||
|
case "FAIL":
|
||||||
|
return styleFail.Render("FAIL")
|
||||||
|
case "CANCEL":
|
||||||
|
return styleCancel.Render("CANC")
|
||||||
|
default:
|
||||||
|
return styleNA.Render("N/A ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m model) View() string {
|
func (m model) View() string {
|
||||||
if m.busy {
|
if m.busy {
|
||||||
title := "bee"
|
title := "bee"
|
||||||
@@ -19,23 +43,19 @@ func (m model) View() string {
|
|||||||
}
|
}
|
||||||
switch m.screen {
|
switch m.screen {
|
||||||
case screenMain:
|
case screenMain:
|
||||||
return renderMainMenu("bee", m.banner, "Select action", m.mainMenu, m.cursor)
|
return renderTwoColumnMain(m)
|
||||||
|
case screenHealthCheck:
|
||||||
|
return renderHealthCheck(m)
|
||||||
|
case screenSettings:
|
||||||
|
return renderMenu("Settings", "Select action", m.settingsMenu, m.cursor)
|
||||||
case screenNetwork:
|
case screenNetwork:
|
||||||
return renderMenu("Network", "Select action", m.networkMenu, m.cursor)
|
return renderMenu("Network", "Select action", m.networkMenu, m.cursor)
|
||||||
case screenServices:
|
case screenServices:
|
||||||
return renderMenu("Services", "Select service", m.services, m.cursor)
|
return renderMenu("Services", "Select service", m.services, m.cursor)
|
||||||
case screenServiceAction:
|
case screenServiceAction:
|
||||||
items := make([]string, len(m.serviceMenu))
|
return renderMenu("Service: "+m.selectedService, "Select action", m.serviceMenu, m.cursor)
|
||||||
copy(items, m.serviceMenu)
|
|
||||||
return renderMenu("Service: "+m.selectedService, "Select action", items, m.cursor)
|
|
||||||
case screenAcceptance:
|
|
||||||
return renderMenu("Acceptance tests", "Select action", []string{"Run NVIDIA command pack", "Run memory test", "Run storage diagnostic pack", "Back"}, m.cursor)
|
|
||||||
case screenExportTargets:
|
case screenExportTargets:
|
||||||
title := "Export audit"
|
return renderMenu("Export support bundle", "Select removable filesystem", renderTargetItems(m.targets), m.cursor)
|
||||||
if m.pendingAction == actionExportBundle {
|
|
||||||
title = "Export support bundle"
|
|
||||||
}
|
|
||||||
return renderMenu(title, "Select removable filesystem", renderTargetItems(m.targets), m.cursor)
|
|
||||||
case screenInterfacePick:
|
case screenInterfacePick:
|
||||||
return renderMenu("Interfaces", "Select interface", renderInterfaceItems(m.interfaces), m.cursor)
|
return renderMenu("Interfaces", "Select interface", renderInterfaceItems(m.interfaces), m.cursor)
|
||||||
case screenStaticForm:
|
case screenStaticForm:
|
||||||
@@ -54,27 +74,73 @@ func (m model) View() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m model) confirmBody() (string, string) {
|
// renderTwoColumnMain renders the main screen with menu on the left and hardware panel on the right.
|
||||||
switch m.pendingAction {
|
func renderTwoColumnMain(m model) string {
|
||||||
case actionExportAudit:
|
// Left column lines
|
||||||
if m.selectedTarget == nil {
|
leftLines := []string{"bee", ""}
|
||||||
return "Export audit", "No target selected"
|
for i, item := range m.mainMenu {
|
||||||
|
pfx := " "
|
||||||
|
if !m.panelFocus && m.cursor == i {
|
||||||
|
pfx = "> "
|
||||||
}
|
}
|
||||||
return "Export audit", fmt.Sprintf("Copy latest audit JSON to %s?", m.selectedTarget.Device)
|
leftLines = append(leftLines, pfx+item)
|
||||||
case actionExportBundle:
|
|
||||||
if m.selectedTarget == nil {
|
|
||||||
return "Export support bundle", "No target selected"
|
|
||||||
}
|
|
||||||
return "Export support bundle", fmt.Sprintf("Copy support bundle archive to %s?", m.selectedTarget.Device)
|
|
||||||
case actionRunNvidiaSAT:
|
|
||||||
return "NVIDIA SAT", "Run NVIDIA acceptance command pack?"
|
|
||||||
case actionRunMemorySAT:
|
|
||||||
return "Memory SAT", "Run runtime memory test with memtester?"
|
|
||||||
case actionRunStorageSAT:
|
|
||||||
return "Storage SAT", "Run storage diagnostic pack and start short self-tests where supported?"
|
|
||||||
default:
|
|
||||||
return "Confirm", "Proceed?"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Right column lines
|
||||||
|
rightLines := buildPanelLines(m)
|
||||||
|
|
||||||
|
// Render side by side
|
||||||
|
var b strings.Builder
|
||||||
|
maxRows := max(len(leftLines), len(rightLines))
|
||||||
|
for i := 0; i < maxRows; i++ {
|
||||||
|
l := ""
|
||||||
|
if i < len(leftLines) {
|
||||||
|
l = leftLines[i]
|
||||||
|
}
|
||||||
|
r := ""
|
||||||
|
if i < len(rightLines) {
|
||||||
|
r = rightLines[i]
|
||||||
|
}
|
||||||
|
w := lipgloss.Width(l)
|
||||||
|
if w < leftColWidth {
|
||||||
|
l += strings.Repeat(" ", leftColWidth-w)
|
||||||
|
}
|
||||||
|
b.WriteString(l + " │ " + r + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sep := strings.Repeat("─", leftColWidth) + "─┴─" + strings.Repeat("─", 46)
|
||||||
|
b.WriteString(sep + "\n")
|
||||||
|
|
||||||
|
if m.panelFocus {
|
||||||
|
b.WriteString("[↑↓] move [enter] details [tab/←] menu [ctrl+c] quit\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("[↑↓] move [enter] select [tab/→] panel [ctrl+c] quit\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPanelLines(m model) []string {
|
||||||
|
p := m.panel
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
for _, h := range p.Header {
|
||||||
|
lines = append(lines, h)
|
||||||
|
}
|
||||||
|
if len(p.Header) > 0 && len(p.Rows) > 0 {
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, row := range p.Rows {
|
||||||
|
pfx := " "
|
||||||
|
if m.panelFocus && m.panelCursor == i {
|
||||||
|
pfx = "> "
|
||||||
|
}
|
||||||
|
status := colorStatus(row.Status)
|
||||||
|
lines = append(lines, fmt.Sprintf("%s%s %-4s %s", pfx, status, row.Key, row.Detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTargetItems(targets []platform.RemovableTarget) []string {
|
func renderTargetItems(targets []platform.RemovableTarget) []string {
|
||||||
@@ -122,30 +188,6 @@ func renderMenu(title, subtitle string, items []string, cursor int) string {
|
|||||||
return body.String()
|
return body.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderMainMenu(title, banner, subtitle string, items []string, cursor int) string {
|
|
||||||
var body strings.Builder
|
|
||||||
fmt.Fprintf(&body, "%s\n\n", title)
|
|
||||||
if banner != "" {
|
|
||||||
body.WriteString(strings.TrimSpace(banner))
|
|
||||||
body.WriteString("\n\n")
|
|
||||||
}
|
|
||||||
body.WriteString(subtitle)
|
|
||||||
body.WriteString("\n\n")
|
|
||||||
if len(items) == 0 {
|
|
||||||
body.WriteString("(no items)\n")
|
|
||||||
} else {
|
|
||||||
for i, item := range items {
|
|
||||||
prefix := " "
|
|
||||||
if i == cursor {
|
|
||||||
prefix = "> "
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&body, "%s%s\n", prefix, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body.WriteString("\n[↑/↓] move [enter] select [esc] back [ctrl+c] quit\n")
|
|
||||||
return body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderForm(title string, fields []formField, idx int) string {
|
func renderForm(title string, fields []formField, idx int) string {
|
||||||
var body strings.Builder
|
var body strings.Builder
|
||||||
fmt.Fprintf(&body, "%s\n\n", title)
|
fmt.Fprintf(&body, "%s\n\n", title)
|
||||||
|
|||||||
2
bible
2
bible
Submodule bible updated: 456c1f022c...688b87e98d
@@ -1,5 +1,26 @@
|
|||||||
# Backlog
|
# Backlog
|
||||||
|
|
||||||
|
## BMC версия через IPMI
|
||||||
|
|
||||||
|
**Статус:** не реализовано.
|
||||||
|
|
||||||
|
Добавить сбор версии BMC firmware в board collector:
|
||||||
|
- Команда: `ipmitool mc info` → поле `Firmware Revision`
|
||||||
|
- Записывать в `hardware.firmware[]` как `{device_name: "BMC", version: "..."}`
|
||||||
|
- Показывать в TUI правой колонке рядом с BIOS версией
|
||||||
|
- Graceful skip если `/dev/ipmi0` отсутствует (silent: same pattern as PSU collector)
|
||||||
|
|
||||||
|
## CPU acceptance test через stress-ng
|
||||||
|
|
||||||
|
**Статус:** не реализовано. CPU в Health Check всегда `N/A`.
|
||||||
|
|
||||||
|
Добавить CPU SAT на базе `stress-ng`:
|
||||||
|
- Bake `stress-ng` в ISO (добавить в `bee.list.chroot`)
|
||||||
|
- Новый `bee sat cpu` — запускает `stress-ng --cpu 0 --cpu-method all --timeout <N>` где N = duration из режима (Quick=60s, Standard=300s, Express=900s)
|
||||||
|
- Параллельно снимает температуры через `sensors` и throttle-флаги из аудит JSON
|
||||||
|
- Результат: SAT архив с summary.txt в формате других SAT (overall_status=OK/FAILED)
|
||||||
|
- После реализации: CPU в Health Check получает реальный PASS/FAIL статус
|
||||||
|
|
||||||
## Real hardware validation
|
## Real hardware validation
|
||||||
|
|
||||||
**Статус:** ожидает доступа к железу.
|
**Статус:** ожидает доступа к железу.
|
||||||
|
|||||||
Reference in New Issue
Block a user