Embed MOTD banner into TUI

This commit is contained in:
Mikhail Chusavitin
2026-03-25 18:11:17 +03:00
parent f8c997d272
commit 1e62f828c6
6 changed files with 224 additions and 76 deletions

View File

@@ -27,8 +27,9 @@ type exportTargetsMsg struct {
err error
}
type panelMsg struct {
data app.HardwarePanelData
type snapshotMsg struct {
banner string
panel app.HardwarePanelData
}
type nvidiaGPUsMsg struct {

View File

@@ -0,0 +1,30 @@
package tui
import (
"bee/audit/internal/app"
tea "github.com/charmbracelet/bubbletea"
)
func (m model) refreshSnapshotCmd() tea.Cmd {
if m.app == nil {
return nil
}
return func() tea.Msg {
return snapshotMsg{
banner: m.app.MainBanner(),
panel: m.app.LoadHardwarePanel(),
}
}
}
func shouldRefreshSnapshot(prev, next model) bool {
return prev.screen != next.screen || prev.busy != next.busy
}
func emptySnapshot() snapshotMsg {
return snapshotMsg{
banner: "",
panel: app.HardwarePanelData{},
}
}

View File

@@ -53,9 +53,9 @@ func TestUpdateMainMenuEnterActions(t *testing.T) {
wantBusy bool
wantCmd bool
}{
{name: "health_check", cursor: 0, wantScreen: screenHealthCheck},
{name: "health_check", cursor: 0, wantScreen: screenHealthCheck, wantCmd: true},
{name: "export", cursor: 1, wantScreen: screenMain, wantBusy: true, wantCmd: true},
{name: "settings", cursor: 2, wantScreen: screenSettings},
{name: "settings", cursor: 2, wantScreen: screenSettings, wantCmd: true},
{name: "exit", cursor: 3, wantScreen: screenMain, wantCmd: true},
}
@@ -460,6 +460,55 @@ func TestViewOutputScreenRendersBodyAndBackHint(t *testing.T) {
}
}
func TestViewRendersBannerModuleAboveScreenBody(t *testing.T) {
t.Parallel()
m := newTestModel()
m.banner = "System: Demo Server\nIP: 10.0.0.10"
m.width = 60
view := m.View()
for _, want := range []string{
"┌ MOTD ",
"System: Demo Server",
"IP: 10.0.0.10",
"Health Check",
"Export support bundle",
} {
if !strings.Contains(view, want) {
t.Fatalf("view missing %q\nview:\n%s", want, view)
}
}
}
func TestSnapshotMsgUpdatesBannerAndPanel(t *testing.T) {
t.Parallel()
m := newTestModel()
next, cmd := m.Update(snapshotMsg{
banner: "System: Demo",
panel: app.HardwarePanelData{
Header: []string{"Demo header"},
Rows: []app.ComponentRow{
{Key: "CPU", Status: "PASS", Detail: "ok"},
},
},
})
got := next.(model)
if cmd != nil {
t.Fatal("expected nil cmd")
}
if got.banner != "System: Demo" {
t.Fatalf("banner=%q want %q", got.banner, "System: Demo")
}
if len(got.panel.Rows) != 1 || got.panel.Rows[0].Key != "CPU" {
t.Fatalf("panel rows=%+v", got.panel.Rows)
}
}
func TestViewExportTargetsRendersDeviceMetadata(t *testing.T) {
t.Parallel()

View File

@@ -32,32 +32,32 @@ const (
type actionKind string
const (
actionNone actionKind = ""
actionDHCPOne actionKind = "dhcp_one"
actionStaticIPv4 actionKind = "static_ipv4"
actionExportBundle actionKind = "export_bundle"
actionRunAll actionKind = "run_all"
actionRunMemorySAT actionKind = "run_memory_sat"
actionRunStorageSAT actionKind = "run_storage_sat"
actionRunCPUSAT actionKind = "run_cpu_sat"
actionRunAMDGPUSAT actionKind = "run_amd_gpu_sat"
actionNone actionKind = ""
actionDHCPOne actionKind = "dhcp_one"
actionStaticIPv4 actionKind = "static_ipv4"
actionExportBundle actionKind = "export_bundle"
actionRunAll actionKind = "run_all"
actionRunMemorySAT actionKind = "run_memory_sat"
actionRunStorageSAT actionKind = "run_storage_sat"
actionRunCPUSAT actionKind = "run_cpu_sat"
actionRunAMDGPUSAT actionKind = "run_amd_gpu_sat"
)
type model struct {
app *app.App
runtimeMode runtimeenv.Mode
screen screen
prevScreen screen
cursor int
busy bool
busyTitle string
title string
body string
mainMenu []string
screen screen
prevScreen screen
cursor int
busy bool
busyTitle string
title string
body string
mainMenu []string
settingsMenu []string
networkMenu []string
serviceMenu []string
networkMenu []string
serviceMenu []string
services []string
interfaces []platform.InterfaceInfo
@@ -74,6 +74,7 @@ type model struct {
panel app.HardwarePanelData
panelFocus bool
panelCursor int
banner string
// Health Check screen
hcSel [4]bool
@@ -95,6 +96,9 @@ type model struct {
progressLines []string
progressPrefix string
progressSince time.Time
// Terminal size
width int
}
type formField struct {
@@ -151,9 +155,7 @@ func newModel(application *app.App, runtimeMode runtimeenv.Mode) model {
}
func (m model) Init() tea.Cmd {
return func() tea.Msg {
return panelMsg{data: m.app.LoadHardwarePanel()}
}
return m.refreshSnapshotCmd()
}
func (m model) confirmBody() (string, string) {

View File

@@ -9,6 +9,9 @@ import (
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
return m, nil
case tea.KeyMsg:
if m.busy {
if msg.String() == "ctrl+c" {
@@ -16,7 +19,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
}
return m.updateKey(msg)
next, cmd := m.updateKey(msg)
nextModel := next.(model)
if shouldRefreshSnapshot(m, nextModel) {
return nextModel, tea.Batch(cmd, nextModel.refreshSnapshotCmd())
}
return nextModel, cmd
case satProgressMsg:
if m.busy && m.progressPrefix != "" {
if len(msg.lines) > 0 {
@@ -25,6 +33,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, pollSATProgress(m.progressPrefix, m.progressSince)
}
return m, nil
case snapshotMsg:
m.banner = msg.banner
m.panel = msg.panel
return m, nil
case resultMsg:
m.busy = false
m.busyTitle = ""
@@ -49,7 +61,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.screen = screenOutput
m.cursor = 0
return m, nil
return m, m.refreshSnapshotCmd()
case servicesMsg:
m.busy = false
m.busyTitle = ""
@@ -58,12 +70,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.body = msg.err.Error()
m.prevScreen = screenSettings
m.screen = screenOutput
return m, nil
return m, m.refreshSnapshotCmd()
}
m.services = msg.services
m.screen = screenServices
m.cursor = 0
return m, nil
return m, m.refreshSnapshotCmd()
case interfacesMsg:
m.busy = false
m.busyTitle = ""
@@ -72,12 +84,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.body = msg.err.Error()
m.prevScreen = screenNetwork
m.screen = screenOutput
return m, nil
return m, m.refreshSnapshotCmd()
}
m.interfaces = msg.ifaces
m.screen = screenInterfacePick
m.cursor = 0
return m, nil
return m, m.refreshSnapshotCmd()
case exportTargetsMsg:
m.busy = false
m.busyTitle = ""
@@ -86,15 +98,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.body = msg.err.Error()
m.prevScreen = screenMain
m.screen = screenOutput
return m, nil
return m, m.refreshSnapshotCmd()
}
m.targets = msg.targets
m.screen = screenExportTargets
m.cursor = 0
return m, nil
case panelMsg:
m.panel = msg.data
return m, nil
return m, m.refreshSnapshotCmd()
case nvidiaGPUsMsg:
return m.handleNvidiaGPUsMsg(msg)
case nvtopClosedMsg:
@@ -120,7 +129,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.body = msg.body
}
return m, nil
return m, m.refreshSnapshotCmd()
}
return m, nil
}
@@ -154,10 +163,6 @@ func (m model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.body = ""
m.title = ""
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
case "ctrl+c":
return m, tea.Quit

View File

@@ -6,8 +6,8 @@ import (
"bee/audit/internal/platform"
"github.com/charmbracelet/lipgloss"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Column widths for two-column main layout.
@@ -34,6 +34,7 @@ func colorStatus(status string) string {
}
func (m model) View() string {
var body string
if m.busy {
title := "bee"
if m.busyTitle != "" {
@@ -46,41 +47,44 @@ func (m model) View() string {
fmt.Fprintf(&b, " %s\n", l)
}
b.WriteString("\n[ctrl+c] quit\n")
return b.String()
body = b.String()
} else {
body = fmt.Sprintf("%s\n\nWorking...\n\n[ctrl+c] quit\n", title)
}
} else {
switch m.screen {
case screenMain:
body = renderTwoColumnMain(m)
case screenHealthCheck:
body = renderHealthCheck(m)
case screenSettings:
body = renderMenu("Settings", "Select action", m.settingsMenu, m.cursor)
case screenNetwork:
body = renderMenu("Network", "Select action", m.networkMenu, m.cursor)
case screenServices:
body = renderMenu("Services", "Select service", m.services, m.cursor)
case screenServiceAction:
body = renderMenu("Service: "+m.selectedService, "Select action", m.serviceMenu, m.cursor)
case screenExportTargets:
body = renderMenu("Export support bundle", "Select removable filesystem", renderTargetItems(m.targets), m.cursor)
case screenInterfacePick:
body = renderMenu("Interfaces", "Select interface", renderInterfaceItems(m.interfaces), m.cursor)
case screenStaticForm:
body = renderForm("Static IPv4: "+m.selectedIface, m.formFields, m.formIndex)
case screenConfirm:
title, confirmBody := m.confirmBody()
body = renderConfirm(title, confirmBody, m.cursor)
case screenNvidiaSATSetup:
body = renderNvidiaSATSetup(m)
case screenNvidiaSATRunning:
body = renderNvidiaSATRunning()
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:
body = "bee\n"
}
return fmt.Sprintf("%s\n\nWorking...\n\n[ctrl+c] quit\n", title)
}
switch m.screen {
case screenMain:
return renderTwoColumnMain(m)
case screenHealthCheck:
return renderHealthCheck(m)
case screenSettings:
return renderMenu("Settings", "Select action", m.settingsMenu, m.cursor)
case screenNetwork:
return renderMenu("Network", "Select action", m.networkMenu, m.cursor)
case screenServices:
return renderMenu("Services", "Select service", m.services, m.cursor)
case screenServiceAction:
return renderMenu("Service: "+m.selectedService, "Select action", m.serviceMenu, m.cursor)
case screenExportTargets:
return renderMenu("Export support bundle", "Select removable filesystem", renderTargetItems(m.targets), m.cursor)
case screenInterfacePick:
return renderMenu("Interfaces", "Select interface", renderInterfaceItems(m.interfaces), m.cursor)
case screenStaticForm:
return renderForm("Static IPv4: "+m.selectedIface, m.formFields, m.formIndex)
case screenConfirm:
title, body := m.confirmBody()
return renderConfirm(title, body, m.cursor)
case screenNvidiaSATSetup:
return renderNvidiaSATSetup(m)
case screenNvidiaSATRunning:
return renderNvidiaSATRunning()
case screenOutput:
return fmt.Sprintf("%s\n\n%s\n\n[enter/esc] back [ctrl+c] quit\n", m.title, strings.TrimSpace(m.body))
default:
return "bee\n"
}
return m.renderWithBanner(body)
}
// renderTwoColumnMain renders the main screen with menu on the left and hardware panel on the right.
@@ -231,3 +235,60 @@ func resultCmd(title, body string, err error, back screen) tea.Cmd {
return resultMsg{title: title, body: body, err: err, back: back}
}
}
func (m model) renderWithBanner(body string) string {
body = strings.TrimRight(body, "\n")
banner := renderBannerModule(m.banner, m.width)
if banner == "" {
if body == "" {
return ""
}
return body + "\n"
}
if body == "" {
return banner + "\n"
}
return banner + "\n\n" + body + "\n"
}
func renderBannerModule(banner string, width int) string {
banner = strings.TrimSpace(banner)
if banner == "" {
return ""
}
lines := strings.Split(banner, "\n")
contentWidth := 0
for _, line := range lines {
if w := lipgloss.Width(line); w > contentWidth {
contentWidth = w
}
}
if width > 0 && width-4 > contentWidth {
contentWidth = width - 4
}
if contentWidth < 20 {
contentWidth = 20
}
label := " MOTD "
topFill := contentWidth + 2 - lipgloss.Width(label)
if topFill < 0 {
topFill = 0
}
var b strings.Builder
b.WriteString("┌" + label + strings.Repeat("─", topFill) + "┐\n")
for _, line := range lines {
b.WriteString("│ " + padRight(line, contentWidth) + " │\n")
}
b.WriteString("└" + strings.Repeat("─", contentWidth+2) + "┘")
return b.String()
}
func padRight(value string, width int) string {
if gap := width - lipgloss.Width(value); gap > 0 {
return value + strings.Repeat(" ", gap)
}
return value
}