feat(tui): single-dashboard layout with sidebar and overlay #162
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
+12
-9
@@ -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
|
||||
|
||||
+316
-276
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+111
-160
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user