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

@@ -53,11 +53,10 @@ func TestUpdateMainMenuEnterActions(t *testing.T) {
wantBusy bool
wantCmd bool
}{
{name: "network", cursor: 0, wantScreen: screenNetwork},
{name: "services", cursor: 1, wantScreen: screenMain, wantBusy: true, wantCmd: true},
{name: "acceptance", cursor: 2, wantScreen: screenAcceptance},
{name: "run audit", cursor: 3, wantScreen: screenMain, wantBusy: true, wantCmd: true},
{name: "export", cursor: 4, wantScreen: screenMain, wantBusy: true, wantCmd: true},
{name: "health_check", cursor: 0, wantScreen: screenHealthCheck},
{name: "export", cursor: 1, wantScreen: screenMain, wantBusy: true, wantCmd: true},
{name: "settings", cursor: 2, wantScreen: screenSettings},
{name: "exit", cursor: 3, wantScreen: screenMain, wantCmd: true},
}
for _, test := range tests {
@@ -89,7 +88,7 @@ func TestUpdateConfirmCancelViaKeys(t *testing.T) {
m := newTestModel()
m.screen = screenConfirm
m.pendingAction = actionRunNvidiaSAT
m.pendingAction = actionRunMemorySAT
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight})
got := next.(model)
@@ -99,8 +98,8 @@ func TestUpdateConfirmCancelViaKeys(t *testing.T) {
next, _ = got.Update(tea.KeyMsg{Type: tea.KeyEnter})
got = next.(model)
if got.screen != screenAcceptance {
t.Fatalf("screen=%q want %q", got.screen, screenAcceptance)
if got.screen != screenHealthCheck {
t.Fatalf("screen=%q want %q", got.screen, screenHealthCheck)
}
if got.cursor != 0 {
t.Fatalf("cursor=%d want 0 after cancel", got.cursor)
@@ -115,8 +114,8 @@ func TestMainMenuSimpleTransitions(t *testing.T) {
cursor int
wantScreen screen
}{
{name: "network", cursor: 0, wantScreen: screenNetwork},
{name: "acceptance", cursor: 2, wantScreen: screenAcceptance},
{name: "health_check", cursor: 0, wantScreen: screenHealthCheck},
{name: "settings", cursor: 2, wantScreen: screenSettings},
}
for _, test := range tests {
@@ -143,57 +142,42 @@ func TestMainMenuSimpleTransitions(t *testing.T) {
}
}
func TestMainMenuAsyncActionsSetBusy(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) {
func TestMainMenuExportSetsBusy(t *testing.T) {
t.Parallel()
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()
if !strings.Contains(view, "System: Test Server | S/N ABC123") {
t.Fatalf("view missing system banner:\n%s", view)
}
if !strings.Contains(view, "IP: 10.0.0.10") {
t.Fatalf("view missing ip banner:\n%s", view)
}
if !strings.Contains(view, "Select action") {
t.Fatalf("view missing menu subtitle:\n%s", view)
for _, want := range []string{
"bee",
"Health Check",
"> Export support bundle",
"Settings",
"Exit",
"│",
"[↑↓] 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
wantScreen screen
}{
{name: "network to main", screen: screenNetwork, wantScreen: screenMain},
{name: "services to main", screen: screenServices, wantScreen: screenMain},
{name: "acceptance to main", screen: screenAcceptance, wantScreen: screenMain},
{name: "network to settings", screen: screenNetwork, wantScreen: screenSettings},
{name: "services to settings", screen: screenServices, wantScreen: screenSettings},
{name: "settings to main", screen: screenSettings, wantScreen: screenMain},
{name: "service action to services", screen: screenServiceAction, wantScreen: screenServices},
{name: "export targets to main", screen: screenExportTargets, wantScreen: screenMain},
{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) {
t.Parallel()
@@ -255,14 +257,15 @@ func TestOutputScreenReturnsToPreviousScreen(t *testing.T) {
}
}
func TestAcceptanceNvidiaSATOpensSetup(t *testing.T) {
func TestHealthCheckGPUOpensNvidiaSATSetup(t *testing.T) {
t.Parallel()
m := newTestModel()
m.screen = screenAcceptance
m.cursor = 0
m.screen = screenHealthCheck
m.hcInitialized = true
m.hcSel = [4]bool{true, true, true, true}
next, cmd := m.handleAcceptanceMenu()
next, cmd := m.hcRunSingle(hcGPU)
got := next.(model)
if cmd == nil {
@@ -272,34 +275,37 @@ func TestAcceptanceNvidiaSATOpensSetup(t *testing.T) {
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})
got = next.(model)
if got.screen != screenAcceptance {
t.Fatalf("screen after esc=%q want %q", got.screen, screenAcceptance)
if got.screen != screenHealthCheck {
t.Fatalf("screen after esc=%q want %q", got.screen, screenHealthCheck)
}
}
func TestAcceptanceMenuMapsNewTargets(t *testing.T) {
func TestHealthCheckRunSingleMapsActions(t *testing.T) {
t.Parallel()
tests := []struct {
cursor int
want actionKind
idx int
want actionKind
}{
{cursor: 1, want: actionRunMemorySAT},
{cursor: 2, want: actionRunStorageSAT},
{idx: hcMemory, want: actionRunMemorySAT},
{idx: hcStorage, want: actionRunStorageSAT},
}
for _, test := range tests {
m := newTestModel()
m.screen = screenAcceptance
m.cursor = test.cursor
m.screen = screenHealthCheck
m.hcInitialized = true
next, _ := m.handleAcceptanceMenu()
next, _ := m.hcRunSingle(test.idx)
got := next.(model)
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 {
t.Fatalf("screen=%q want %q", got.screen, screenConfirm)
}
if got.pendingAction != actionExportAudit {
t.Fatalf("pendingAction=%q want %q", got.pendingAction, actionExportAudit)
if got.pendingAction != actionExportBundle {
t.Fatalf("pendingAction=%q want %q", got.pendingAction, actionExportBundle)
}
if got.selectedTarget == nil || got.selectedTarget.Device != "/dev/sdb1" {
t.Fatalf("selectedTarget=%+v want /dev/sdb1", got.selectedTarget)
@@ -374,14 +380,24 @@ func TestConfirmCancelTarget(t *testing.T) {
m := newTestModel()
m.pendingAction = actionExportAudit
m.pendingAction = actionExportBundle
if got := m.confirmCancelTarget(); got != screenExportTargets {
t.Fatalf("export cancel target=%q want %q", got, screenExportTargets)
}
m.pendingAction = actionRunNvidiaSAT
if got := m.confirmCancelTarget(); got != screenAcceptance {
t.Fatalf("sat cancel target=%q want %q", got, screenAcceptance)
m.pendingAction = actionRunAll
if got := m.confirmCancelTarget(); got != screenHealthCheck {
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
@@ -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) {
t.Parallel()
@@ -430,12 +424,12 @@ func TestViewBusyStateUsesBusyTitle(t *testing.T) {
m := newTestModel()
m.busy = true
m.busyTitle = "Export audit"
m.busyTitle = "Export support bundle"
view := m.View()
for _, want := range []string{
"Export audit",
"Export support bundle",
"Working...",
"[ctrl+c] quit",
} {
@@ -484,7 +478,7 @@ func TestViewExportTargetsRendersDeviceMetadata(t *testing.T) {
view := m.View()
for _, want := range []string{
"Export audit",
"Export support bundle",
"Select removable filesystem",
"> /dev/sdb1 [vfat 29G] label=BEEUSB mounted=/media/bee",
} {
@@ -527,14 +521,14 @@ func TestViewConfirmScreenMatchesPendingExport(t *testing.T) {
m := newTestModel()
m.screen = screenConfirm
m.pendingAction = actionExportAudit
m.pendingAction = actionExportBundle
m.selectedTarget = &platform.RemovableTarget{Device: "/dev/sdb1"}
view := m.View()
for _, want := range []string{
"Export audit",
"Copy latest audit JSON to /dev/sdb1?",
"Export support bundle",
"Copy support bundle to /dev/sdb1?",
"> Confirm",
" Cancel",
} {
@@ -549,11 +543,11 @@ func TestResultMsgClearsBusyAndPendingAction(t *testing.T) {
m := newTestModel()
m.busy = true
m.busyTitle = "Export audit"
m.pendingAction = actionExportAudit
m.busyTitle = "Export support bundle"
m.pendingAction = actionExportBundle
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)
if got.busy {
@@ -572,7 +566,7 @@ func TestResultMsgErrorWithoutBodyFormatsCleanly(t *testing.T) {
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)
if got.body != "ERROR: boom" {