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:
Mikhail Chusavitin
2026-03-25 10:59:21 +03:00
parent 2abe2ce3aa
commit 1c80906c1f
18 changed files with 1015 additions and 336 deletions

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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

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

View File

@@ -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" {

View File

@@ -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?"
} }
} }

View File

@@ -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:

View File

@@ -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

Submodule bible updated: 456c1f022c...688b87e98d

View File

@@ -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
**Статус:** ожидает доступа к железу. **Статус:** ожидает доступа к железу.