diff --git a/internal/tui/data.go b/internal/tui/data.go index ab0bb28..4669c69 100644 --- a/internal/tui/data.go +++ b/internal/tui/data.go @@ -125,7 +125,7 @@ func (m *Model) refreshLive() { m.sites = ordered m.refreshLogContent() - if m.currentTab == tabMonitors && m.selectedID != 0 { + if m.selectedID != 0 { for i, s := range m.sites { if s.ID == m.selectedID { m.cursor = i @@ -137,7 +137,7 @@ func (m *Model) refreshLive() { } func (m *Model) syncSelectedID() { - if m.currentTab == tabMonitors && m.cursor < len(m.sites) { + if m.cursor < len(m.sites) { m.selectedID = m.sites[m.cursor].ID } } diff --git a/internal/tui/panel.go b/internal/tui/panel.go index 586af54..6935334 100644 --- a/internal/tui/panel.go +++ b/internal/tui/panel.go @@ -50,3 +50,7 @@ func max(a, b int) int { } return b } + +func placeOverlay(fg string, termW, termH int) string { + return lipgloss.Place(termW, termH, lipgloss.Center, lipgloss.Center, fg) +} diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index fa884a3..3bba857 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -292,10 +292,14 @@ func (m Model) fmtAlertConfigFull(alert models.AlertConfig) string { } func (m Model) viewAlertDetailPanel() string { - if m.cursor >= len(m.alerts) { + idx := m.cursor + if m.returnState == stateSettings { + idx = m.settingsCursor + } + if idx >= len(m.alerts) { return "" } - a := m.alerts[m.cursor] + a := m.alerts[idx] h := m.engine.GetAlertHealth(a.ID) var b strings.Builder @@ -604,7 +608,12 @@ func (m *Model) submitAlertForm() tea.Cmd { ctx := m.ctx id := m.editID name, aType := d.Name, d.AlertType - m.state = stateDashboard + if m.returnState == stateSettings { + m.state = stateSettings + } else { + m.state = stateDashboard + } + m.returnState = 0 if id > 0 { return writeCmd("Update alert", func() error { return st.UpdateAlert(ctx, id, name, aType, settings) diff --git a/internal/tui/tab_maint.go b/internal/tui/tab_maint.go index 5c3a097..24ac44c 100644 --- a/internal/tui/tab_maint.go +++ b/internal/tui/tab_maint.go @@ -20,50 +20,6 @@ type maintFormData struct { CustomHours string } -func (m Model) fmtMaintStatus(mw models.MaintenanceWindow) string { - now := time.Now() - if mw.StartTime.After(now) { - return m.st.warnStyle.Render("SCHEDULED") - } - if !mw.EndTime.IsZero() && mw.EndTime.Before(now) { - return m.st.subtleStyle.Render("ENDED") - } - return m.st.specialStyle.Render("ACTIVE") -} - -func (m Model) fmtMaintType(t string) string { - if t == "incident" { - return m.st.dangerStyle.Render("incident") - } - return m.st.maintStyle.Render("maintenance") -} - -func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string { - if monitorID == 0 { - return "All" - } - for _, s := range sites { - if s.ID == monitorID { - return limitStr(s.Name, maxW) - } - } - return fmt.Sprintf("#%d", monitorID) -} - -func (m Model) fmtMaintTime(t time.Time, colW int) string { - if t.IsZero() { - return m.st.subtleStyle.Render("—") - } - now := time.Now() - if t.Year() == now.Year() && t.YearDay() == now.YearDay() { - return t.Format("15:04") - } - if colW >= 14 { - return t.Format("15:04 Jan 02") - } - return t.Format("Jan 02") -} - func (m Model) isMonitorInMaintenance(monitorID int) bool { for _, mw := range m.maintenanceWindows { if mw.Type != "maintenance" { @@ -88,64 +44,6 @@ func (m Model) isMonitorInMaintenance(monitorID int) bool { return false } -func (m Model) viewMaintTab() string { - if len(m.maintenanceWindows) == 0 { - return m.emptyState("No maintenance windows or incidents.", "[n] Create one") - } - - var headers []string - var widths []int - if m.isWide() { - headers = []string{"#", "TITLE", "TYPE", "MONITORS", "STATUS", "STARTED", "ENDS"} - widths = []int{4, 24, 14, 22, 12, 16, 16} - } else { - headers = []string{"#", "TITLE", "TYPE", "MON", "ST", "START", "ENDS"} - widths = []int{4, 14, 13, 14, 11, 14, 14} - } - titleW := widths[1] - monW := widths[3] - timeW := widths[5] - - tbl := m.renderTable( - headers, - len(m.maintenanceWindows), - func(start, end int) [][]string { - var rows [][]string - allSites := m.engine.GetAllSites() - for i := start; i < end; i++ { - mw := m.maintenanceWindows[i] - rows = append(rows, []string{ - strconv.Itoa(i + 1), - m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, titleW-2)), - m.fmtMaintType(mw.Type), - fmtMaintMonitorW(mw.MonitorID, allSites, monW-2), - m.fmtMaintStatus(mw), - m.fmtMaintTime(mw.StartTime, timeW), - m.fmtMaintTime(mw.EndTime, timeW), - }) - } - return rows - }, - widths, - nil, - ) - - now := time.Now() - var active, scheduled, ended int - for _, mw := range m.maintenanceWindows { - if mw.StartTime.After(now) { - scheduled++ - } else if !mw.EndTime.IsZero() && mw.EndTime.Before(now) { - ended++ - } else { - active++ - } - } - summary := fmt.Sprintf("%d active · %d scheduled · %d ended", active, scheduled, ended) - - return tbl + "\n " + m.st.subtleStyle.Render(summary) -} - func (m *Model) initMaintHuhForm() tea.Cmd { m.maintFormData = &maintFormData{ Type: "maintenance", diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index 5599948..5ebe769 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -116,7 +116,12 @@ func (m *Model) submitUserForm() tea.Cmd { ctx := m.ctx id := m.editID username, key, role := d.Username, d.PublicKey, d.Role - m.state = stateDashboard + if m.returnState == stateSettings { + m.state = stateSettings + } else { + m.state = stateDashboard + } + m.returnState = 0 if id > 0 { return writeCmd("Update user", func() error { return st.UpdateUser(ctx, id, username, key, role) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 61e25c9..05b7a3c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -84,12 +84,6 @@ const ( detailSparkWidth = 40 ) -const ( - tabMonitors = 0 - tabMaint = 1 - tabSettings = 2 -) - const ( sectionAlerts = 0 sectionNodes = 1 @@ -100,6 +94,7 @@ const ( panelMonitors = 0 panelLogs = 1 panelDetail = 2 + panelMaint = 3 ) const ( @@ -114,7 +109,6 @@ type sessionState int const ( stateDashboard sessionState = iota stateLogs - stateUsers stateDetail stateAlertDetail stateFormSite @@ -124,12 +118,16 @@ const ( stateFormMaint stateHistory stateSLA + stateSettings + stateMaintDetail ) type Model struct { state sessionState - currentTab int + returnState sessionState settingsSection int + settingsCursor int + settingsOffset int cursor int selectedID int sortColumn int @@ -173,7 +171,9 @@ type Model struct { deleteID int deleteName string - deleteTab int + deleteKind string + + maintDetailID int ctx context.Context collapsed map[int]bool @@ -199,6 +199,9 @@ type Model struct { logsOpen bool detailOpen bool + maintOpen bool + maintCursor int + maintOffset int detailChanges []models.StateChange detailChangesSiteID int detailDailyDays []monitor.DayReport diff --git a/internal/tui/update.go b/internal/tui/update.go index 0326f46..13874f4 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -81,29 +81,33 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) { ctx := m.ctx id := m.deleteID var cmd tea.Cmd - switch m.deleteTab { - case tabMonitors: + switch m.deleteKind { + case "site": cmd = writeCmd("Delete site", func() error { return st.DeleteSite(ctx, id) }) m.engine.RemoveSite(id) m.adjustCursor(len(m.sites) - 1) - case tabMaint: + case "maint": cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(ctx, id) }) - m.adjustCursor(len(m.maintenanceWindows) - 1) - case tabSettings: - switch m.settingsSection { - case sectionAlerts: - cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(ctx, id) }) - m.adjustCursor(len(m.alerts) - 1) - case sectionUsers: - cmd = writeCmd("Delete user", func() error { return st.DeleteUser(ctx, id) }) - m.adjustCursor(len(m.users) - 1) - } + case "alert": + cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(ctx, id) }) + case "user": + cmd = writeCmd("Delete user", func() error { return st.DeleteUser(ctx, id) }) } m.refreshLive() - m.state = stateDashboard + if m.returnState == stateSettings { + m.state = stateSettings + } else { + m.state = stateDashboard + } + m.returnState = 0 return m, cmd case "n", "N", "esc": - m.state = stateDashboard + if m.returnState == stateSettings { + m.state = stateSettings + } else { + m.state = stateDashboard + } + m.returnState = 0 case "ctrl+c": return m, tea.Quit } @@ -117,7 +121,12 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) { } if keyMsg.String() == "esc" { m.huhForm = nil - m.state = stateDashboard + if m.returnState == stateSettings { + m.state = stateSettings + } else { + m.state = stateDashboard + } + m.returnState = 0 return m, nil } } @@ -153,7 +162,7 @@ func (m *Model) recalcLayout() { if m.filterMode || m.filterText != "" { chrome++ } - if m.detailOpen && m.currentTab == tabMonitors { + if m.detailOpen { chrome += detailInlineHeight } m.maxTableRows = m.termHeight - chrome @@ -313,7 +322,7 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } - if m.currentTab == tabMonitors && m.focusedPanel == panelLogs { + if m.focusedPanel == panelLogs { if msg.Button == tea.MouseButtonWheelUp { m.scrollLogs(-3) } else { @@ -339,7 +348,7 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } } m.syncSelectedID() - if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { + if m.detailOpen && m.cursor < len(m.sites) { return m, m.loadDetailCmd(m.sites[m.cursor].ID) } return m, nil @@ -366,6 +375,10 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleSLAKey(msg) case stateAlertDetail: return m.handleAlertDetailKey(msg) + case stateSettings: + return m.handleSettingsKey(msg) + case stateMaintDetail: + return m.handleMaintDetailKey(msg) case stateDashboard: return m.handleDashboardKey(msg) } @@ -565,10 +578,149 @@ func (m *Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) handleMaintDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + mw := m.findMaintWindow(m.maintDetailID) + switch msg.String() { + case "q", "esc": + m.state = stateDashboard + m.focusedPanel = panelMaint + case "x": + if mw != nil { + now := time.Now() + isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) + if isActive { + st := m.store + ctx := m.ctx + id := mw.ID + m.state = stateDashboard + m.focusedPanel = panelMaint + m.refreshLive() + return m, writeCmd("End maintenance", func() error { + return st.EndMaintenanceWindow(ctx, id) + }) + } + } + case "d": + if mw != nil { + m.deleteID = mw.ID + m.deleteName = mw.Title + m.deleteKind = "maint" + m.state = stateConfirmDelete + } + case "ctrl+c": + return m, tea.Quit + } + return m, nil +} + +func (m Model) findMaintWindow(id int) *models.MaintenanceWindow { + for i := range m.maintenanceWindows { + if m.maintenanceWindows[i].ID == id { + return &m.maintenanceWindows[i] + } + } + return nil +} + func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "i", "esc": + if m.returnState == stateSettings { + m.state = stateSettings + } else { + m.state = stateDashboard + } + } + return m, nil +} + +func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "S": m.state = stateDashboard + case "ctrl+c": + return m, tea.Quit + case "left": + m.switchSettingsSection(m.settingsSection - 1) + m.settingsCursor = 0 + m.settingsOffset = 0 + case "right": + m.switchSettingsSection(m.settingsSection + 1) + m.settingsCursor = 0 + m.settingsOffset = 0 + case "up", "k": + if m.settingsCursor > 0 { + m.settingsCursor-- + if m.settingsCursor < m.settingsOffset { + m.settingsOffset = m.settingsCursor + } + } + case "down", "j": + max := m.settingsListLen() - 1 + if m.settingsCursor < max { + m.settingsCursor++ + if m.settingsCursor >= m.settingsOffset+m.maxTableRows { + m.settingsOffset++ + } + } + case "n": + m.editID = 0 + m.editToken = "" + m.returnState = stateSettings + switch m.settingsSection { + case sectionAlerts: + m.state = stateFormAlert + return m, m.initAlertHuhForm() + case sectionUsers: + if m.isAdmin { + m.state = stateFormUser + return m, m.initUserHuhForm() + } + } + case "e": + m.returnState = stateSettings + switch m.settingsSection { + case sectionAlerts: + if len(m.alerts) > 0 && m.settingsCursor < len(m.alerts) { + m.editID = m.alerts[m.settingsCursor].ID + m.state = stateFormAlert + return m, m.initAlertHuhForm() + } + case sectionUsers: + if m.isAdmin && len(m.users) > 0 && m.settingsCursor < len(m.users) { + m.editID = m.users[m.settingsCursor].ID + m.state = stateFormUser + return m, m.initUserHuhForm() + } + } + case "d": + m.returnState = stateSettings + switch m.settingsSection { + case sectionAlerts: + if len(m.alerts) > 0 && m.settingsCursor < len(m.alerts) { + m.deleteID = m.alerts[m.settingsCursor].ID + m.deleteName = m.alerts[m.settingsCursor].Name + m.deleteKind = "alert" + m.state = stateConfirmDelete + } + case sectionUsers: + if m.isAdmin && len(m.users) > 0 && m.settingsCursor < len(m.users) { + m.deleteID = m.users[m.settingsCursor].ID + m.deleteName = m.users[m.settingsCursor].Username + m.deleteKind = "user" + m.state = stateConfirmDelete + } + } + case "t": + if m.settingsSection == sectionAlerts && len(m.alerts) > 0 && m.settingsCursor < len(m.alerts) { + a := m.alerts[m.settingsCursor] + return m, m.testAlertCmd(a.ID, a.Name) + } + case "i": + if m.settingsSection == sectionAlerts && len(m.alerts) > 0 && m.settingsCursor < len(m.alerts) { + m.returnState = stateSettings + m.state = stateAlertDetail + } } return m, nil } @@ -578,54 +730,46 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "q": return m, tea.Quit case "/": - if m.currentTab == tabMonitors { - m.filterMode = true - m.recalcLayout() - return m, nil - } + m.filterMode = true + m.recalcLayout() + return m, nil case ">", ".": - if m.currentTab == tabMonitors { - m.sortColumn = (m.sortColumn + 1) % sortMax - m.sortAsc = false - m.refreshLive() - } + m.sortColumn = (m.sortColumn + 1) % sortMax + m.sortAsc = false + m.refreshLive() case "<", ",": - if m.currentTab == tabMonitors { - m.sortColumn = (m.sortColumn - 1 + sortMax) % sortMax - m.sortAsc = false - m.refreshLive() - } + m.sortColumn = (m.sortColumn - 1 + sortMax) % sortMax + m.sortAsc = false + m.refreshLive() case "r": - if m.currentTab == tabMonitors { - m.sortAsc = !m.sortAsc - m.refreshLive() - } - case "tab": - m.switchTab(m.currentTab + 1) - case "left": - if m.currentTab == tabSettings { - m.switchSettingsSection(m.settingsSection - 1) - } - case "right": - if m.currentTab == tabSettings { - m.switchSettingsSection(m.settingsSection + 1) - } - case "l": - switch m.currentTab { - case tabSettings: - m.switchSettingsSection(m.settingsSection + 1) - case tabMonitors: - if m.focusedPanel == panelLogs { - m.logsOpen = false + m.sortAsc = !m.sortAsc + m.refreshLive() + case "m": + if m.termWidth >= wideBreakpoint { + if m.focusedPanel == panelMaint { + m.maintOpen = false m.focusedPanel = panelMonitors } else { - m.logsOpen = true - m.focusedPanel = panelLogs + m.maintOpen = true + m.focusedPanel = panelMaint } m.recalcLayout() } + case "l": + if m.focusedPanel == panelLogs { + m.logsOpen = false + m.focusedPanel = panelMonitors + } else { + m.logsOpen = true + m.focusedPanel = panelLogs + } + m.recalcLayout() case "up", "k": - if m.currentTab == tabMonitors && m.focusedPanel == panelLogs { + if m.focusedPanel == panelMaint { + m.scrollMaintCursor(-1) + return m, nil + } + if m.focusedPanel == panelLogs { m.scrollLogs(-1) return m, nil } @@ -635,12 +779,16 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.tableOffset = m.cursor } m.syncSelectedID() - if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { + if m.detailOpen && m.cursor < len(m.sites) { return m, m.loadDetailCmd(m.sites[m.cursor].ID) } } case "down", "j": - if m.currentTab == tabMonitors && m.focusedPanel == panelLogs { + if m.focusedPanel == panelMaint { + m.scrollMaintCursor(1) + return m, nil + } + if m.focusedPanel == panelLogs { m.scrollLogs(1) return m, nil } @@ -651,33 +799,39 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.tableOffset++ } m.syncSelectedID() - if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { + if m.detailOpen && m.cursor < len(m.sites) { return m, m.loadDetailCmd(m.sites[m.cursor].ID) } } case "n": + if m.focusedPanel == panelMaint { + m.state = stateFormMaint + return m, m.initMaintHuhForm() + } return m.handleNewItem() case "enter": - if m.currentTab == tabMonitors && m.focusedPanel == panelLogs { + if m.focusedPanel == panelMaint { + windows := m.activeMaintWindows() + if m.maintCursor < len(windows) { + m.maintDetailID = windows[m.maintCursor].ID + m.state = stateMaintDetail + } + return m, nil + } + if m.focusedPanel == panelLogs { m.refreshLogContent() m.logViewport.GotoTop() m.state = stateLogs return m, nil } - if m.currentTab == tabMonitors && len(m.sites) > 0 { + if len(m.sites) > 0 { m.state = stateDetail return m, m.loadDetailCmd(m.sites[m.cursor].ID) } - return m.handleEditItem() case "e": return m.handleEditItem() - case "t": - 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 == tabMonitors && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" { + if 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) @@ -689,7 +843,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { }) } case "p": - if m.currentTab == tabMonitors && len(m.sites) > 0 { + if len(m.sites) > 0 { id := m.sites[m.cursor].ID paused := m.engine.ToggleSitePause(id) st := m.store @@ -700,7 +854,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { }) } case "i": - if m.currentTab == tabMonitors && len(m.sites) > 0 { + if len(m.sites) > 0 { m.detailOpen = !m.detailOpen m.recalcLayout() st := m.store @@ -721,25 +875,21 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmd, saveCmd) } return m, saveCmd - } else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 { - m.state = stateAlertDetail } case "esc": - if m.currentTab == tabMonitors { - if m.focusedPanel != panelMonitors { - m.focusedPanel = panelMonitors - } else if m.detailOpen { - m.detailOpen = false - m.recalcLayout() - st := m.store - ctx := m.ctx - return m, writeCmd("Save detail preference", func() error { - return st.SetPreference(ctx, "detail_open", "false") - }) - } + if m.focusedPanel != panelMonitors { + m.focusedPanel = panelMonitors + } else if m.detailOpen { + m.detailOpen = false + m.recalcLayout() + st := m.store + ctx := m.ctx + return m, writeCmd("Save detail preference", func() error { + return st.SetPreference(ctx, "detail_open", "false") + }) } case "h": - if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { + if m.detailOpen && m.cursor < len(m.sites) { site := m.sites[m.cursor] m.historySiteName = site.Name m.historySiteID = site.ID @@ -753,24 +903,32 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.loadHistoryCmd(site.ID) } case "s": - if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) { + if m.detailOpen && m.cursor < len(m.sites) { return m, m.openSLAView(m.sites[m.cursor]) } case "x": - 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)) - if isActive { - st := m.store - ctx := m.ctx - id := mw.ID - m.refreshLive() - return m, writeCmd("End maintenance", func() error { - return st.EndMaintenanceWindow(ctx, id) - }) + if m.focusedPanel == panelMaint { + windows := m.activeMaintWindows() + if m.maintCursor < len(windows) { + mw := windows[m.maintCursor] + now := time.Now() + isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) + if isActive { + st := m.store + ctx := m.ctx + id := mw.ID + m.refreshLive() + return m, writeCmd("End maintenance", func() error { + return st.EndMaintenanceWindow(ctx, id) + }) + } } } + case "S": + m.settingsCursor = 0 + m.settingsOffset = 0 + m.state = stateSettings + return m, nil case "T": m.themeIndex = (m.themeIndex + 1) % len(themes) m.theme = themes[m.themeIndex] @@ -782,6 +940,17 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return st.SetPreference(ctx, "theme", name) }) case "d": + if m.focusedPanel == panelMaint { + windows := m.activeMaintWindows() + if m.maintCursor < len(windows) { + mw := windows[m.maintCursor] + m.deleteID = mw.ID + m.deleteName = mw.Title + m.deleteKind = "maint" + m.state = stateConfirmDelete + } + return m, nil + } return m.handleDeleteItem() } return m, nil @@ -790,162 +959,76 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) handleNewItem() (tea.Model, tea.Cmd) { m.editID = 0 m.editToken = "" - switch m.currentTab { - case tabMonitors: - m.state = stateFormSite - return m, m.initSiteHuhForm() - case tabMaint: - m.state = stateFormMaint - return m, m.initMaintHuhForm() - 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 + m.state = stateFormSite + return m, m.initSiteHuhForm() } func (m *Model) handleEditItem() (tea.Model, tea.Cmd) { - switch m.currentTab { - 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 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() - } - } + 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() } return m, nil } func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) { - switch m.currentTab { - case tabMonitors: - if len(m.sites) > 0 { - m.deleteID = m.sites[m.cursor].ID - m.deleteName = m.sites[m.cursor].Name - m.deleteTab = tabMonitors - m.state = stateConfirmDelete - } - case tabMaint: - if len(m.maintenanceWindows) > 0 { - m.deleteID = m.maintenanceWindows[m.cursor].ID - m.deleteName = m.maintenanceWindows[m.cursor].Title - m.deleteTab = tabMaint - 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 - } - } + if len(m.sites) > 0 { + m.deleteID = m.sites[m.cursor].ID + m.deleteName = m.sites[m.cursor].Name + m.deleteKind = "site" + m.state = stateConfirmDelete } return m, nil } func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - tabCount := tabSettings + 1 - for i := 0; i < tabCount; i++ { - if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) { - m.switchTab(i) - return m, nil - } + sortZones := []struct { + zone string + col int + }{ + {"sort-status", sortStatus}, + {"sort-name", sortName}, + {"sort-latency", sortLatency}, } - - if m.currentTab == tabSettings { - maxSec := 1 - if m.isAdmin { - maxSec = 2 - } - for i := 0; i <= maxSec; i++ { - if m.zones.Get(fmt.Sprintf("section-%d", i)).InBounds(msg) { - m.switchSettingsSection(i) - return m, nil + for _, sz := range sortZones { + if m.zones.Get(sz.zone).InBounds(msg) { + if m.sortColumn == sz.col { + m.sortAsc = !m.sortAsc + } else { + m.sortColumn = sz.col + m.sortAsc = false } - } - } - - if m.currentTab == tabMonitors { - sortZones := []struct { - zone string - col int - }{ - {"sort-status", sortStatus}, - {"sort-name", sortName}, - {"sort-latency", sortLatency}, - } - for _, sz := range sortZones { - if m.zones.Get(sz.zone).InBounds(msg) { - if m.sortColumn == sz.col { - m.sortAsc = !m.sortAsc - } else { - m.sortColumn = sz.col - m.sortAsc = false - } - m.refreshLive() - return m, nil - } - } - - if m.zones.Get("panel-monitors").InBounds(msg) { - m.focusedPanel = panelMonitors - } else if m.zones.Get("panel-logs").InBounds(msg) { - m.focusedPanel = panelLogs - return m, nil - } else if m.detailOpen && m.zones.Get("panel-detail").InBounds(msg) { - m.focusedPanel = panelDetail + m.refreshLive() return m, nil } } - prefix, listLen := m.currentZonePrefix() + if m.maintOpen && m.zones.Get("panel-maint").InBounds(msg) { + m.focusedPanel = panelMaint + return m, nil + } else if m.zones.Get("panel-monitors").InBounds(msg) { + m.focusedPanel = panelMonitors + } else if m.zones.Get("panel-logs").InBounds(msg) { + m.focusedPanel = panelLogs + return m, nil + } else if m.detailOpen && m.zones.Get("panel-detail").InBounds(msg) { + m.focusedPanel = panelDetail + return m, nil + } + end := m.tableOffset + m.maxTableRows - if end > listLen { - end = listLen + if end > len(m.sites) { + end = len(m.sites) } for i := m.tableOffset; i < end; i++ { - if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) { + if m.zones.Get(fmt.Sprintf("site-%d", i)).InBounds(msg) { m.cursor = i m.syncSelectedID() - if m.currentTab == tabMonitors { - m.focusedPanel = panelMonitors - if m.detailOpen { - return m, m.loadDetailCmd(m.sites[m.cursor].ID) - } + m.focusedPanel = panelMonitors + if m.detailOpen { + return m, m.loadDetailCmd(m.sites[m.cursor].ID) } return m, nil } @@ -954,17 +1037,6 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } -func (m *Model) switchTab(idx int) { - maxTabs := tabSettings - if idx > maxTabs { - idx = 0 - } - m.currentTab = idx - m.cursor = 0 - m.tableOffset = 0 - m.state = stateDashboard -} - func (m *Model) adjustCursor(_ int) { m.clampCursor() } @@ -992,37 +1064,5 @@ func (m *Model) submitForm() tea.Cmd { } func (m Model) currentListLen() int { - switch m.currentTab { - 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 tabMonitors: - return "site", len(m.sites) - case tabMaint: - return "maint", len(m.maintenanceWindows) - case tabSettings: - switch m.settingsSection { - case sectionAlerts: - return "alert", len(m.alerts) - case sectionUsers: - return "user", len(m.users) - } - } - return "site", 0 + return len(m.sites) } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 11b98f6..9234289 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -266,7 +266,7 @@ func TestConfirmDelete_WritesOffUIGoroutine(t *testing.T) { m := newTestModel(ms) m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 4, Name: "s"}}} m.state = stateConfirmDelete - m.deleteTab = 0 + m.deleteKind = "site" m.deleteID = 4 updated, cmd := (&m).handleConfirmDelete(keyMsg("y")) diff --git a/internal/tui/view_dashboard.go b/internal/tui/view_dashboard.go index 28d57fc..20a0d2b 100644 --- a/internal/tui/view_dashboard.go +++ b/internal/tui/view_dashboard.go @@ -47,16 +47,13 @@ func (m Model) View() string { switch m.state { case stateConfirmDelete: kind := "monitor" - switch m.deleteTab { - case tabMaint: + switch m.deleteKind { + case "maint": kind = "maintenance window" - case tabSettings: - switch m.settingsSection { - case sectionAlerts: - kind = "alert" - case sectionUsers: - kind = "user" - } + case "alert": + kind = "alert" + case "user": + kind = "user" } msg := m.st.dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) hint := m.st.subtleStyle.Render("[y] Confirm [n] Cancel") @@ -103,6 +100,10 @@ func (m Model) View() string { return m.viewSLAPanel() case stateAlertDetail: return m.viewAlertDetailPanel() + case stateSettings: + return m.viewSettingsOverlay() + case stateMaintDetail: + return m.viewMaintDetailPanel() default: return m.zones.Scan(m.viewDashboard()) } @@ -148,63 +149,68 @@ func (m Model) computeStats() dashboardStats { return s } +const maintSidebarW = 22 + +func (m Model) viewMonitorsLayout() string { + availW := m.termWidth - chromePadH + wide := m.termWidth >= wideBreakpoint + + showMaint := m.maintOpen && wide + showLogs := m.logsOpen && wide + + var maintW, logsW, monW int + if showMaint { + maintW = maintSidebarW + if maintW > availW/4 { + maintW = availW / 4 + } + } + remaining := availW - maintW + if showLogs { + monW = remaining * 70 / 100 + logsW = remaining - monW + } else { + monW = remaining + } + + m.contentWidth = monW - 2 + + monitors := m.viewSitesTab() + monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, monW, m.focusedPanel == panelMonitors)) + + var topParts []string + if showMaint { + sidebar := m.viewMaintSidebar(maintW-2, m.maxTableRows) + maintPanel := m.zones.Mark("panel-maint", m.titledPanel("Maint", sidebar, maintW, m.focusedPanel == panelMaint)) + topParts = append(topParts, maintPanel) + } + topParts = append(topParts, monPanel) + if showLogs { + logContent := m.viewLogsSidebar(logsW-2, m.maxTableRows) + logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, logsW, m.focusedPanel == panelLogs)) + topParts = append(topParts, logPanel) + } + + top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...) + + if m.detailOpen { + site := "" + if m.cursor < len(m.sites) { + site = m.sites[m.cursor].Name + } + detail := m.viewDetailInline(availW - 2) + detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail)) + return top + "\n" + detailPanel + } + return top +} + func (m Model) viewDashboard() string { stats := m.computeStats() - header := m.renderTabBar(stats) - header = m.pulseIndicator() + " " + header + header := m.renderStatusLine(stats) - var content string - switch m.currentTab { - case tabMonitors: - showSidebar := m.termWidth >= wideBreakpoint && m.logsOpen - if showSidebar { - availW := m.termWidth - chromePadH - leftW := availW * 70 / 100 - rightW := availW - leftW - m.contentWidth = leftW - 2 - monitors := m.viewSitesTab() - monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors)) - sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows) - logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs)) - top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel) - if m.detailOpen { - site := "" - if m.cursor < len(m.sites) { - site = m.sites[m.cursor].Name - } - detail := m.viewDetailInline(availW - 2) - detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail)) - content = top + "\n" + detailPanel - } else { - content = top - } - } else { - m.contentWidth = m.termWidth - 2 - monitors := m.viewSitesTab() - availW := m.termWidth - chromePadH - monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, availW, m.focusedPanel == panelMonitors)) - if m.detailOpen { - site := "" - if m.cursor < len(m.sites) { - site = m.sites[m.cursor].Name - } - detail := m.viewDetailInline(availW - 2) - detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail)) - content = monPanel + "\n" + detailPanel - } else { - content = monPanel - } - } - case tabMaint: - m.contentWidth = m.termWidth - availW := m.termWidth - chromePadH - content = m.titledPanel("Maintenance", m.viewMaintTab(), availW, true) - case tabSettings: - m.contentWidth = m.termWidth - availW := m.termWidth - chromePadH - content = m.titledPanel("Settings", m.viewSettingsTab(), availW, true) - } + content := m.viewMonitorsLayout() content = strings.TrimSpace(content) footer := m.renderFooter(stats) @@ -216,74 +222,17 @@ func (m Model) viewDashboard() string { availHeight = 5 } - divW := m.termWidth - chromePadH - if divW < 40 { - divW = 40 - } - tabDivider := m.st.subtleStyle.Render(strings.Repeat("─", divW)) - - contentHeight := availHeight - lipgloss.Height(header) - 1 - lipgloss.Height(footer) + contentHeight := availHeight - lipgloss.Height(header) - lipgloss.Height(footer) if contentHeight < 1 { contentHeight = 1 } paddedContent := lipgloss.NewStyle().Height(contentHeight).MaxHeight(contentHeight).Render(content) - return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, tabDivider, paddedContent, footer)) + return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, paddedContent, footer)) } -type tabEntry struct { - name string - count int - warn int -} - -func (m Model) renderTabBar(stats dashboardStats) string { - settingsCount := len(m.alerts) + len(m.nodes) - settingsWarn := stats.offlineNodes - for _, a := range m.alerts { - h := m.engine.GetAlertHealth(a.ID) - if !h.LastSendOK && !h.LastSendAt.IsZero() { - settingsWarn++ - } - } - if m.isAdmin { - settingsCount += len(m.users) - } - tabs := []tabEntry{ - {"Monitors", stats.totalMonitors, stats.downCount + stats.lateCount}, - {"Maint", len(m.maintenanceWindows), stats.activeMaint}, - {"Settings", settingsCount, settingsWarn}, - } - - countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted) - - var renderedTabs []string - for i, t := range tabs { - label := t.name - if t.count > 0 { - badge := countStyle.Render(fmt.Sprintf(" %d", t.count)) - if t.warn > 0 { - badge = m.st.dangerStyle.Render(fmt.Sprintf(" %d", t.warn)) - } - label += badge - } - - var rendered string - if i == m.currentTab { - rendered = m.st.activeTab.Render(label) - } else { - rendered = m.st.inactiveTab.Render(label) - } - renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered)) - } - return lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) -} - -func (m Model) renderFooter(stats dashboardStats) string { - if m.filterMode { - cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│") - return "\n" + m.st.titleStyle.Render("/") + " " + m.filterText + cursor + " " + m.st.subtleStyle.Render("[Enter]Apply [Esc]Clear") - } +func (m Model) renderStatusLine(stats dashboardStats) string { + dot := m.st.subtleStyle.Render(" · ") upCount := stats.totalMonitors - stats.downCount - stats.lateCount var upStr string @@ -294,9 +243,14 @@ func (m Model) renderFooter(stats dashboardStats) string { } else { upStr = m.st.specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) } - statusParts := []string{upStr} + + parts := []string{m.pulseIndicator() + " " + upStr} + if stats.lateCount > 0 { - statusParts = append(statusParts, m.st.warnStyle.Render(fmt.Sprintf("%d LATE", stats.lateCount))) + parts = append(parts, m.st.warnStyle.Render(fmt.Sprintf("%d late", stats.lateCount))) + } + if stats.activeMaint > 0 { + parts = append(parts, m.st.maintStyle.Render(fmt.Sprintf("%d maint", stats.activeMaint))) } if len(m.nodes) > 0 { online := 0 @@ -305,48 +259,45 @@ func (m Model) renderFooter(stats dashboardStats) string { online++ } } - probeLabel := "probes" + label := "probes" if online == 1 { - probeLabel = "probe" + label = "probe" } - statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel)) + parts = append(parts, fmt.Sprintf("%d %s", online, label)) + } + + left := strings.Join(parts, dot) + ver := m.st.subtleStyle.Render("v" + m.version) + + padW := m.termWidth - chromePadH - lipgloss.Width(left) - lipgloss.Width(ver) + if padW < 2 { + padW = 2 + } + + return left + strings.Repeat(" ", padW) + ver +} + +func (m Model) renderFooter(_ dashboardStats) string { + if m.filterMode { + cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│") + return "\n" + m.st.titleStyle.Render("/") + " " + m.filterText + cursor + " " + m.st.subtleStyle.Render("[Enter]Apply [Esc]Clear") } - statusLine := strings.Join(statusParts, m.st.subtleStyle.Render(" · ")) var keys string - switch m.currentTab { - case tabMonitors: - if m.focusedPanel == panelLogs { - keys = "[↑/↓]Scroll [Enter]Expand [l/Esc]Back [T]Theme [q]Quit" - } else if m.detailOpen { - keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit" - } else { - keys = "[/]Filter [i]Info [Enter]Detail []Sort [r]Reverse [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit" - } - case tabMaint: - keys = "[n]New [x]End [d]Del [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" + if m.focusedPanel == panelMaint { + keys = "[n]New [x]End [d]Del [Esc]Back [S]Settings [T]Theme [q]Quit" + } else if m.focusedPanel == panelLogs { + keys = "[↑/↓]Scroll [Enter]Expand [l/Esc]Back [S]Settings [T]Theme [q]Quit" + } else if m.detailOpen { + keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit" + } else { + keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit" } - ver := m.st.subtleStyle.Render("v" + m.version) - line := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver - 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(keys) + if m.filterText != "" { + line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s ", m.filterText)) + line } - divW := m.termWidth - chromePadH - if divW < 40 { - divW = 40 - } - return m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n" + line + return "\n" + line } diff --git a/internal/tui/view_maint_sidebar.go b/internal/tui/view_maint_sidebar.go new file mode 100644 index 0000000..45c191f --- /dev/null +++ b/internal/tui/view_maint_sidebar.go @@ -0,0 +1,193 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" + "github.com/charmbracelet/lipgloss" +) + +func (m Model) viewMaintDetailPanel() string { + mw := m.findMaintWindow(m.maintDetailID) + if mw == nil { + return lipgloss.NewStyle().Padding(1, 2).Render(m.st.subtleStyle.Render("Maintenance window not found")) + } + + var b strings.Builder + + header := " " + m.st.subtleStyle.Render("Maintenance >") + " " + m.st.titleStyle.Render(mw.Title) + b.WriteString(header + "\n") + b.WriteString(m.divider() + "\n") + + row := func(label, value string) { + fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render(label), value) + } + + maintType := m.st.maintStyle.Render("maintenance") + if mw.Type == "incident" { + maintType = m.st.dangerStyle.Render("incident") + } + row("Type", maintType) + + now := time.Now() + if mw.StartTime.After(now) { + row("Status", m.st.warnStyle.Render("SCHEDULED")) + } else if !mw.EndTime.IsZero() && mw.EndTime.Before(now) { + row("Status", m.st.subtleStyle.Render("ENDED")) + } else { + row("Status", m.st.specialStyle.Render("ACTIVE")) + } + + if mw.MonitorID == 0 { + row("Monitors", "All") + } else { + name := fmt.Sprintf("#%d", mw.MonitorID) + allSites := m.engine.GetAllSites() + for _, s := range allSites { + if s.ID == mw.MonitorID { + name = s.Name + break + } + } + row("Monitors", name) + } + + row("Started", mw.StartTime.Format("2006-01-02 15:04")) + if mw.EndTime.IsZero() { + row("Ends", m.st.subtleStyle.Render("indefinite")) + } else { + row("Ends", mw.EndTime.Format("2006-01-02 15:04")) + if mw.EndTime.After(now) && !mw.StartTime.After(now) { + remaining := time.Until(mw.EndTime) + row("Remaining", fmtDuration(remaining)) + } + dur := mw.EndTime.Sub(mw.StartTime) + row("Duration", fmtDuration(dur)) + } + + if mw.Description != "" { + b.WriteString("\n") + row("Description", mw.Description) + } + + b.WriteString("\n" + m.divider() + "\n") + + var keys []string + isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) + if isActive { + keys = append(keys, "[x] End") + } + keys = append(keys, "[d] Delete", "[q/Esc] Back") + b.WriteString(" " + m.st.subtleStyle.Render(strings.Join(keys, " "))) + + return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) +} + +func (m Model) activeMaintWindows() []models.MaintenanceWindow { + now := time.Now() + var out []models.MaintenanceWindow + for _, mw := range m.maintenanceWindows { + if !mw.EndTime.IsZero() && mw.EndTime.Before(now) { + continue + } + out = append(out, mw) + } + return out +} + +func (m Model) viewMaintSidebar(width, maxLines int) string { + windows := m.activeMaintWindows() + if len(windows) == 0 { + return m.st.subtleStyle.Render(" No active maintenance") + } + + contentW := width - 2 + if contentW < 10 { + contentW = 10 + } + + start := m.maintOffset + if start > len(windows) { + start = len(windows) + } + linesUsed := 0 + end := start + for end < len(windows) && linesUsed+2 <= maxLines { + linesUsed += 2 + end++ + } + + var lines []string + now := time.Now() + for i := start; i < end; i++ { + mw := windows[i] + selected := m.focusedPanel == panelMaint && i == m.maintCursor + + isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) + + var icon string + if isActive { + icon = m.st.specialStyle.Render("●") + } else { + icon = m.st.warnStyle.Render("○") + } + + title := limitStr(mw.Title, contentW-3) + titleLine := " " + icon + " " + title + + var detail string + if isActive { + if mw.EndTime.IsZero() { + detail = m.st.subtleStyle.Render("active · indefinite") + } else { + remaining := time.Until(mw.EndTime) + detail = m.st.subtleStyle.Render("active · " + fmtDuration(remaining)) + } + } else { + detail = m.st.subtleStyle.Render(mw.StartTime.Format("Jan 02") + " · " + fmtDuration(mw.EndTime.Sub(mw.StartTime))) + } + detailLine := " " + limitStr(detail, contentW-3) + + if selected { + sel := m.st.tableSelectedStyle + titleLine = sel.Render(lipgloss.NewStyle().Width(contentW).Render(titleLine)) + detailLine = sel.Render(lipgloss.NewStyle().Width(contentW).Render(detailLine)) + } + + lines = append(lines, titleLine, detailLine) + } + + if len(windows) > end-start { + more := fmt.Sprintf(" %d more", len(windows)-(end-start)) + lines = append(lines, m.st.subtleStyle.Render(more)) + } + + return strings.Join(lines, "\n") +} + +func (m *Model) scrollMaintCursor(delta int) { + windows := m.activeMaintWindows() + total := len(windows) + if total == 0 { + return + } + m.maintCursor += delta + if m.maintCursor < 0 { + m.maintCursor = 0 + } + if m.maintCursor >= total { + m.maintCursor = total - 1 + } + if m.maintCursor < m.maintOffset { + m.maintOffset = m.maintCursor + } + visible := m.maxTableRows / 2 + if visible < 1 { + visible = 1 + } + if m.maintCursor >= m.maintOffset+visible { + m.maintOffset = m.maintCursor - visible + 1 + } +} diff --git a/internal/tui/view_settings_overlay.go b/internal/tui/view_settings_overlay.go new file mode 100644 index 0000000..6e40e40 --- /dev/null +++ b/internal/tui/view_settings_overlay.go @@ -0,0 +1,87 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +func (m Model) viewSettingsOverlay() string { + overlayW := m.termWidth - 8 + if overlayW > 90 { + overlayW = 90 + } + if overlayW < 40 { + overlayW = 40 + } + + overlayH := m.termHeight - 6 + if overlayH < 10 { + overlayH = 10 + } + + savedCursor, savedOffset := m.cursor, m.tableOffset + savedContentW := m.contentWidth + m.cursor = m.settingsCursor + m.tableOffset = m.settingsOffset + m.contentWidth = overlayW - 4 + + content := m.viewSettingsTab() + + m.cursor = savedCursor + m.tableOffset = savedOffset + m.contentWidth = savedContentW + + settingsListLen := m.settingsListLen() + footer := m.settingsFooterKeys() + if settingsListLen > 0 { + footer = fmt.Sprintf("%d items %s", settingsListLen, footer) + } + footerLine := m.st.subtleStyle.Render(footer) + + inner := content + "\n" + footerLine + box := m.titledPanel("Settings", inner, overlayW, true) + + boxLines := lipgloss.Height(box) + if boxLines > overlayH { + lines := splitLines(box, overlayH) + box = lines + } + + return placeOverlay(box, m.termWidth, m.termHeight) +} + +func (m Model) settingsFooterKeys() string { + switch m.settingsSection { + case sectionAlerts: + return "[n]New [e]Edit [i]Info [d]Del [t]Test [←/→]Section [Esc]Close" + case sectionUsers: + if m.isAdmin { + return "[n]Add [d]Revoke [←/→]Section [Esc]Close" + } + return "[←/→]Section [Esc]Close" + default: + return "[←/→]Section [Esc]Close" + } +} + +func (m Model) settingsListLen() int { + switch m.settingsSection { + case sectionAlerts: + return len(m.alerts) + case sectionNodes: + return len(m.nodes) + case sectionUsers: + return len(m.users) + } + return 0 +} + +func splitLines(s string, maxLines int) string { + lines := strings.Split(s, "\n") + if len(lines) > maxLines { + lines = lines[:maxLines] + } + return strings.Join(lines, "\n") +}