feat(tui): GPU Platform Stress Test — live nvtop chart during test

Apply the same pattern as NVIDIA SAT: launch nvtop via tea.ExecProcess
so it occupies the full terminal as a live GPU chart (temp, power, fan,
utilisation lines) while the stress test runs in the background.

- Add screenGPUStressRunning screen + dedicated running/render handlers
- startGPUStressTest: tea.Batch(stress goroutine, tea.ExecProcess(nvtop))
- [o] reopen nvtop at any time; [a] abort (cancels context)
- Graceful degradation: test still runs if nvtop is not on PATH
- gpuStressDoneMsg routes result to screenOutput on completion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-03-26 10:01:31 +03:00
parent 540a9e39b8
commit 4669f14f4f
6 changed files with 97 additions and 15 deletions

View File

@@ -1,7 +1,6 @@
package tui
import (
"context"
"time"
"bee/audit/internal/platform"
@@ -140,20 +139,7 @@ func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
pollSATProgress("gpu-amd", since),
)
case actionRunFanStress:
m.busyTitle = "GPU Platform Stress Test"
m.progressPrefix = "fan-stress"
m.progressSince = time.Now()
m.progressLines = nil
since := m.progressSince
opts := hcFanStressOpts(m.hcMode, m.app)
return m, tea.Batch(
func() tea.Msg {
ctx := context.Background()
result, err := m.app.RunFanStressTestResult(ctx, opts)
return resultMsg{title: result.Title, body: result.Body, err: err, back: screenHealthCheck}
},
pollSATProgress("fan-stress", since),
)
return m.startGPUStressTest()
}
case "ctrl+c":
return m, tea.Quit

View File

@@ -44,3 +44,9 @@ type nvidiaSATDoneMsg struct {
body string
err error
}
type gpuStressDoneMsg struct {
title string
body string
err error
}

View File

@@ -3,6 +3,7 @@ package tui
import (
"context"
"fmt"
"os/exec"
"strings"
tea "github.com/charmbracelet/bubbletea"
@@ -155,6 +156,64 @@ func (m model) hcRunFanStress() (tea.Model, tea.Cmd) {
return m, nil
}
// startGPUStressTest launches the GPU Platform Stress Test and nvtop concurrently.
// nvtop occupies the full terminal as a live chart; the stress test runs in background.
func (m model) startGPUStressTest() (tea.Model, tea.Cmd) {
opts := hcFanStressOpts(m.hcMode, m.app)
ctx, cancel := context.WithCancel(context.Background())
m.gpuStressCancel = cancel
m.gpuStressAborted = false
m.screen = screenGPUStressRunning
m.nvidiaSATCursor = 0
stressCmd := func() tea.Msg {
result, err := m.app.RunFanStressTestResult(ctx, opts)
return gpuStressDoneMsg{title: result.Title, body: result.Body, err: err}
}
nvtopPath, lookErr := exec.LookPath("nvtop")
if lookErr != nil {
return m, stressCmd
}
return m, tea.Batch(
stressCmd,
tea.ExecProcess(exec.Command(nvtopPath), func(_ error) tea.Msg {
return nvtopClosedMsg{}
}),
)
}
// updateGPUStressRunning handles keys on the GPU stress running screen.
func (m model) updateGPUStressRunning(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "o", "O":
nvtopPath, err := exec.LookPath("nvtop")
if err != nil {
return m, nil
}
return m, tea.ExecProcess(exec.Command(nvtopPath), func(_ error) tea.Msg {
return nvtopClosedMsg{}
})
case "a", "A":
if m.gpuStressCancel != nil {
m.gpuStressCancel()
m.gpuStressCancel = nil
}
m.gpuStressAborted = true
m.screen = screenHealthCheck
m.cursor = 0
case "ctrl+c":
return m, tea.Quit
}
return m, nil
}
func renderGPUStressRunning() string {
return "GPU PLATFORM STRESS TEST\n\nTest is running...\n\n[o] Open nvtop [a] Abort test [ctrl+c] quit\n"
}
func (m model) hcRunAll() (tea.Model, tea.Cmd) {
for _, sel := range m.hcSel {
if sel {

View File

@@ -27,6 +27,7 @@ const (
screenConfirm screen = "confirm"
screenNvidiaSATSetup screen = "nvidia_sat_setup"
screenNvidiaSATRunning screen = "nvidia_sat_running"
screenGPUStressRunning screen = "gpu_stress_running"
)
type actionKind string
@@ -93,6 +94,10 @@ type model struct {
nvidiaSATCancel func()
nvidiaSATAborted bool
// GPU Platform Stress Test running
gpuStressCancel func()
gpuStressAborted bool
// SAT verbose progress (CPU / Memory / Storage / AMD GPU)
progressLines []string
progressPrefix string

View File

@@ -108,6 +108,28 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleNvidiaGPUsMsg(msg)
case nvtopClosedMsg:
return m, nil
case gpuStressDoneMsg:
if m.gpuStressAborted {
return m, nil
}
if m.gpuStressCancel != nil {
m.gpuStressCancel()
m.gpuStressCancel = nil
}
m.prevScreen = screenHealthCheck
m.screen = screenOutput
m.title = msg.title
if msg.err != nil {
body := strings.TrimSpace(msg.body)
if body == "" {
m.body = fmt.Sprintf("ERROR: %v", msg.err)
} else {
m.body = fmt.Sprintf("%s\n\nERROR: %v", body, msg.err)
}
} else {
m.body = msg.body
}
return m, m.refreshSnapshotCmd()
case nvidiaSATDoneMsg:
if m.nvidiaSATAborted {
return m, nil
@@ -152,6 +174,8 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.updateNvidiaSATSetup(msg)
case screenNvidiaSATRunning:
return m.updateNvidiaSATRunning(msg)
case screenGPUStressRunning:
return m.updateGPUStressRunning(msg)
case screenExportTargets:
return m.updateMenu(msg, len(m.targets), m.handleExportTargetsMenu)
case screenInterfacePick:

View File

@@ -78,6 +78,8 @@ func (m model) View() string {
body = renderNvidiaSATSetup(m)
case screenNvidiaSATRunning:
body = renderNvidiaSATRunning()
case screenGPUStressRunning:
body = renderGPUStressRunning()
case screenOutput:
body = fmt.Sprintf("%s\n\n%s\n\n[enter/esc] back [ctrl+c] quit\n", m.title, strings.TrimSpace(m.body))
default: