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 err error
} }
type panelMsg struct { type snapshotMsg struct {
data app.HardwarePanelData banner string
panel app.HardwarePanelData
} }
type nvidiaGPUsMsg struct { 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 wantBusy bool
wantCmd 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: "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}, {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) { func TestViewExportTargetsRendersDeviceMetadata(t *testing.T) {
t.Parallel() t.Parallel()

View File

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

View File

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

View File

@@ -6,8 +6,8 @@ import (
"bee/audit/internal/platform" "bee/audit/internal/platform"
"github.com/charmbracelet/lipgloss"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
// Column widths for two-column main layout. // Column widths for two-column main layout.
@@ -34,6 +34,7 @@ func colorStatus(status string) string {
} }
func (m model) View() string { func (m model) View() string {
var body string
if m.busy { if m.busy {
title := "bee" title := "bee"
if m.busyTitle != "" { if m.busyTitle != "" {
@@ -46,41 +47,44 @@ func (m model) View() string {
fmt.Fprintf(&b, " %s\n", l) fmt.Fprintf(&b, " %s\n", l)
} }
b.WriteString("\n[ctrl+c] quit\n") 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. // 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} 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
}