diff --git a/audit/internal/tui/messages.go b/audit/internal/tui/messages.go index ab9afd0..71873f7 100644 --- a/audit/internal/tui/messages.go +++ b/audit/internal/tui/messages.go @@ -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 { diff --git a/audit/internal/tui/snapshot.go b/audit/internal/tui/snapshot.go new file mode 100644 index 0000000..a7f094f --- /dev/null +++ b/audit/internal/tui/snapshot.go @@ -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{}, + } +} diff --git a/audit/internal/tui/tui_test.go b/audit/internal/tui/tui_test.go index 09974c0..5e02faa 100644 --- a/audit/internal/tui/tui_test.go +++ b/audit/internal/tui/tui_test.go @@ -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() diff --git a/audit/internal/tui/types.go b/audit/internal/tui/types.go index a37f65e..323db00 100644 --- a/audit/internal/tui/types.go +++ b/audit/internal/tui/types.go @@ -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) { diff --git a/audit/internal/tui/update.go b/audit/internal/tui/update.go index 5af9942..db4f2d8 100644 --- a/audit/internal/tui/update.go +++ b/audit/internal/tui/update.go @@ -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 diff --git a/audit/internal/tui/view.go b/audit/internal/tui/view.go index 3fc5855..4872d78 100644 --- a/audit/internal/tui/view.go +++ b/audit/internal/tui/view.go @@ -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 +}