Compare commits

..

5 Commits

Author SHA1 Message Date
lerko 060cd24de2 fix(tui): align log sidebar with monitor table top edge
CI / test (pull_request) Successful in 1m42s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 51s
2026-06-20 18:23:52 -04:00
lerko 5c40629987 fix(tui): clamp log sidebar width, strip redundant prefixes
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.
2026-06-20 18:17:08 -04:00
lerko e12f42fe16 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.
2026-06-20 18:14:01 -04:00
lerko 8323d27e7d feat(tui): compact log sidebar with severity icons
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.
2026-06-20 18:06:07 -04:00
lerko 047bb237e0 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
2026-06-20 17:59:47 -04:00
10 changed files with 322 additions and 195 deletions
+2 -2
View File
@@ -106,7 +106,7 @@ func (m *Model) refreshLive() {
m.sites = ordered m.sites = ordered
m.refreshLogContent() m.refreshLogContent()
if m.currentTab == 0 && m.selectedID != 0 { if m.currentTab == tabMonitors && m.selectedID != 0 {
for i, s := range m.sites { for i, s := range m.sites {
if s.ID == m.selectedID { if s.ID == m.selectedID {
m.cursor = i m.cursor = i
@@ -118,7 +118,7 @@ func (m *Model) refreshLive() {
} }
func (m *Model) syncSelectedID() { 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 m.selectedID = m.sites[m.cursor].ID
} }
} }
-20
View File
@@ -107,23 +107,3 @@ func (m *Model) refreshLogContent() {
m.logShown = shown m.logShown = shown
m.logViewport.SetContent(strings.Join(rendered, "\n")) 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()
}
+75
View File
@@ -0,0 +1,75 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
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:])
}
}
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 " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
}
return " " + 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")
}
sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width)
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 "\n" + sidebarStyle.Render(strings.Join(lines, "\n"))
}
+58
View File
@@ -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
}
+6 -2
View File
@@ -82,8 +82,12 @@ func (m Model) computeLayout() tableLayout {
var widths []int var widths []int
var fixed int var fixed int
cw := m.contentWidth
if cw == 0 {
cw = m.termWidth
}
for _, c := range siteColumns { for _, c := range siteColumns {
if c.minTerm > 0 && m.termWidth < c.minTerm { if c.minTerm > 0 && cw < c.minTerm {
continue continue
} }
active = append(active, c.key) active = append(active, c.key)
@@ -104,7 +108,7 @@ func (m Model) computeLayout() tableLayout {
numCols := len(headers) numCols := len(headers)
borderOverhead := 2 + (numCols - 1) borderOverhead := 2 + (numCols - 1)
avail := m.termWidth - chromePadH - 2 - borderOverhead - fixed avail := cw - chromePadH - 2 - borderOverhead - fixed
if avail < 20 { if avail < 20 {
avail = 20 avail = 20
} }
+1 -1
View File
@@ -116,7 +116,7 @@ func (m *Model) submitUserForm() tea.Cmd {
st := m.store st := m.store
id := m.editID id := m.editID
username, key, role := d.Username, d.PublicKey, d.Role username, key, role := d.Username, d.PublicKey, d.Role
m.state = stateUsers m.state = stateDashboard
if id > 0 { if id > 0 {
return writeCmd("Update user", func() error { return writeCmd("Update user", func() error {
return st.UpdateUser(context.Background(), id, username, key, role) return st.UpdateUser(context.Background(), id, username, key, role)
+10 -2
View File
@@ -13,7 +13,11 @@ const (
) )
func (m Model) isWide() bool { 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 { 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 borderOverhead := 2 + len(colWidths) - 1
tableWidth := colTotal + borderOverhead tableWidth := colTotal + borderOverhead
maxWidth := m.termWidth - chromePadH - 2 cw := m.contentWidth
if cw == 0 {
cw = m.termWidth
}
maxWidth := cw - chromePadH - 2
if tableWidth > maxWidth { if tableWidth > maxWidth {
tableWidth = maxWidth tableWidth = maxWidth
} }
+14
View File
@@ -84,6 +84,18 @@ const (
detailSparkWidth = 40 detailSparkWidth = 40
) )
const (
tabMonitors = 0
tabMaint = 1
tabSettings = 2
)
const (
sectionAlerts = 0
sectionNodes = 1
sectionUsers = 2
)
type sessionState int type sessionState int
const ( const (
@@ -104,12 +116,14 @@ const (
type Model struct { type Model struct {
state sessionState state sessionState
currentTab int currentTab int
settingsSection int
cursor int cursor int
selectedID int selectedID int
tableOffset int tableOffset int
maxTableRows int maxTableRows int
termWidth int termWidth int
termHeight int termHeight int
contentWidth int
editID int editID int
editToken string editToken string
+81 -104
View File
@@ -78,31 +78,28 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) {
id := m.deleteID id := m.deleteID
var cmd tea.Cmd var cmd tea.Cmd
switch m.deleteTab { switch m.deleteTab {
case 0: case tabMonitors:
cmd = writeCmd("Delete site", func() error { return st.DeleteSite(context.Background(), id) }) cmd = writeCmd("Delete site", func() error { return st.DeleteSite(context.Background(), id) })
m.engine.RemoveSite(id) m.engine.RemoveSite(id)
m.adjustCursor(len(m.sites) - 1) m.adjustCursor(len(m.sites) - 1)
case 1: case tabMaint:
cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(context.Background(), id) })
m.adjustCursor(len(m.alerts) - 1)
case 4:
cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(context.Background(), id) }) cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(context.Background(), id) })
m.adjustCursor(len(m.maintenanceWindows) - 1) m.adjustCursor(len(m.maintenanceWindows) - 1)
case 5: 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) }) cmd = writeCmd("Delete user", func() error { return st.DeleteUser(context.Background(), id) })
m.adjustCursor(len(m.users) - 1) m.adjustCursor(len(m.users) - 1)
} }
}
m.refreshLive() m.refreshLive()
m.state = stateDashboard m.state = stateDashboard
if m.deleteTab == 5 {
m.state = stateUsers
}
return m, cmd return m, cmd
case "n", "N", "esc": case "n", "N", "esc":
m.state = stateDashboard m.state = stateDashboard
if m.deleteTab == 5 {
m.state = stateUsers
}
case "ctrl+c": case "ctrl+c":
return m, tea.Quit return m, tea.Quit
} }
@@ -117,9 +114,6 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg.String() == "esc" { if keyMsg.String() == "esc" {
m.huhForm = nil m.huhForm = nil
m.state = stateDashboard m.state = stateDashboard
if m.currentTab == 5 {
m.state = stateUsers
}
return m, nil return m, nil
} }
} }
@@ -265,7 +259,7 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers { if m.state != stateDashboard {
return m, nil return m, nil
} }
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { 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 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() listLen := m.currentListLen()
if msg.Button == tea.MouseButtonWheelUp { if msg.Button == tea.MouseButtonWheelUp {
if m.cursor > 0 { if m.cursor > 0 {
@@ -325,7 +310,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleSLAKey(msg) return m.handleSLAKey(msg)
case stateAlertDetail: case stateAlertDetail:
return m.handleAlertDetailKey(msg) return m.handleAlertDetailKey(msg)
case stateDashboard, stateLogs, stateUsers: case stateDashboard:
return m.handleDashboardKey(msg) return m.handleDashboardKey(msg)
} }
return m, nil 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) { func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg.String() { switch msg.String() {
case "q": case "q":
return m, tea.Quit return m, tea.Quit
case "/": case "/":
if m.currentTab == 0 { if m.currentTab == tabMonitors {
m.filterMode = true m.filterMode = true
m.recalcLayout() m.recalcLayout()
return m, nil return m, nil
} }
case "f":
if m.state == stateLogs {
m.logFilterImportant = !m.logFilterImportant
m.refreshLogContent()
return m, nil
}
case "tab": case "tab":
m.switchTab(m.currentTab + 1) m.switchTab(m.currentTab + 1)
case "pgup", "pgdown": case "left", "h":
if m.state == stateLogs { if m.currentTab == tabSettings {
m.logViewport, cmd = m.logViewport.Update(msg) m.switchSettingsSection(m.settingsSection - 1)
return m, cmd }
case "right", "l":
if m.currentTab == tabSettings {
m.switchSettingsSection(m.settingsSection + 1)
} }
case "up", "k": case "up", "k":
if m.state == stateLogs { if m.cursor > 0 {
m.logViewport.ScrollUp(1)
} else if m.cursor > 0 {
m.cursor-- m.cursor--
if m.cursor < m.tableOffset { if m.cursor < m.tableOffset {
m.tableOffset = m.cursor m.tableOffset = m.cursor
@@ -555,9 +534,6 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.syncSelectedID() m.syncSelectedID()
} }
case "down", "j": case "down", "j":
if m.state == stateLogs {
m.logViewport.ScrollDown(1)
} else {
max := m.currentListLen() - 1 max := m.currentListLen() - 1
if m.cursor < max { if m.cursor < max {
m.cursor++ m.cursor++
@@ -566,18 +542,17 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
m.syncSelectedID() m.syncSelectedID()
} }
}
case "n": case "n":
return m.handleNewItem() return m.handleNewItem()
case "e", "enter": case "e", "enter":
return m.handleEditItem() return m.handleEditItem()
case "t": 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] a := m.alerts[m.cursor]
return m, m.testAlertCmd(a.ID, a.Name) return m, m.testAlertCmd(a.ID, a.Name)
} }
case " ": 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 gid := m.sites[m.cursor].ID
m.collapsed[gid] = !m.collapsed[gid] m.collapsed[gid] = !m.collapsed[gid]
payload := collapsedJSON(m.collapsed) payload := collapsedJSON(m.collapsed)
@@ -588,7 +563,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}) })
} }
case "p": case "p":
if m.currentTab == 0 && len(m.sites) > 0 { if m.currentTab == tabMonitors && len(m.sites) > 0 {
id := m.sites[m.cursor].ID id := m.sites[m.cursor].ID
paused := m.engine.ToggleSitePause(id) paused := m.engine.ToggleSitePause(id)
st := m.store st := m.store
@@ -598,14 +573,14 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}) })
} }
case "i": case "i":
if m.currentTab == 0 && len(m.sites) > 0 { if m.currentTab == tabMonitors && len(m.sites) > 0 {
m.state = stateDetail m.state = stateDetail
return m, m.loadDetailCmd(m.sites[m.cursor].ID) 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 m.state = stateAlertDetail
} }
case "x": case "x":
if m.currentTab == 4 && len(m.maintenanceWindows) > 0 { if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 {
mw := m.maintenanceWindows[m.cursor] mw := m.maintenanceWindows[m.cursor]
now := time.Now() now := time.Now()
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
@@ -637,88 +612,94 @@ func (m *Model) handleNewItem() (tea.Model, tea.Cmd) {
m.editID = 0 m.editID = 0
m.editToken = "" m.editToken = ""
switch m.currentTab { switch m.currentTab {
case 0: case tabMonitors:
m.state = stateFormSite m.state = stateFormSite
return m, m.initSiteHuhForm() return m, m.initSiteHuhForm()
case 1: case tabMaint:
m.state = stateFormAlert
return m, m.initAlertHuhForm()
case 4:
m.state = stateFormMaint m.state = stateFormMaint
return m, m.initMaintHuhForm() return m, m.initMaintHuhForm()
case 5: case tabSettings:
switch m.settingsSection {
case sectionAlerts:
m.state = stateFormAlert
return m, m.initAlertHuhForm()
case sectionUsers:
if m.isAdmin { if m.isAdmin {
m.state = stateFormUser m.state = stateFormUser
return m, m.initUserHuhForm() return m, m.initUserHuhForm()
} }
} }
}
return m, nil return m, nil
} }
func (m *Model) handleEditItem() (tea.Model, tea.Cmd) { func (m *Model) handleEditItem() (tea.Model, tea.Cmd) {
switch m.currentTab { switch m.currentTab {
case 0: case tabMonitors:
if len(m.sites) > 0 { if len(m.sites) > 0 {
m.editID = m.sites[m.cursor].ID m.editID = m.sites[m.cursor].ID
m.editToken = m.sites[m.cursor].Token m.editToken = m.sites[m.cursor].Token
m.state = stateFormSite m.state = stateFormSite
return m, m.initSiteHuhForm() return m, m.initSiteHuhForm()
} }
case 1: case tabSettings:
switch m.settingsSection {
case sectionAlerts:
if len(m.alerts) > 0 { if len(m.alerts) > 0 {
m.editID = m.alerts[m.cursor].ID m.editID = m.alerts[m.cursor].ID
m.state = stateFormAlert m.state = stateFormAlert
return m, m.initAlertHuhForm() return m, m.initAlertHuhForm()
} }
case 5: case sectionUsers:
if m.isAdmin && len(m.users) > 0 { if m.isAdmin && len(m.users) > 0 {
m.editID = m.users[m.cursor].ID m.editID = m.users[m.cursor].ID
m.state = stateFormUser m.state = stateFormUser
return m, m.initUserHuhForm() return m, m.initUserHuhForm()
} }
} }
}
return m, nil return m, nil
} }
func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) { func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) {
switch m.currentTab { switch m.currentTab {
case 0: case tabMonitors:
if len(m.sites) > 0 { if len(m.sites) > 0 {
m.deleteID = m.sites[m.cursor].ID m.deleteID = m.sites[m.cursor].ID
m.deleteName = m.sites[m.cursor].Name m.deleteName = m.sites[m.cursor].Name
m.deleteTab = 0 m.deleteTab = tabMonitors
m.state = stateConfirmDelete m.state = stateConfirmDelete
} }
case 1: case tabMaint:
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:
if len(m.maintenanceWindows) > 0 { if len(m.maintenanceWindows) > 0 {
m.deleteID = m.maintenanceWindows[m.cursor].ID m.deleteID = m.maintenanceWindows[m.cursor].ID
m.deleteName = m.maintenanceWindows[m.cursor].Title m.deleteName = m.maintenanceWindows[m.cursor].Title
m.deleteTab = 4 m.deleteTab = tabMaint
m.state = stateConfirmDelete m.state = stateConfirmDelete
} }
case 5: 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 { if m.isAdmin && len(m.users) > 0 {
m.deleteID = m.users[m.cursor].ID m.deleteID = m.users[m.cursor].ID
m.deleteName = m.users[m.cursor].Username m.deleteName = m.users[m.cursor].Username
m.deleteTab = 5 m.deleteTab = tabSettings
m.state = stateConfirmDelete m.state = stateConfirmDelete
} }
} }
}
return m, nil return m, nil
} }
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
tabCount := 5 tabCount := tabSettings + 1
if m.isAdmin {
tabCount = 6
}
for i := 0; i < tabCount; i++ { for i := 0; i < tabCount; i++ {
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) { if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
m.switchTab(i) m.switchTab(i)
@@ -743,24 +724,14 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
func (m *Model) switchTab(idx int) { func (m *Model) switchTab(idx int) {
maxTabs := 4 maxTabs := tabSettings
if m.isAdmin {
maxTabs = 5
}
if idx > maxTabs { if idx > maxTabs {
idx = 0 idx = 0
} }
m.currentTab = idx m.currentTab = idx
m.cursor = 0 m.cursor = 0
m.tableOffset = 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) { func (m *Model) adjustCursor(_ int) {
@@ -791,30 +762,36 @@ func (m *Model) submitForm() tea.Cmd {
func (m Model) currentListLen() int { func (m Model) currentListLen() int {
switch m.currentTab { switch m.currentTab {
case 1: case tabMonitors:
return len(m.alerts)
case 3:
return len(m.nodes)
case 4:
return len(m.maintenanceWindows)
case 5:
return len(m.users)
default:
return len(m.sites) 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) { func (m Model) currentZonePrefix() (string, int) {
switch m.currentTab { switch m.currentTab {
case 0: case tabMonitors:
return "site", len(m.sites) return "site", len(m.sites)
case 1: case tabMaint:
return "alert", len(m.alerts)
case 4:
return "maint", len(m.maintenanceWindows) return "maint", len(m.maintenanceWindows)
case 5: case tabSettings:
switch m.settingsSection {
case sectionAlerts:
return "alert", len(m.alerts)
case sectionUsers:
return "user", len(m.users) return "user", len(m.users)
default:
return "site", 0
} }
}
return "site", 0
} }
+38 -27
View File
@@ -151,20 +151,28 @@ func (m Model) viewDashboard() string {
var content string var content string
switch m.currentTab { switch m.currentTab {
case 0: case tabMonitors:
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 {
m.contentWidth = m.termWidth
content = m.viewSitesTab() 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 tabMaint:
m.contentWidth = m.termWidth
content = m.viewMaintTab()
case tabSettings:
m.contentWidth = m.termWidth
content = m.viewSettingsTab()
} }
content = strings.TrimSpace(content) content = strings.TrimSpace(content)
@@ -199,15 +207,15 @@ type tabEntry struct {
} }
func (m Model) renderTabBar(stats dashboardStats) string { 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{ tabs := []tabEntry{
{"Monitors", stats.totalMonitors, stats.downCount + stats.lateCount}, {"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}, {"Maint", len(m.maintenanceWindows), stats.activeMaint},
} {"Settings", settingsCount, settingsWarn},
if m.isAdmin {
tabs = append(tabs, tabEntry{"Users", len(m.users), 0})
} }
countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted) countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted)
@@ -270,23 +278,26 @@ func (m Model) renderFooter(stats dashboardStats) string {
var keys string var keys string
switch m.currentTab { 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" keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Space]Collapse [T]Theme [Tab]Switch [q]Quit"
case 1: case tabMaint:
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:
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
case 5: case tabSettings:
keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit" 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: default:
keys = "[T]Theme [Tab]Switch [q]Quit" keys = "[T]Theme [Tab]Switch [q]Quit"
} }
ver := m.st.subtleStyle.Render("v" + m.version) ver := m.st.subtleStyle.Render("v" + m.version)
line := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver 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 line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
} }