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

@@ -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,42 +47,45 @@ 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 {
return fmt.Sprintf("%s\n\nWorking...\n\n[ctrl+c] quit\n", title) body = fmt.Sprintf("%s\n\nWorking...\n\n[ctrl+c] quit\n", title)
} }
} else {
switch m.screen { switch m.screen {
case screenMain: case screenMain:
return renderTwoColumnMain(m) body = renderTwoColumnMain(m)
case screenHealthCheck: case screenHealthCheck:
return renderHealthCheck(m) body = renderHealthCheck(m)
case screenSettings: case screenSettings:
return renderMenu("Settings", "Select action", m.settingsMenu, m.cursor) body = renderMenu("Settings", "Select action", m.settingsMenu, m.cursor)
case screenNetwork: case screenNetwork:
return renderMenu("Network", "Select action", m.networkMenu, m.cursor) body = renderMenu("Network", "Select action", m.networkMenu, m.cursor)
case screenServices: case screenServices:
return renderMenu("Services", "Select service", m.services, m.cursor) body = renderMenu("Services", "Select service", m.services, m.cursor)
case screenServiceAction: case screenServiceAction:
return renderMenu("Service: "+m.selectedService, "Select action", m.serviceMenu, m.cursor) body = renderMenu("Service: "+m.selectedService, "Select action", m.serviceMenu, m.cursor)
case screenExportTargets: case screenExportTargets:
return renderMenu("Export support bundle", "Select removable filesystem", renderTargetItems(m.targets), m.cursor) body = renderMenu("Export support bundle", "Select removable filesystem", renderTargetItems(m.targets), m.cursor)
case screenInterfacePick: case screenInterfacePick:
return renderMenu("Interfaces", "Select interface", renderInterfaceItems(m.interfaces), m.cursor) body = renderMenu("Interfaces", "Select interface", renderInterfaceItems(m.interfaces), m.cursor)
case screenStaticForm: case screenStaticForm:
return renderForm("Static IPv4: "+m.selectedIface, m.formFields, m.formIndex) body = renderForm("Static IPv4: "+m.selectedIface, m.formFields, m.formIndex)
case screenConfirm: case screenConfirm:
title, body := m.confirmBody() title, confirmBody := m.confirmBody()
return renderConfirm(title, body, m.cursor) body = renderConfirm(title, confirmBody, m.cursor)
case screenNvidiaSATSetup: case screenNvidiaSATSetup:
return renderNvidiaSATSetup(m) body = renderNvidiaSATSetup(m)
case screenNvidiaSATRunning: case screenNvidiaSATRunning:
return renderNvidiaSATRunning() body = renderNvidiaSATRunning()
case screenOutput: case screenOutput:
return fmt.Sprintf("%s\n\n%s\n\n[enter/esc] back [ctrl+c] quit\n", m.title, strings.TrimSpace(m.body)) body = fmt.Sprintf("%s\n\n%s\n\n[enter/esc] back [ctrl+c] quit\n", m.title, strings.TrimSpace(m.body))
default: default:
return "bee\n" body = "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.
func renderTwoColumnMain(m model) string { func renderTwoColumnMain(m model) string {
@@ -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
}