From 047bb237e008713a641c0ed7b7ee7f91a433cabf Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 17:59:47 -0400 Subject: [PATCH 1/5] feat(tui): consolidate 6 tabs to 3, add log sidebar Tab bar: Monitors | Maint | Settings (was 6 tabs). Settings tab merges Alerts, Nodes, Users as sub-sections with left/right arrow navigation. Each section keeps its own cursor, keybindings, and CRUD operations. Monitors tab now shows a log sidebar at >= 120 cols (70/30 split). Under 120 cols, monitors render full-width without logs. - Introduced tab constants (tabMonitors, tabMaint, tabSettings) - Introduced section constants (sectionAlerts, sectionNodes, sectionUsers) - Removed stateLogs and stateUsers states - All magic tab numbers replaced with named constants --- internal/tui/data.go | 4 +- internal/tui/tab_settings.go | 58 ++++++++ internal/tui/tab_users.go | 2 +- internal/tui/tui.go | 33 +++-- internal/tui/update.go | 237 +++++++++++++++------------------ internal/tui/view_dashboard.go | 61 +++++---- 6 files changed, 224 insertions(+), 171 deletions(-) create mode 100644 internal/tui/tab_settings.go diff --git a/internal/tui/data.go b/internal/tui/data.go index a04ff25..03c9532 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -106,7 +106,7 @@ func (m *Model) refreshLive() { m.sites = ordered m.refreshLogContent() - if m.currentTab == 0 && m.selectedID != 0 { + if m.currentTab == tabMonitors && m.selectedID != 0 { for i, s := range m.sites { if s.ID == m.selectedID { m.cursor = i @@ -118,7 +118,7 @@ func (m *Model) refreshLive() { } func (m *Model) syncSelectedID() { - if m.currentTab == 0 && m.cursor < len(m.sites) { + if m.currentTab == tabMonitors && m.cursor < len(m.sites) { m.selectedID = m.sites[m.cursor].ID } } diff --git a/internal/tui/tab_settings.go b/internal/tui/tab_settings.go new file mode 100644 index 0000000..c1d2f51 --- /dev/null +++ b/internal/tui/tab_settings.go @@ -0,0 +1,58 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" +) + +func (m Model) viewSettingsTab() string { + maxSections := 2 + if m.isAdmin { + maxSections = 3 + } + + sections := []string{"Alerts", "Nodes"} + if m.isAdmin { + sections = append(sections, "Users") + } + _ = maxSections + + var tabs []string + for i, name := range sections { + if i == m.settingsSection { + tabs = append(tabs, m.st.activeTab.Render(name)) + } else { + tabs = append(tabs, m.st.inactiveTab.Render(name)) + } + } + header := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + + var content string + switch m.settingsSection { + case sectionAlerts: + content = m.viewAlertsTab() + case sectionNodes: + content = m.viewNodesTab() + case sectionUsers: + if m.isAdmin { + content = m.viewUsersTab() + } + } + + return header + "\n" + content +} + +func (m *Model) switchSettingsSection(idx int) { + max := 1 + if m.isAdmin { + max = 2 + } + if idx > max { + idx = 0 + } + if idx < 0 { + idx = max + } + m.settingsSection = idx + m.cursor = 0 + m.tableOffset = 0 +} diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index 3666357..2bd86fa 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -116,7 +116,7 @@ func (m *Model) submitUserForm() tea.Cmd { st := m.store id := m.editID username, key, role := d.Username, d.PublicKey, d.Role - m.state = stateUsers + m.state = stateDashboard if id > 0 { return writeCmd("Update user", func() error { return st.UpdateUser(context.Background(), id, username, key, role) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 59bd582..602c2b1 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -84,6 +84,18 @@ const ( detailSparkWidth = 40 ) +const ( + tabMonitors = 0 + tabMaint = 1 + tabSettings = 2 +) + +const ( + sectionAlerts = 0 + sectionNodes = 1 + sectionUsers = 2 +) + type sessionState int const ( @@ -102,16 +114,17 @@ const ( ) type Model struct { - state sessionState - currentTab int - cursor int - selectedID int - tableOffset int - maxTableRows int - termWidth int - termHeight int - editID int - editToken string + state sessionState + currentTab int + settingsSection int + cursor int + selectedID int + tableOffset int + maxTableRows int + termWidth int + termHeight int + editID int + editToken string huhForm *huh.Form siteFormData *siteFormData diff --git a/internal/tui/update.go b/internal/tui/update.go index e86582b..5800bf0 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -78,31 +78,28 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) { id := m.deleteID var cmd tea.Cmd switch m.deleteTab { - case 0: + case tabMonitors: cmd = writeCmd("Delete site", func() error { return st.DeleteSite(context.Background(), id) }) m.engine.RemoveSite(id) m.adjustCursor(len(m.sites) - 1) - case 1: - cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(context.Background(), id) }) - m.adjustCursor(len(m.alerts) - 1) - case 4: + case tabMaint: cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(context.Background(), id) }) m.adjustCursor(len(m.maintenanceWindows) - 1) - case 5: - cmd = writeCmd("Delete user", func() error { return st.DeleteUser(context.Background(), id) }) - m.adjustCursor(len(m.users) - 1) + case tabSettings: + switch m.settingsSection { + case sectionAlerts: + cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(context.Background(), id) }) + m.adjustCursor(len(m.alerts) - 1) + case sectionUsers: + cmd = writeCmd("Delete user", func() error { return st.DeleteUser(context.Background(), id) }) + m.adjustCursor(len(m.users) - 1) + } } m.refreshLive() m.state = stateDashboard - if m.deleteTab == 5 { - m.state = stateUsers - } return m, cmd case "n", "N", "esc": m.state = stateDashboard - if m.deleteTab == 5 { - m.state = stateUsers - } case "ctrl+c": return m, tea.Quit } @@ -117,9 +114,6 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) { if keyMsg.String() == "esc" { m.huhForm = nil m.state = stateDashboard - if m.currentTab == 5 { - m.state = stateUsers - } return m, nil } } @@ -265,7 +259,7 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } return m, nil } - if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers { + if m.state != stateDashboard { return m, nil } if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { @@ -275,15 +269,6 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } - if m.state == stateLogs { - if msg.Button == tea.MouseButtonWheelUp { - m.logViewport.ScrollUp(3) - } else { - m.logViewport.ScrollDown(3) - } - return m, nil - } - listLen := m.currentListLen() if msg.Button == tea.MouseButtonWheelUp { if m.cursor > 0 { @@ -325,7 +310,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleSLAKey(msg) case stateAlertDetail: return m.handleAlertDetailKey(msg) - case stateDashboard, stateLogs, stateUsers: + case stateDashboard: return m.handleDashboardKey(msg) } return m, nil @@ -521,33 +506,27 @@ func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd switch msg.String() { case "q": return m, tea.Quit case "/": - if m.currentTab == 0 { + if m.currentTab == tabMonitors { m.filterMode = true m.recalcLayout() return m, nil } - case "f": - if m.state == stateLogs { - m.logFilterImportant = !m.logFilterImportant - m.refreshLogContent() - return m, nil - } case "tab": m.switchTab(m.currentTab + 1) - case "pgup", "pgdown": - if m.state == stateLogs { - m.logViewport, cmd = m.logViewport.Update(msg) - return m, cmd + case "left", "h": + if m.currentTab == tabSettings { + m.switchSettingsSection(m.settingsSection - 1) + } + case "right", "l": + if m.currentTab == tabSettings { + m.switchSettingsSection(m.settingsSection + 1) } case "up", "k": - if m.state == stateLogs { - m.logViewport.ScrollUp(1) - } else if m.cursor > 0 { + if m.cursor > 0 { m.cursor-- if m.cursor < m.tableOffset { m.tableOffset = m.cursor @@ -555,29 +534,25 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.syncSelectedID() } case "down", "j": - if m.state == stateLogs { - m.logViewport.ScrollDown(1) - } else { - max := m.currentListLen() - 1 - if m.cursor < max { - m.cursor++ - if m.cursor >= m.tableOffset+m.maxTableRows { - m.tableOffset++ - } - m.syncSelectedID() + max := m.currentListLen() - 1 + if m.cursor < max { + m.cursor++ + if m.cursor >= m.tableOffset+m.maxTableRows { + m.tableOffset++ } + m.syncSelectedID() } case "n": return m.handleNewItem() case "e", "enter": return m.handleEditItem() case "t": - if m.currentTab == 1 && len(m.alerts) > 0 { + if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 { a := m.alerts[m.cursor] return m, m.testAlertCmd(a.ID, a.Name) } case " ": - if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" { + if m.currentTab == tabMonitors && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" { gid := m.sites[m.cursor].ID m.collapsed[gid] = !m.collapsed[gid] payload := collapsedJSON(m.collapsed) @@ -588,7 +563,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { }) } case "p": - if m.currentTab == 0 && len(m.sites) > 0 { + if m.currentTab == tabMonitors && len(m.sites) > 0 { id := m.sites[m.cursor].ID paused := m.engine.ToggleSitePause(id) st := m.store @@ -598,14 +573,14 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { }) } case "i": - if m.currentTab == 0 && len(m.sites) > 0 { + if m.currentTab == tabMonitors && len(m.sites) > 0 { m.state = stateDetail return m, m.loadDetailCmd(m.sites[m.cursor].ID) - } else if m.currentTab == 1 && len(m.alerts) > 0 { + } else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 { m.state = stateAlertDetail } case "x": - if m.currentTab == 4 && len(m.maintenanceWindows) > 0 { + if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 { mw := m.maintenanceWindows[m.cursor] now := time.Now() isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) @@ -637,19 +612,22 @@ func (m *Model) handleNewItem() (tea.Model, tea.Cmd) { m.editID = 0 m.editToken = "" switch m.currentTab { - case 0: + case tabMonitors: m.state = stateFormSite return m, m.initSiteHuhForm() - case 1: - m.state = stateFormAlert - return m, m.initAlertHuhForm() - case 4: + case tabMaint: m.state = stateFormMaint return m, m.initMaintHuhForm() - case 5: - if m.isAdmin { - m.state = stateFormUser - return m, m.initUserHuhForm() + case tabSettings: + switch m.settingsSection { + case sectionAlerts: + m.state = stateFormAlert + return m, m.initAlertHuhForm() + case sectionUsers: + if m.isAdmin { + m.state = stateFormUser + return m, m.initUserHuhForm() + } } } return m, nil @@ -657,24 +635,27 @@ func (m *Model) handleNewItem() (tea.Model, tea.Cmd) { func (m *Model) handleEditItem() (tea.Model, tea.Cmd) { switch m.currentTab { - case 0: + case tabMonitors: if len(m.sites) > 0 { m.editID = m.sites[m.cursor].ID m.editToken = m.sites[m.cursor].Token m.state = stateFormSite return m, m.initSiteHuhForm() } - case 1: - if len(m.alerts) > 0 { - m.editID = m.alerts[m.cursor].ID - m.state = stateFormAlert - return m, m.initAlertHuhForm() - } - case 5: - if m.isAdmin && len(m.users) > 0 { - m.editID = m.users[m.cursor].ID - m.state = stateFormUser - return m, m.initUserHuhForm() + case tabSettings: + switch m.settingsSection { + case sectionAlerts: + if len(m.alerts) > 0 { + m.editID = m.alerts[m.cursor].ID + m.state = stateFormAlert + return m, m.initAlertHuhForm() + } + case sectionUsers: + if m.isAdmin && len(m.users) > 0 { + m.editID = m.users[m.cursor].ID + m.state = stateFormUser + return m, m.initUserHuhForm() + } } } return m, nil @@ -682,43 +663,43 @@ func (m *Model) handleEditItem() (tea.Model, tea.Cmd) { func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) { switch m.currentTab { - case 0: + case tabMonitors: if len(m.sites) > 0 { m.deleteID = m.sites[m.cursor].ID m.deleteName = m.sites[m.cursor].Name - m.deleteTab = 0 + m.deleteTab = tabMonitors m.state = stateConfirmDelete } - case 1: - if len(m.alerts) > 0 { - m.deleteID = m.alerts[m.cursor].ID - m.deleteName = m.alerts[m.cursor].Name - m.deleteTab = 1 - m.state = stateConfirmDelete - } - case 4: + case tabMaint: if len(m.maintenanceWindows) > 0 { m.deleteID = m.maintenanceWindows[m.cursor].ID m.deleteName = m.maintenanceWindows[m.cursor].Title - m.deleteTab = 4 + m.deleteTab = tabMaint m.state = stateConfirmDelete } - case 5: - if m.isAdmin && len(m.users) > 0 { - m.deleteID = m.users[m.cursor].ID - m.deleteName = m.users[m.cursor].Username - m.deleteTab = 5 - m.state = stateConfirmDelete + case tabSettings: + switch m.settingsSection { + case sectionAlerts: + if len(m.alerts) > 0 { + m.deleteID = m.alerts[m.cursor].ID + m.deleteName = m.alerts[m.cursor].Name + m.deleteTab = tabSettings + m.state = stateConfirmDelete + } + case sectionUsers: + if m.isAdmin && len(m.users) > 0 { + m.deleteID = m.users[m.cursor].ID + m.deleteName = m.users[m.cursor].Username + m.deleteTab = tabSettings + m.state = stateConfirmDelete + } } } return m, nil } func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - tabCount := 5 - if m.isAdmin { - tabCount = 6 - } + tabCount := tabSettings + 1 for i := 0; i < tabCount; i++ { if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) { m.switchTab(i) @@ -743,24 +724,14 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } func (m *Model) switchTab(idx int) { - maxTabs := 4 - if m.isAdmin { - maxTabs = 5 - } + maxTabs := tabSettings if idx > maxTabs { idx = 0 } m.currentTab = idx m.cursor = 0 m.tableOffset = 0 - switch idx { - case 2: - m.state = stateLogs - case 5: - m.state = stateUsers - default: - m.state = stateDashboard - } + m.state = stateDashboard } func (m *Model) adjustCursor(_ int) { @@ -791,30 +762,36 @@ func (m *Model) submitForm() tea.Cmd { func (m Model) currentListLen() int { switch m.currentTab { - case 1: - return len(m.alerts) - case 3: - return len(m.nodes) - case 4: - return len(m.maintenanceWindows) - case 5: - return len(m.users) - default: + case tabMonitors: return len(m.sites) + case tabMaint: + return len(m.maintenanceWindows) + case tabSettings: + switch m.settingsSection { + case sectionAlerts: + return len(m.alerts) + case sectionNodes: + return len(m.nodes) + case sectionUsers: + return len(m.users) + } } + return 0 } func (m Model) currentZonePrefix() (string, int) { switch m.currentTab { - case 0: + case tabMonitors: return "site", len(m.sites) - case 1: - return "alert", len(m.alerts) - case 4: + case tabMaint: return "maint", len(m.maintenanceWindows) - case 5: - return "user", len(m.users) - default: - return "site", 0 + case tabSettings: + switch m.settingsSection { + case sectionAlerts: + return "alert", len(m.alerts) + case sectionUsers: + return "user", len(m.users) + } } + return "site", 0 } diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 02bf716..94374f5 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -151,20 +151,22 @@ func (m Model) viewDashboard() string { var content string switch m.currentTab { - case 0: - content = m.viewSitesTab() - case 1: - content = m.viewAlertsTab() - case 2: - content = m.viewLogsTab() - case 3: - content = m.viewNodesTab() - case 4: - content = m.viewMaintTab() - case 5: - if m.isAdmin { - content = m.viewUsersTab() + case tabMonitors: + monitors := m.viewSitesTab() + if m.termWidth >= wideBreakpoint { + availW := m.termWidth - chromePadH + leftW := availW * 70 / 100 + rightW := availW - leftW + left := lipgloss.NewStyle().Width(leftW).Render(monitors) + right := lipgloss.NewStyle().Width(rightW).Render(m.viewLogsTab()) + content = lipgloss.JoinHorizontal(lipgloss.Top, left, right) + } else { + content = monitors } + case tabMaint: + content = m.viewMaintTab() + case tabSettings: + content = m.viewSettingsTab() } content = strings.TrimSpace(content) @@ -199,15 +201,15 @@ type tabEntry struct { } func (m Model) renderTabBar(stats dashboardStats) string { + settingsCount := len(m.alerts) + len(m.nodes) + settingsWarn := stats.offlineNodes + if m.isAdmin { + settingsCount += len(m.users) + } tabs := []tabEntry{ {"Monitors", stats.totalMonitors, stats.downCount + stats.lateCount}, - {"Alerts", len(m.alerts), 0}, - {"Logs", 0, 0}, - {"Nodes", len(m.nodes), stats.offlineNodes}, {"Maint", len(m.maintenanceWindows), stats.activeMaint}, - } - if m.isAdmin { - tabs = append(tabs, tabEntry{"Users", len(m.users), 0}) + {"Settings", settingsCount, settingsWarn}, } countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted) @@ -270,23 +272,26 @@ func (m Model) renderFooter(stats dashboardStats) string { var keys string switch m.currentTab { - case 0: + case tabMonitors: keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Space]Collapse [T]Theme [Tab]Switch [q]Quit" - case 1: - keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit" - case 2: - keys = "[↑/↓]Scroll [PgUp/PgDn]Page [f]Filter [T]Theme [Tab]Switch [q]Quit" - case 4: + case tabMaint: keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" - case 5: - keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit" + case tabSettings: + switch m.settingsSection { + case sectionAlerts: + keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [←/→]Section [T]Theme [Tab]Switch [q]Quit" + case sectionUsers: + keys = "[n]Add [d]Revoke [←/→]Section [T]Theme [Tab]Switch [q]Quit" + default: + keys = "[←/→]Section [T]Theme [Tab]Switch [q]Quit" + } default: keys = "[T]Theme [Tab]Switch [q]Quit" } ver := m.st.subtleStyle.Render("v" + m.version) line := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver - if m.filterText != "" && m.currentTab == 0 { + if m.filterText != "" && m.currentTab == tabMonitors { line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver } -- 2.52.0 From 8323d27e7d26ae15da00547e6c1183824909e9a9 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 18:06:07 -0400 Subject: [PATCH 2/5] feat(tui): compact log sidebar with severity icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace full viewLogsTab with compact sidebar renderer for the 70/30 monitors split. Single-char severity icons (▼▲◆●·), truncated messages, no header chrome. Renders directly from engine logs without viewport. --- internal/tui/tab_logs.go | 20 ---------- internal/tui/tab_logs_sidebar.go | 64 ++++++++++++++++++++++++++++++++ internal/tui/view_dashboard.go | 3 +- 3 files changed, 66 insertions(+), 21 deletions(-) create mode 100644 internal/tui/tab_logs_sidebar.go diff --git a/internal/tui/tab_logs.go b/internal/tui/tab_logs.go index 3769a3e..29463ea 100644 --- a/internal/tui/tab_logs.go +++ b/internal/tui/tab_logs.go @@ -107,23 +107,3 @@ func (m *Model) refreshLogContent() { m.logShown = shown m.logViewport.SetContent(strings.Join(rendered, "\n")) } - -func (m Model) viewLogsTab() string { - if m.logTotal == 0 { - return m.emptyState("No log entries yet.", "Logs appear as monitors run checks") - } - - filterLabel := "All" - if m.logFilterImportant { - filterLabel = "Important" - } - - header := m.st.subtleStyle.Render(fmt.Sprintf( - " %d entries Filter: %s", m.logShown, filterLabel)) - - if m.logFilterImportant && m.logShown < m.logTotal { - header += m.st.subtleStyle.Render(fmt.Sprintf(" (%d hidden)", m.logTotal-m.logShown)) - } - - return "\n" + header + "\n\n" + m.logViewport.View() -} diff --git a/internal/tui/tab_logs_sidebar.go b/internal/tui/tab_logs_sidebar.go new file mode 100644 index 0000000..96cdc45 --- /dev/null +++ b/internal/tui/tab_logs_sidebar.go @@ -0,0 +1,64 @@ +package tui + +import ( + "fmt" + "strings" +) + +func (m Model) renderCompactLogLine(line string, maxW int) string { + sev := classifyLog(line) + + var tag string + switch sev { + case severityDown: + tag = m.st.dangerStyle.Render("▼") + case severityUp: + tag = m.st.specialStyle.Render("▲") + case severityWarn: + tag = m.st.warnStyle.Render("◆") + case severitySystem: + tag = m.st.titleStyle.Render("●") + default: + tag = m.st.subtleStyle.Render("·") + } + + ts := "" + msg := line + if len(line) > 10 && line[0] == '[' { + if idx := strings.Index(line, "]"); idx > 0 && idx < 12 { + ts = line[1:idx] + msg = strings.TrimSpace(line[idx+1:]) + } + } + + msgW := maxW - 10 + if msgW < 10 { + msgW = 10 + } + msg = limitStr(msg, msgW) + + if ts != "" { + return fmt.Sprintf(" %s %s %s", m.st.subtleStyle.Render(ts), tag, msg) + } + return fmt.Sprintf(" %s %s", tag, msg) +} + +func (m Model) viewLogsSidebar(width int) string { + logs := m.engine.GetLogs() + if len(logs) == 0 { + return m.st.subtleStyle.Render(" No logs yet") + } + + var lines []string + for _, line := range logs { + if strings.TrimSpace(line) == "" { + continue + } + if m.logFilterImportant && !isImportantLog(classifyLog(line)) { + continue + } + lines = append(lines, m.renderCompactLogLine(line, width)) + } + + return strings.Join(lines, "\n") +} diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 94374f5..98482e7 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -158,7 +158,8 @@ func (m Model) viewDashboard() string { leftW := availW * 70 / 100 rightW := availW - leftW left := lipgloss.NewStyle().Width(leftW).Render(monitors) - right := lipgloss.NewStyle().Width(rightW).Render(m.viewLogsTab()) + sidebar := m.viewLogsSidebar(rightW) + right := lipgloss.NewStyle().Width(rightW).Render(sidebar) content = lipgloss.JoinHorizontal(lipgloss.Top, left, right) } else { content = monitors -- 2.52.0 From e12f42fe16bd1cf9825d5208f592286b6cb64679 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 18:14:01 -0400 Subject: [PATCH 3/5] fix(tui): use panel width for table layout in split-pane mode Table columns were computed from terminal width, causing row wrapping when the monitors panel only gets 70% of the space. Introduced contentWidth field set per-tab in viewDashboard. computeLayout, isWide, and renderTable now use contentWidth for column visibility, available space, and max table width calculations. Columns gracefully hide (SSL, RETRIES, TYPE, UPTIME) when the panel is narrower, matching the existing responsive breakpoint behavior. --- internal/tui/tab_sites.go | 8 ++++++-- internal/tui/table_helpers.go | 12 ++++++++++-- internal/tui/tui.go | 1 + internal/tui/view_dashboard.go | 11 ++++++++--- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index b88c1a8..de1d01e 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -82,8 +82,12 @@ func (m Model) computeLayout() tableLayout { var widths []int var fixed int + cw := m.contentWidth + if cw == 0 { + cw = m.termWidth + } for _, c := range siteColumns { - if c.minTerm > 0 && m.termWidth < c.minTerm { + if c.minTerm > 0 && cw < c.minTerm { continue } active = append(active, c.key) @@ -104,7 +108,7 @@ func (m Model) computeLayout() tableLayout { numCols := len(headers) borderOverhead := 2 + (numCols - 1) - avail := m.termWidth - chromePadH - 2 - borderOverhead - fixed + avail := cw - chromePadH - 2 - borderOverhead - fixed if avail < 20 { avail = 20 } diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go index 2c32182..e2dd5d8 100644 --- a/internal/tui/table_helpers.go +++ b/internal/tui/table_helpers.go @@ -13,7 +13,11 @@ const ( ) func (m Model) isWide() bool { - return m.termWidth >= wideBreakpoint + w := m.contentWidth + if w == 0 { + w = m.termWidth + } + return w >= wideBreakpoint } func (m Model) renderTable(headers []string, items int, buildRows func(start, end int) [][]string, colWidths []int, styleOverride StyleOverride) string { @@ -35,7 +39,11 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en } borderOverhead := 2 + len(colWidths) - 1 tableWidth := colTotal + borderOverhead - maxWidth := m.termWidth - chromePadH - 2 + cw := m.contentWidth + if cw == 0 { + cw = m.termWidth + } + maxWidth := cw - chromePadH - 2 if tableWidth > maxWidth { tableWidth = maxWidth } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 602c2b1..7cb209e 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -123,6 +123,7 @@ type Model struct { maxTableRows int termWidth int termHeight int + contentWidth int editID int editToken string diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 98482e7..9f67cd0 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -152,21 +152,26 @@ func (m Model) viewDashboard() string { var content string switch m.currentTab { case tabMonitors: - monitors := m.viewSitesTab() - if m.termWidth >= wideBreakpoint { + showSidebar := m.termWidth >= wideBreakpoint + if showSidebar { availW := m.termWidth - chromePadH leftW := availW * 70 / 100 rightW := availW - leftW + m.contentWidth = leftW + monitors := m.viewSitesTab() left := lipgloss.NewStyle().Width(leftW).Render(monitors) sidebar := m.viewLogsSidebar(rightW) right := lipgloss.NewStyle().Width(rightW).Render(sidebar) content = lipgloss.JoinHorizontal(lipgloss.Top, left, right) } else { - content = monitors + m.contentWidth = m.termWidth + content = m.viewSitesTab() } case tabMaint: + m.contentWidth = m.termWidth content = m.viewMaintTab() case tabSettings: + m.contentWidth = m.termWidth content = m.viewSettingsTab() } -- 2.52.0 From 5c4062998740db75edce224bc3d8c890a6aef884 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 18:17:08 -0400 Subject: [PATCH 4/5] fix(tui): clamp log sidebar width, strip redundant prefixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log lines now hard-clamped to panel width via lipgloss MaxWidth. Stripped "Monitor " and "Push " prefixes from sidebar messages — redundant in a monitoring app, saves 8 chars per line. Improved prefix width calculation to prevent line wrapping at narrow widths. --- internal/tui/tab_logs_sidebar.go | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/internal/tui/tab_logs_sidebar.go b/internal/tui/tab_logs_sidebar.go index 96cdc45..7dcf140 100644 --- a/internal/tui/tab_logs_sidebar.go +++ b/internal/tui/tab_logs_sidebar.go @@ -1,8 +1,9 @@ package tui import ( - "fmt" "strings" + + "github.com/charmbracelet/lipgloss" ) func (m Model) renderCompactLogLine(line string, maxW int) string { @@ -31,16 +32,24 @@ func (m Model) renderCompactLogLine(line string, maxW int) string { } } - msgW := maxW - 10 - if msgW < 10 { - msgW = 10 + msg = strings.TrimPrefix(msg, "Monitor ") + msg = strings.TrimPrefix(msg, "Push ") + + // prefix: " HH:MM ● " = 9 visible chars, or " ● " = 3 without timestamp + prefixW := 3 + if ts != "" { + prefixW = len(ts) + 4 + } + msgW := maxW - prefixW + if msgW < 5 { + msgW = 5 } msg = limitStr(msg, msgW) if ts != "" { - return fmt.Sprintf(" %s %s %s", m.st.subtleStyle.Render(ts), tag, msg) + return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg } - return fmt.Sprintf(" %s %s", tag, msg) + return " " + tag + " " + msg } func (m Model) viewLogsSidebar(width int) string { @@ -49,6 +58,8 @@ func (m Model) viewLogsSidebar(width int) string { return m.st.subtleStyle.Render(" No logs yet") } + sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width) + var lines []string for _, line := range logs { if strings.TrimSpace(line) == "" { @@ -60,5 +71,5 @@ func (m Model) viewLogsSidebar(width int) string { lines = append(lines, m.renderCompactLogLine(line, width)) } - return strings.Join(lines, "\n") + return sidebarStyle.Render(strings.Join(lines, "\n")) } -- 2.52.0 From 060cd24de2a5134214e69292503ffbc3f035adc1 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 18:23:52 -0400 Subject: [PATCH 5/5] fix(tui): align log sidebar with monitor table top edge --- internal/tui/tab_logs_sidebar.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/tab_logs_sidebar.go b/internal/tui/tab_logs_sidebar.go index 7dcf140..e21c6cd 100644 --- a/internal/tui/tab_logs_sidebar.go +++ b/internal/tui/tab_logs_sidebar.go @@ -71,5 +71,5 @@ func (m Model) viewLogsSidebar(width int) string { lines = append(lines, m.renderCompactLogLine(line, width)) } - return sidebarStyle.Render(strings.Join(lines, "\n")) + return "\n" + sidebarStyle.Render(strings.Join(lines, "\n")) } -- 2.52.0