feat(tui): single-dashboard layout with sidebar and overlay #162

Merged
lerko merged 1 commits from feat/single-dashboard into main 2026-06-29 22:18:00 +00:00
11 changed files with 744 additions and 554 deletions
+2 -2
View File
@@ -125,7 +125,7 @@ func (m *Model) refreshLive() {
m.sites = ordered m.sites = ordered
m.refreshLogContent() m.refreshLogContent()
if m.currentTab == tabMonitors && m.selectedID != 0 { if 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
@@ -137,7 +137,7 @@ func (m *Model) refreshLive() {
} }
func (m *Model) syncSelectedID() { 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 m.selectedID = m.sites[m.cursor].ID
} }
} }
+4
View File
@@ -50,3 +50,7 @@ func max(a, b int) int {
} }
return b return b
} }
func placeOverlay(fg string, termW, termH int) string {
return lipgloss.Place(termW, termH, lipgloss.Center, lipgloss.Center, fg)
}
+12 -3
View File
@@ -292,10 +292,14 @@ func (m Model) fmtAlertConfigFull(alert models.AlertConfig) string {
} }
func (m Model) viewAlertDetailPanel() 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 "" return ""
} }
a := m.alerts[m.cursor] a := m.alerts[idx]
h := m.engine.GetAlertHealth(a.ID) h := m.engine.GetAlertHealth(a.ID)
var b strings.Builder var b strings.Builder
@@ -604,7 +608,12 @@ func (m *Model) submitAlertForm() tea.Cmd {
ctx := m.ctx ctx := m.ctx
id := m.editID id := m.editID
name, aType := d.Name, d.AlertType 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 { if id > 0 {
return writeCmd("Update alert", func() error { return writeCmd("Update alert", func() error {
return st.UpdateAlert(ctx, id, name, aType, settings) return st.UpdateAlert(ctx, id, name, aType, settings)
-102
View File
@@ -20,50 +20,6 @@ type maintFormData struct {
CustomHours string 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 { func (m Model) isMonitorInMaintenance(monitorID int) bool {
for _, mw := range m.maintenanceWindows { for _, mw := range m.maintenanceWindows {
if mw.Type != "maintenance" { if mw.Type != "maintenance" {
@@ -88,64 +44,6 @@ func (m Model) isMonitorInMaintenance(monitorID int) bool {
return false 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 { func (m *Model) initMaintHuhForm() tea.Cmd {
m.maintFormData = &maintFormData{ m.maintFormData = &maintFormData{
Type: "maintenance", Type: "maintenance",
+6 -1
View File
@@ -116,7 +116,12 @@ func (m *Model) submitUserForm() tea.Cmd {
ctx := m.ctx ctx := m.ctx
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 = stateDashboard if m.returnState == stateSettings {
m.state = stateSettings
} else {
m.state = stateDashboard
}
m.returnState = 0
if id > 0 { if id > 0 {
return writeCmd("Update user", func() error { return writeCmd("Update user", func() error {
return st.UpdateUser(ctx, id, username, key, role) return st.UpdateUser(ctx, id, username, key, role)
+12 -9
View File
@@ -84,12 +84,6 @@ const (
detailSparkWidth = 40 detailSparkWidth = 40
) )
const (
tabMonitors = 0
tabMaint = 1
tabSettings = 2
)
const ( const (
sectionAlerts = 0 sectionAlerts = 0
sectionNodes = 1 sectionNodes = 1
@@ -100,6 +94,7 @@ const (
panelMonitors = 0 panelMonitors = 0
panelLogs = 1 panelLogs = 1
panelDetail = 2 panelDetail = 2
panelMaint = 3
) )
const ( const (
@@ -114,7 +109,6 @@ type sessionState int
const ( const (
stateDashboard sessionState = iota stateDashboard sessionState = iota
stateLogs stateLogs
stateUsers
stateDetail stateDetail
stateAlertDetail stateAlertDetail
stateFormSite stateFormSite
@@ -124,12 +118,16 @@ const (
stateFormMaint stateFormMaint
stateHistory stateHistory
stateSLA stateSLA
stateSettings
stateMaintDetail
) )
type Model struct { type Model struct {
state sessionState state sessionState
currentTab int returnState sessionState
settingsSection int settingsSection int
settingsCursor int
settingsOffset int
cursor int cursor int
selectedID int selectedID int
sortColumn int sortColumn int
@@ -173,7 +171,9 @@ type Model struct {
deleteID int deleteID int
deleteName string deleteName string
deleteTab int deleteKind string
maintDetailID int
ctx context.Context ctx context.Context
collapsed map[int]bool collapsed map[int]bool
@@ -199,6 +199,9 @@ type Model struct {
logsOpen bool logsOpen bool
detailOpen bool detailOpen bool
maintOpen bool
maintCursor int
maintOffset int
detailChanges []models.StateChange detailChanges []models.StateChange
detailChangesSiteID int detailChangesSiteID int
detailDailyDays []monitor.DayReport detailDailyDays []monitor.DayReport
+316 -276
View File
@@ -81,29 +81,33 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) {
ctx := m.ctx ctx := m.ctx
id := m.deleteID id := m.deleteID
var cmd tea.Cmd var cmd tea.Cmd
switch m.deleteTab { switch m.deleteKind {
case tabMonitors: case "site":
cmd = writeCmd("Delete site", func() error { return st.DeleteSite(ctx, id) }) cmd = writeCmd("Delete site", func() error { return st.DeleteSite(ctx, id) })
m.engine.RemoveSite(id) m.engine.RemoveSite(id)
m.adjustCursor(len(m.sites) - 1) m.adjustCursor(len(m.sites) - 1)
case tabMaint: case "maint":
cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(ctx, id) }) cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(ctx, id) })
m.adjustCursor(len(m.maintenanceWindows) - 1) case "alert":
case tabSettings: cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(ctx, id) })
switch m.settingsSection { case "user":
case sectionAlerts: cmd = writeCmd("Delete user", func() error { return st.DeleteUser(ctx, id) })
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)
}
} }
m.refreshLive() m.refreshLive()
m.state = stateDashboard if m.returnState == stateSettings {
m.state = stateSettings
} else {
m.state = stateDashboard
}
m.returnState = 0
return m, cmd return m, cmd
case "n", "N", "esc": case "n", "N", "esc":
m.state = stateDashboard if m.returnState == stateSettings {
m.state = stateSettings
} else {
m.state = stateDashboard
}
m.returnState = 0
case "ctrl+c": case "ctrl+c":
return m, tea.Quit return m, tea.Quit
} }
@@ -117,7 +121,12 @@ 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 if m.returnState == stateSettings {
m.state = stateSettings
} else {
m.state = stateDashboard
}
m.returnState = 0
return m, nil return m, nil
} }
} }
@@ -153,7 +162,7 @@ func (m *Model) recalcLayout() {
if m.filterMode || m.filterText != "" { if m.filterMode || m.filterText != "" {
chrome++ chrome++
} }
if m.detailOpen && m.currentTab == tabMonitors { if m.detailOpen {
chrome += detailInlineHeight chrome += detailInlineHeight
} }
m.maxTableRows = m.termHeight - chrome m.maxTableRows = m.termHeight - chrome
@@ -313,7 +322,7 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs { if m.focusedPanel == panelLogs {
if msg.Button == tea.MouseButtonWheelUp { if msg.Button == tea.MouseButtonWheelUp {
m.scrollLogs(-3) m.scrollLogs(-3)
} else { } else {
@@ -339,7 +348,7 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
} }
m.syncSelectedID() 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, m.loadDetailCmd(m.sites[m.cursor].ID)
} }
return m, nil return m, nil
@@ -366,6 +375,10 @@ 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 stateSettings:
return m.handleSettingsKey(msg)
case stateMaintDetail:
return m.handleMaintDetailKey(msg)
case stateDashboard: case stateDashboard:
return m.handleDashboardKey(msg) return m.handleDashboardKey(msg)
} }
@@ -565,10 +578,149 @@ func (m *Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil 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) { func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "q", "i", "esc": 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 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 return m, nil
} }
@@ -578,54 +730,46 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "q": case "q":
return m, tea.Quit return m, tea.Quit
case "/": case "/":
if m.currentTab == tabMonitors { m.filterMode = true
m.filterMode = true m.recalcLayout()
m.recalcLayout() return m, nil
return m, nil
}
case ">", ".": case ">", ".":
if m.currentTab == tabMonitors { m.sortColumn = (m.sortColumn + 1) % sortMax
m.sortColumn = (m.sortColumn + 1) % sortMax m.sortAsc = false
m.sortAsc = false m.refreshLive()
m.refreshLive()
}
case "<", ",": case "<", ",":
if m.currentTab == tabMonitors { m.sortColumn = (m.sortColumn - 1 + sortMax) % sortMax
m.sortColumn = (m.sortColumn - 1 + sortMax) % sortMax m.sortAsc = false
m.sortAsc = false m.refreshLive()
m.refreshLive()
}
case "r": case "r":
if m.currentTab == tabMonitors { m.sortAsc = !m.sortAsc
m.sortAsc = !m.sortAsc m.refreshLive()
m.refreshLive() case "m":
} if m.termWidth >= wideBreakpoint {
case "tab": if m.focusedPanel == panelMaint {
m.switchTab(m.currentTab + 1) m.maintOpen = false
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.focusedPanel = panelMonitors m.focusedPanel = panelMonitors
} else { } else {
m.logsOpen = true m.maintOpen = true
m.focusedPanel = panelLogs m.focusedPanel = panelMaint
} }
m.recalcLayout() 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": 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) m.scrollLogs(-1)
return m, nil return m, nil
} }
@@ -635,12 +779,16 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.tableOffset = m.cursor m.tableOffset = m.cursor
} }
m.syncSelectedID() 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, m.loadDetailCmd(m.sites[m.cursor].ID)
} }
} }
case "down", "j": 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) m.scrollLogs(1)
return m, nil return m, nil
} }
@@ -651,33 +799,39 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.tableOffset++ m.tableOffset++
} }
m.syncSelectedID() 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, m.loadDetailCmd(m.sites[m.cursor].ID)
} }
} }
case "n": case "n":
if m.focusedPanel == panelMaint {
m.state = stateFormMaint
return m, m.initMaintHuhForm()
}
return m.handleNewItem() return m.handleNewItem()
case "enter": 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.refreshLogContent()
m.logViewport.GotoTop() m.logViewport.GotoTop()
m.state = stateLogs m.state = stateLogs
return m, nil return m, nil
} }
if m.currentTab == tabMonitors && len(m.sites) > 0 { if 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)
} }
return m.handleEditItem()
case "e": case "e":
return m.handleEditItem() 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 " ": 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 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)
@@ -689,7 +843,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}) })
} }
case "p": case "p":
if m.currentTab == tabMonitors && len(m.sites) > 0 { if 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
@@ -700,7 +854,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}) })
} }
case "i": case "i":
if m.currentTab == tabMonitors && len(m.sites) > 0 { if len(m.sites) > 0 {
m.detailOpen = !m.detailOpen m.detailOpen = !m.detailOpen
m.recalcLayout() m.recalcLayout()
st := m.store 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, tea.Batch(cmd, saveCmd)
} }
return m, saveCmd return m, saveCmd
} else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
m.state = stateAlertDetail
} }
case "esc": case "esc":
if m.currentTab == tabMonitors { if m.focusedPanel != panelMonitors {
if m.focusedPanel != panelMonitors { m.focusedPanel = panelMonitors
m.focusedPanel = panelMonitors } else if m.detailOpen {
} else if m.detailOpen { m.detailOpen = false
m.detailOpen = false m.recalcLayout()
m.recalcLayout() st := m.store
st := m.store ctx := m.ctx
ctx := m.ctx return m, writeCmd("Save detail preference", func() error {
return m, writeCmd("Save detail preference", func() error { return st.SetPreference(ctx, "detail_open", "false")
return st.SetPreference(ctx, "detail_open", "false") })
})
}
} }
case "h": 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] site := m.sites[m.cursor]
m.historySiteName = site.Name m.historySiteName = site.Name
m.historySiteID = site.ID 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) return m, m.loadHistoryCmd(site.ID)
} }
case "s": 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]) return m, m.openSLAView(m.sites[m.cursor])
} }
case "x": case "x":
if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 { if m.focusedPanel == panelMaint {
mw := m.maintenanceWindows[m.cursor] windows := m.activeMaintWindows()
now := time.Now() if m.maintCursor < len(windows) {
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) mw := windows[m.maintCursor]
if isActive { now := time.Now()
st := m.store isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
ctx := m.ctx if isActive {
id := mw.ID st := m.store
m.refreshLive() ctx := m.ctx
return m, writeCmd("End maintenance", func() error { id := mw.ID
return st.EndMaintenanceWindow(ctx, 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": case "T":
m.themeIndex = (m.themeIndex + 1) % len(themes) m.themeIndex = (m.themeIndex + 1) % len(themes)
m.theme = themes[m.themeIndex] 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) return st.SetPreference(ctx, "theme", name)
}) })
case "d": 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.handleDeleteItem()
} }
return m, nil 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) { func (m *Model) handleNewItem() (tea.Model, tea.Cmd) {
m.editID = 0 m.editID = 0
m.editToken = "" m.editToken = ""
switch m.currentTab { m.state = stateFormSite
case tabMonitors: return m, m.initSiteHuhForm()
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
} }
func (m *Model) handleEditItem() (tea.Model, tea.Cmd) { func (m *Model) handleEditItem() (tea.Model, tea.Cmd) {
switch m.currentTab { if len(m.sites) > 0 {
case tabMonitors: m.editID = m.sites[m.cursor].ID
if len(m.sites) > 0 { m.editToken = m.sites[m.cursor].Token
m.editID = m.sites[m.cursor].ID m.state = stateFormSite
m.editToken = m.sites[m.cursor].Token return m, m.initSiteHuhForm()
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()
}
}
} }
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 { if len(m.sites) > 0 {
case tabMonitors: m.deleteID = m.sites[m.cursor].ID
if len(m.sites) > 0 { m.deleteName = m.sites[m.cursor].Name
m.deleteID = m.sites[m.cursor].ID m.deleteKind = "site"
m.deleteName = m.sites[m.cursor].Name m.state = stateConfirmDelete
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
}
}
} }
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 := tabSettings + 1 sortZones := []struct {
for i := 0; i < tabCount; i++ { zone string
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) { col int
m.switchTab(i) }{
return m, nil {"sort-status", sortStatus},
} {"sort-name", sortName},
{"sort-latency", sortLatency},
} }
for _, sz := range sortZones {
if m.currentTab == tabSettings { if m.zones.Get(sz.zone).InBounds(msg) {
maxSec := 1 if m.sortColumn == sz.col {
if m.isAdmin { m.sortAsc = !m.sortAsc
maxSec = 2 } else {
} m.sortColumn = sz.col
for i := 0; i <= maxSec; i++ { m.sortAsc = false
if m.zones.Get(fmt.Sprintf("section-%d", i)).InBounds(msg) {
m.switchSettingsSection(i)
return m, nil
} }
} m.refreshLive()
}
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
return m, nil 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 end := m.tableOffset + m.maxTableRows
if end > listLen { if end > len(m.sites) {
end = listLen end = len(m.sites)
} }
for i := m.tableOffset; i < end; i++ { 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.cursor = i
m.syncSelectedID() m.syncSelectedID()
if m.currentTab == tabMonitors { m.focusedPanel = panelMonitors
m.focusedPanel = panelMonitors if m.detailOpen {
if m.detailOpen { return m, m.loadDetailCmd(m.sites[m.cursor].ID)
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
} }
return m, nil return m, nil
} }
@@ -954,17 +1037,6 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m, nil 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) { func (m *Model) adjustCursor(_ int) {
m.clampCursor() m.clampCursor()
} }
@@ -992,37 +1064,5 @@ func (m *Model) submitForm() tea.Cmd {
} }
func (m Model) currentListLen() int { func (m Model) currentListLen() int {
switch m.currentTab { return len(m.sites)
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
} }
+1 -1
View File
@@ -266,7 +266,7 @@ func TestConfirmDelete_WritesOffUIGoroutine(t *testing.T) {
m := newTestModel(ms) m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 4, Name: "s"}}} m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 4, Name: "s"}}}
m.state = stateConfirmDelete m.state = stateConfirmDelete
m.deleteTab = 0 m.deleteKind = "site"
m.deleteID = 4 m.deleteID = 4
updated, cmd := (&m).handleConfirmDelete(keyMsg("y")) updated, cmd := (&m).handleConfirmDelete(keyMsg("y"))
+111 -160
View File
@@ -47,16 +47,13 @@ func (m Model) View() string {
switch m.state { switch m.state {
case stateConfirmDelete: case stateConfirmDelete:
kind := "monitor" kind := "monitor"
switch m.deleteTab { switch m.deleteKind {
case tabMaint: case "maint":
kind = "maintenance window" kind = "maintenance window"
case tabSettings: case "alert":
switch m.settingsSection { kind = "alert"
case sectionAlerts: case "user":
kind = "alert" kind = "user"
case sectionUsers:
kind = "user"
}
} }
msg := m.st.dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) msg := m.st.dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
hint := m.st.subtleStyle.Render("[y] Confirm [n] Cancel") hint := m.st.subtleStyle.Render("[y] Confirm [n] Cancel")
@@ -103,6 +100,10 @@ func (m Model) View() string {
return m.viewSLAPanel() return m.viewSLAPanel()
case stateAlertDetail: case stateAlertDetail:
return m.viewAlertDetailPanel() return m.viewAlertDetailPanel()
case stateSettings:
return m.viewSettingsOverlay()
case stateMaintDetail:
return m.viewMaintDetailPanel()
default: default:
return m.zones.Scan(m.viewDashboard()) return m.zones.Scan(m.viewDashboard())
} }
@@ -148,63 +149,68 @@ func (m Model) computeStats() dashboardStats {
return s 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 { func (m Model) viewDashboard() string {
stats := m.computeStats() stats := m.computeStats()
header := m.renderTabBar(stats) header := m.renderStatusLine(stats)
header = m.pulseIndicator() + " " + header
var content string content := m.viewMonitorsLayout()
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 = strings.TrimSpace(content) content = strings.TrimSpace(content)
footer := m.renderFooter(stats) footer := m.renderFooter(stats)
@@ -216,74 +222,17 @@ func (m Model) viewDashboard() string {
availHeight = 5 availHeight = 5
} }
divW := m.termWidth - chromePadH contentHeight := availHeight - lipgloss.Height(header) - lipgloss.Height(footer)
if divW < 40 {
divW = 40
}
tabDivider := m.st.subtleStyle.Render(strings.Repeat("─", divW))
contentHeight := availHeight - lipgloss.Height(header) - 1 - lipgloss.Height(footer)
if contentHeight < 1 { if contentHeight < 1 {
contentHeight = 1 contentHeight = 1
} }
paddedContent := lipgloss.NewStyle().Height(contentHeight).MaxHeight(contentHeight).Render(content) 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 { func (m Model) renderStatusLine(stats dashboardStats) string {
name string dot := m.st.subtleStyle.Render(" · ")
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")
}
upCount := stats.totalMonitors - stats.downCount - stats.lateCount upCount := stats.totalMonitors - stats.downCount - stats.lateCount
var upStr string var upStr string
@@ -294,9 +243,14 @@ func (m Model) renderFooter(stats dashboardStats) string {
} else { } else {
upStr = m.st.specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) 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 { 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 { if len(m.nodes) > 0 {
online := 0 online := 0
@@ -305,48 +259,45 @@ func (m Model) renderFooter(stats dashboardStats) string {
online++ online++
} }
} }
probeLabel := "probes" label := "probes"
if online == 1 { 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 var keys string
switch m.currentTab { if m.focusedPanel == panelMaint {
case tabMonitors: keys = "[n]New [x]End [d]Del [Esc]Back [S]Settings [T]Theme [q]Quit"
if m.focusedPanel == panelLogs { } else if m.focusedPanel == panelLogs {
keys = "[↑/↓]Scroll [Enter]Expand [l/Esc]Back [T]Theme [q]Quit" keys = "[↑/↓]Scroll [Enter]Expand [l/Esc]Back [S]Settings [T]Theme [q]Quit"
} else if m.detailOpen { } else if m.detailOpen {
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit" keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit"
} else { } else {
keys = "[/]Filter [i]Info [Enter]Detail [</>]Sort [r]Reverse [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit" keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [m]Maint [l]Logs [S]Settings [T]Theme [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"
} }
ver := m.st.subtleStyle.Render("v" + m.version) line := m.st.subtleStyle.Render(keys)
line := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver if m.filterText != "" {
if m.filterText != "" && m.currentTab == tabMonitors { line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s ", m.filterText)) + line
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
} }
divW := m.termWidth - chromePadH return "\n" + line
if divW < 40 {
divW = 40
}
return m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n" + line
} }
+193
View File
@@ -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
}
}
+87
View File
@@ -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")
}