feat(tui): single-dashboard layout with sidebar and overlay
Replace 3-tab navigation with a single monitoring dashboard. - Replace tab bar with minimal status line (pulse + counts + version) - Maintenance becomes a toggleable left sidebar (m key) - Compact list: active (●) and scheduled (○) windows - Enter opens full-screen detail with end/delete actions - Settings becomes a centered floating overlay (S key) - Alerts/Nodes/Users sub-sections with left/right navigation - Full CRUD support inside overlay with returnState tracking - Remove currentTab, switchTab, tab constants, tab click zones - Replace deleteTab with deleteKind string for cleaner dispatch - Separate settingsCursor/settingsOffset from main cursor - Panel focus: maint → monitors → logs → detail - Context-sensitive footer key hints per focused panel
This commit was merged in pull request #162.
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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