Compare commits
1 Commits
main
..
650e6c4b16
| Author | SHA1 | Date | |
|---|---|---|---|
|
650e6c4b16
|
Binary file not shown.
|
After Width: | Height: | Size: 312 KiB |
@@ -123,10 +123,9 @@ func (m *Model) refreshLive() {
|
|||||||
ordered = filterSites(ordered, m.filterText)
|
ordered = filterSites(ordered, m.filterText)
|
||||||
}
|
}
|
||||||
m.sites = ordered
|
m.sites = ordered
|
||||||
m.buildMaintSet()
|
|
||||||
m.refreshLogContent()
|
m.refreshLogContent()
|
||||||
|
|
||||||
if m.selectedID != 0 {
|
if m.currentTab == tabMonitors && m.selectedID != 0 {
|
||||||
for i, s := range m.sites {
|
for i, s := range m.sites {
|
||||||
if s.ID == m.selectedID {
|
if s.ID == m.selectedID {
|
||||||
m.cursor = i
|
m.cursor = i
|
||||||
@@ -138,7 +137,7 @@ func (m *Model) refreshLive() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) syncSelectedID() {
|
func (m *Model) syncSelectedID() {
|
||||||
if m.cursor < len(m.sites) {
|
if m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||||
m.selectedID = m.sites[m.cursor].ID
|
m.selectedID = m.sites[m.cursor].ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-32
@@ -49,13 +49,13 @@ func siteOrder(s models.Site) int {
|
|||||||
return 3
|
return 3
|
||||||
}
|
}
|
||||||
switch s.Status {
|
switch s.Status {
|
||||||
case models.StatusDown, models.StatusSSLExp:
|
case "DOWN", "SSL EXP":
|
||||||
return 0
|
return 0
|
||||||
case models.StatusStale:
|
case "STALE":
|
||||||
return 1
|
return 1
|
||||||
case models.StatusLate:
|
case "LATE":
|
||||||
return 1
|
return 1
|
||||||
case models.StatusPending:
|
case "PENDING":
|
||||||
return 3
|
return 3
|
||||||
default:
|
default:
|
||||||
return 2
|
return 2
|
||||||
@@ -104,13 +104,6 @@ func (m Model) fmtLatency(d time.Duration) string {
|
|||||||
return m.st.dangerStyle.Render(s)
|
return m.st.dangerStyle.Render(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) fmtUptimeMaint(statuses []bool, siteID int) string {
|
|
||||||
if m.isMonitorInMaintenance(siteID) {
|
|
||||||
return m.st.subtleStyle.Render("—")
|
|
||||||
}
|
|
||||||
return m.fmtUptime(statuses)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) fmtUptime(statuses []bool) string {
|
func (m Model) fmtUptime(statuses []bool) string {
|
||||||
if len(statuses) == 0 {
|
if len(statuses) == 0 {
|
||||||
return m.st.subtleStyle.Render("—")
|
return m.st.subtleStyle.Render("—")
|
||||||
@@ -162,27 +155,6 @@ func (m Model) fmtRetries(site models.Site) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) fmtStatusDot(status models.Status, paused bool, inMaint bool) string {
|
|
||||||
if paused {
|
|
||||||
return m.st.warnStyle.Render("◇")
|
|
||||||
}
|
|
||||||
if inMaint {
|
|
||||||
return m.st.maintStyle.Render("◼")
|
|
||||||
}
|
|
||||||
switch status {
|
|
||||||
case models.StatusDown, models.StatusSSLExp:
|
|
||||||
return m.st.dangerStyle.Render("▼")
|
|
||||||
case models.StatusLate:
|
|
||||||
return m.st.warnStyle.Render("◆")
|
|
||||||
case models.StatusStale:
|
|
||||||
return m.st.staleStyle.Render("◆")
|
|
||||||
case models.StatusPending:
|
|
||||||
return m.st.subtleStyle.Render("○")
|
|
||||||
default:
|
|
||||||
return m.st.specialStyle.Render("▲")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) fmtStatus(status models.Status, paused bool, inMaint bool) string {
|
func (m Model) fmtStatus(status models.Status, paused bool, inMaint bool) string {
|
||||||
if paused {
|
if paused {
|
||||||
return m.st.warnStyle.Render("◇ PAUSED")
|
return m.st.warnStyle.Render("◇ PAUSED")
|
||||||
|
|||||||
@@ -6,85 +6,6 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset int, focused bool) string {
|
|
||||||
if height <= 0 {
|
|
||||||
return m.titledPanel(title, content, width, focused)
|
|
||||||
}
|
|
||||||
|
|
||||||
borderColor := m.theme.Border
|
|
||||||
titleColor := m.theme.Muted
|
|
||||||
if focused {
|
|
||||||
borderColor = m.theme.Accent
|
|
||||||
titleColor = m.theme.Accent
|
|
||||||
}
|
|
||||||
|
|
||||||
bc := lipgloss.NewStyle().Foreground(borderColor)
|
|
||||||
tc := lipgloss.NewStyle().Foreground(titleColor).Bold(true)
|
|
||||||
|
|
||||||
innerW := width - 2
|
|
||||||
if innerW < 10 {
|
|
||||||
innerW = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
titleRendered := tc.Render(" " + title + " ")
|
|
||||||
titleLen := len([]rune(title)) + 2
|
|
||||||
fillLen := innerW - titleLen - 1
|
|
||||||
if fillLen < 0 {
|
|
||||||
fillLen = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
top := bc.Render("╭─") + titleRendered + bc.Render(strings.Repeat("─", fillLen)+"╮")
|
|
||||||
bottom := bc.Render("╰" + strings.Repeat("─", innerW) + "╯")
|
|
||||||
|
|
||||||
contentStyle := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW)
|
|
||||||
inner := contentStyle.Render(content)
|
|
||||||
contentLines := strings.Split(inner, "\n")
|
|
||||||
|
|
||||||
var footerLines []string
|
|
||||||
if footer != "" {
|
|
||||||
footerRendered := contentStyle.Render(footer)
|
|
||||||
footerLines = strings.Split(footerRendered, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyH := height - 2 - len(footerLines)
|
|
||||||
if bodyH < 1 {
|
|
||||||
bodyH = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if scrollOffset > len(contentLines)-bodyH {
|
|
||||||
scrollOffset = len(contentLines) - bodyH
|
|
||||||
}
|
|
||||||
if scrollOffset < 0 {
|
|
||||||
scrollOffset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
end := scrollOffset + bodyH
|
|
||||||
if end > len(contentLines) {
|
|
||||||
end = len(contentLines)
|
|
||||||
}
|
|
||||||
visible := contentLines[scrollOffset:end]
|
|
||||||
|
|
||||||
borderLine := func(line string) string {
|
|
||||||
return bc.Render("│") + line + strings.Repeat(" ", max(0, innerW-lipgloss.Width(line))) + bc.Render("│")
|
|
||||||
}
|
|
||||||
emptyLine := borderLine(strings.Repeat(" ", innerW))
|
|
||||||
|
|
||||||
var lines []string
|
|
||||||
lines = append(lines, top)
|
|
||||||
for _, line := range visible {
|
|
||||||
lines = append(lines, borderLine(line))
|
|
||||||
}
|
|
||||||
for len(lines) < height-1-len(footerLines) {
|
|
||||||
lines = append(lines, emptyLine)
|
|
||||||
}
|
|
||||||
for _, line := range footerLines {
|
|
||||||
lines = append(lines, borderLine(line))
|
|
||||||
}
|
|
||||||
lines = append(lines, bottom)
|
|
||||||
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) titledPanel(title, content string, width int, focused bool) string {
|
func (m Model) titledPanel(title, content string, width int, focused bool) string {
|
||||||
borderColor := m.theme.Border
|
borderColor := m.theme.Border
|
||||||
titleColor := m.theme.Muted
|
titleColor := m.theme.Muted
|
||||||
@@ -129,7 +50,3 @@ 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -156,8 +156,9 @@ func resolveSparklineIndex(x, sparkWidth, dataLen int) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor) string {
|
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor) string {
|
||||||
|
allSites := m.engine.GetAllSites()
|
||||||
var childStatuses [][]bool
|
var childStatuses [][]bool
|
||||||
for _, s := range m.sites {
|
for _, s := range allSites {
|
||||||
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
||||||
hist, _ := m.engine.GetHistory(s.ID)
|
hist, _ := m.engine.GetHistory(s.ID)
|
||||||
if len(hist.Statuses) > 0 {
|
if len(hist.Statuses) > 0 {
|
||||||
@@ -208,8 +209,9 @@ func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) groupUptime(groupID int) string {
|
func (m Model) groupUptime(groupID int) string {
|
||||||
|
allSites := m.engine.GetAllSites()
|
||||||
var allStatuses [][]bool
|
var allStatuses [][]bool
|
||||||
for _, s := range m.sites {
|
for _, s := range allSites {
|
||||||
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
||||||
hist, _ := m.engine.GetHistory(s.ID)
|
hist, _ := m.engine.GetHistory(s.ID)
|
||||||
if len(hist.Statuses) > 0 {
|
if len(hist.Statuses) > 0 {
|
||||||
|
|||||||
@@ -292,14 +292,10 @@ func (m Model) fmtAlertConfigFull(alert models.AlertConfig) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewAlertDetailPanel() string {
|
func (m Model) viewAlertDetailPanel() string {
|
||||||
idx := m.cursor
|
if m.cursor >= len(m.alerts) {
|
||||||
if m.returnState == stateSettings {
|
|
||||||
idx = m.settingsCursor
|
|
||||||
}
|
|
||||||
if idx >= len(m.alerts) {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
a := m.alerts[idx]
|
a := m.alerts[m.cursor]
|
||||||
h := m.engine.GetAlertHealth(a.ID)
|
h := m.engine.GetAlertHealth(a.ID)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -332,7 +328,7 @@ func (m Model) viewAlertDetailPanel() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(m.divider() + "\n")
|
b.WriteString(m.divider() + "\n")
|
||||||
b.WriteString(m.st.titleStyle.Render(" CONFIGURATION") + "\n")
|
b.WriteString(m.st.subtleStyle.Render(" CONFIGURATION") + "\n")
|
||||||
// Render through the same allowlist the backup export uses — this panel
|
// Render through the same allowlist the backup export uses — this panel
|
||||||
// ends up in screen shares and asciinema recordings. Keys are sorted so
|
// ends up in screen shares and asciinema recordings. Keys are sorted so
|
||||||
// rows don't reshuffle every render.
|
// rows don't reshuffle every render.
|
||||||
@@ -352,7 +348,7 @@ func (m Model) viewAlertDetailPanel() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(m.divider() + "\n")
|
b.WriteString(m.divider() + "\n")
|
||||||
b.WriteString(m.st.subtleStyle.Render(" [e] Edit [t] Test [q/Esc] Back"))
|
b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [t] Test"))
|
||||||
|
|
||||||
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
||||||
}
|
}
|
||||||
@@ -608,12 +604,7 @@ 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
|
||||||
if m.returnState == stateSettings {
|
m.state = stateDashboard
|
||||||
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)
|
||||||
|
|||||||
@@ -74,14 +74,14 @@ func (m Model) renderLogLine(entry models.LogEntry) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewLogsFullscreen() string {
|
func (m Model) viewLogsFullscreen() string {
|
||||||
header := " " + m.st.titleStyle.Render("Logs") + "\n" + m.divider()
|
header := m.st.subtleStyle.Render(" Logs") + "\n" + m.divider()
|
||||||
|
|
||||||
filterLabel := m.st.subtleStyle.Render("[f] All")
|
filterLabel := m.st.subtleStyle.Render("[f] All")
|
||||||
if m.logFilterImportant {
|
if m.logFilterImportant {
|
||||||
filterLabel = m.st.subtleStyle.Render("[f] Important only")
|
filterLabel = m.st.subtleStyle.Render("[f] Important only")
|
||||||
}
|
}
|
||||||
countLabel := m.st.subtleStyle.Render(fmt.Sprintf("%d/%d", m.logShown, m.logTotal))
|
countLabel := m.st.subtleStyle.Render(fmt.Sprintf("%d/%d", m.logShown, m.logTotal))
|
||||||
footer := m.divider() + "\n " + filterLabel + " " + countLabel + " " + m.st.subtleStyle.Render("[q/Esc] Back")
|
footer := m.divider() + "\n " + m.st.subtleStyle.Render("[Esc] Back") + " " + filterLabel + " " + countLabel
|
||||||
|
|
||||||
m.logViewport.Width = m.termWidth - chromePadH
|
m.logViewport.Width = m.termWidth - chromePadH
|
||||||
m.logViewport.Height = m.termHeight - 8
|
m.logViewport.Height = m.termHeight - 8
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string {
|
|||||||
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
|
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewLogsStrip(width, maxLines int) string {
|
func (m Model) viewLogsSidebar(width, maxLines int) string {
|
||||||
logs := m.engine.GetLogs()
|
logs := m.engine.GetLogs()
|
||||||
if len(logs) == 0 {
|
if len(logs) == 0 {
|
||||||
return m.st.subtleStyle.Render(" No logs yet")
|
return m.st.subtleStyle.Render(" No logs yet")
|
||||||
}
|
}
|
||||||
|
|
||||||
style := lipgloss.NewStyle().Width(width).MaxWidth(width)
|
sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width)
|
||||||
|
|
||||||
var all []string
|
var all []string
|
||||||
for _, entry := range logs {
|
for _, entry := range logs {
|
||||||
@@ -69,7 +69,7 @@ func (m Model) viewLogsStrip(width, maxLines int) string {
|
|||||||
}
|
}
|
||||||
visible := all[start:end]
|
visible := all[start:end]
|
||||||
|
|
||||||
return style.Render(strings.Join(visible, "\n"))
|
return sidebarStyle.Render(strings.Join(visible, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) scrollLogs(delta int) {
|
func (m *Model) scrollLogs(delta int) {
|
||||||
|
|||||||
+106
-13
@@ -20,37 +20,130 @@ type maintFormData struct {
|
|||||||
CustomHours string
|
CustomHours string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) isMonitorInMaintenance(monitorID int) bool {
|
func (m Model) fmtMaintStatus(mw models.MaintenanceWindow) string {
|
||||||
return m.maintSet[monitorID]
|
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) buildMaintSet() {
|
func (m Model) fmtMaintType(t string) string {
|
||||||
set := make(map[int]bool)
|
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()
|
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 {
|
for _, mw := range m.maintenanceWindows {
|
||||||
if mw.Type != "maintenance" {
|
if mw.Type != "maintenance" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
now := time.Now()
|
||||||
if mw.StartTime.After(now) {
|
if mw.StartTime.After(now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
|
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if mw.MonitorID == 0 {
|
if mw.MonitorID == 0 || mw.MonitorID == monitorID {
|
||||||
for _, s := range m.sites {
|
return true
|
||||||
set[s.ID] = true
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
set[mw.MonitorID] = true
|
|
||||||
for _, s := range m.sites {
|
for _, s := range m.sites {
|
||||||
if s.ParentID == mw.MonitorID {
|
if s.ID == monitorID && s.ParentID > 0 && mw.MonitorID == s.ParentID {
|
||||||
set[s.ID] = true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.maintSet = set
|
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 {
|
||||||
|
|||||||
@@ -8,19 +8,19 @@ import (
|
|||||||
|
|
||||||
func (m Model) viewNodesTab() string {
|
func (m Model) viewNodesTab() string {
|
||||||
if len(m.nodes) == 0 {
|
if len(m.nodes) == 0 {
|
||||||
return m.emptyState("No probe nodes connected.", "Probes auto-register on connection.")
|
return m.emptyState("No probe nodes connected.", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
var headers []string
|
var headers []string
|
||||||
var widths []int
|
var widths []int
|
||||||
if m.isWide() {
|
if m.isWide() {
|
||||||
headers = []string{"#", "NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}
|
headers = []string{"NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}
|
||||||
widths = []int{4, 24, 14, 16, 12, 10}
|
widths = []int{24, 14, 16, 12, 10}
|
||||||
} else {
|
} else {
|
||||||
headers = []string{"#", "NAME", "REGION", "SEEN", "VER", "STATUS"}
|
headers = []string{"NAME", "REGION", "SEEN", "VER", "STATUS"}
|
||||||
widths = []int{4, 16, 10, 10, 8, 8}
|
widths = []int{16, 10, 10, 8, 8}
|
||||||
}
|
}
|
||||||
nameW := widths[1]
|
nameW := widths[0]
|
||||||
|
|
||||||
tbl := m.renderTable(
|
tbl := m.renderTable(
|
||||||
headers,
|
headers,
|
||||||
@@ -43,7 +43,7 @@ func (m Model) viewNodesTab() string {
|
|||||||
version = m.st.subtleStyle.Render("—")
|
version = m.st.subtleStyle.Render("—")
|
||||||
}
|
}
|
||||||
status := m.fmtNodeStatus(node.LastSeen)
|
status := m.fmtNodeStatus(node.LastSeen)
|
||||||
rows = append(rows, []string{fmt.Sprintf("%d", i+1), name, region, lastSeen, version, status})
|
rows = append(rows, []string{name, region, lastSeen, version, status})
|
||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,10 +7,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) viewSettingsTab() string {
|
func (m Model) viewSettingsTab() string {
|
||||||
|
maxSections := 2
|
||||||
|
if m.isAdmin {
|
||||||
|
maxSections = 3
|
||||||
|
}
|
||||||
|
|
||||||
sections := []string{"Alerts", "Nodes"}
|
sections := []string{"Alerts", "Nodes"}
|
||||||
if m.isAdmin {
|
if m.isAdmin {
|
||||||
sections = append(sections, "Users")
|
sections = append(sections, "Users")
|
||||||
}
|
}
|
||||||
|
_ = maxSections
|
||||||
|
|
||||||
var tabs []string
|
var tabs []string
|
||||||
for i, name := range sections {
|
for i, name := range sections {
|
||||||
|
|||||||
+22
-56
@@ -34,9 +34,10 @@ type siteFormData struct {
|
|||||||
type colKey int
|
type colKey int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
colDot colKey = iota
|
colNum colKey = iota
|
||||||
colName
|
colName
|
||||||
colType
|
colType
|
||||||
|
colStatus
|
||||||
colLatency
|
colLatency
|
||||||
colUptime
|
colUptime
|
||||||
colHistory
|
colHistory
|
||||||
@@ -54,18 +55,17 @@ type columnDef struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var siteColumns = []columnDef{
|
var siteColumns = []columnDef{
|
||||||
{colDot, "", "", 3, 3, 0},
|
{colNum, "#", "#", 4, 4, 0},
|
||||||
{colName, "NAME", "NAME", 0, 0, 0},
|
{colName, "NAME", "NAME", 0, 0, 0},
|
||||||
{colType, "TYPE", "TYPE", 10, 8, 0},
|
{colType, "TYPE", "TYPE", 10, 8, mediumBreakpoint},
|
||||||
|
{colStatus, "STATUS", "STATUS", 10, 10, 0},
|
||||||
{colLatency, "LATENCY", "LAT", 10, 7, 0},
|
{colLatency, "LATENCY", "LAT", 10, 7, 0},
|
||||||
{colUptime, "UPTIME", "UP%", 8, 8, 0},
|
{colUptime, "UPTIME", "UP%", 8, 8, mediumBreakpoint},
|
||||||
{colHistory, "HISTORY", "HISTORY", 0, 0, 0},
|
{colHistory, "HISTORY", "HISTORY", 0, 0, mediumBreakpoint},
|
||||||
{colSSL, "SSL", "SSL", 7, 5, 0},
|
{colSSL, "SSL", "SSL", 7, 5, wideBreakpoint},
|
||||||
{colRetries, "RETRIES", "RT", 9, 5, 0},
|
{colRetries, "RETRIES", "RT", 9, 5, wideBreakpoint},
|
||||||
}
|
}
|
||||||
|
|
||||||
var columnDropOrder = []colKey{colHistory, colRetries, colSSL, colUptime, colType}
|
|
||||||
|
|
||||||
type tableLayout struct {
|
type tableLayout struct {
|
||||||
nameW, sparkW int
|
nameW, sparkW int
|
||||||
headers []string
|
headers []string
|
||||||
@@ -76,51 +76,17 @@ type tableLayout struct {
|
|||||||
func (m Model) computeLayout() tableLayout {
|
func (m Model) computeLayout() tableLayout {
|
||||||
wide := m.isWide()
|
wide := m.isWide()
|
||||||
|
|
||||||
cw := m.contentWidth
|
|
||||||
if cw == 0 {
|
|
||||||
cw = m.termWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
dropped := make(map[colKey]bool)
|
|
||||||
minNameW := 20
|
|
||||||
minSparkW := 12
|
|
||||||
|
|
||||||
for attempt := 0; attempt <= len(columnDropOrder); attempt++ {
|
|
||||||
var fixed int
|
|
||||||
var flexCount int
|
|
||||||
for _, c := range siteColumns {
|
|
||||||
if dropped[c.key] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
w := c.narrowW
|
|
||||||
if wide {
|
|
||||||
w = c.wideW
|
|
||||||
}
|
|
||||||
if w > 0 {
|
|
||||||
fixed += w
|
|
||||||
} else {
|
|
||||||
flexCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
numCols := len(siteColumns) - len(dropped)
|
|
||||||
borderOverhead := 2 + (numCols - 1)
|
|
||||||
avail := cw - chromePadH - 2 - borderOverhead - fixed
|
|
||||||
minFlex := minNameW
|
|
||||||
if flexCount > 1 {
|
|
||||||
minFlex += minSparkW
|
|
||||||
}
|
|
||||||
if avail >= minFlex || attempt >= len(columnDropOrder) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
dropped[columnDropOrder[attempt]] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var active []colKey
|
var active []colKey
|
||||||
var headers []string
|
var headers []string
|
||||||
var widths []int
|
var widths []int
|
||||||
var fixed int
|
var fixed int
|
||||||
|
|
||||||
|
cw := m.contentWidth
|
||||||
|
if cw == 0 {
|
||||||
|
cw = m.termWidth
|
||||||
|
}
|
||||||
for _, c := range siteColumns {
|
for _, c := range siteColumns {
|
||||||
if dropped[c.key] {
|
if c.minTerm > 0 && cw < c.minTerm {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
active = append(active, c.key)
|
active = append(active, c.key)
|
||||||
@@ -140,12 +106,12 @@ func (m Model) computeLayout() tableLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sortColMap := map[int]colKey{
|
sortColMap := map[int]colKey{
|
||||||
sortStatus: colDot,
|
sortStatus: colStatus,
|
||||||
sortName: colName,
|
sortName: colName,
|
||||||
sortLatency: colLatency,
|
sortLatency: colLatency,
|
||||||
}
|
}
|
||||||
sortableKeys := map[colKey]string{
|
sortableKeys := map[colKey]string{
|
||||||
colDot: "sort-status",
|
colStatus: "sort-status",
|
||||||
colName: "sort-name",
|
colName: "sort-name",
|
||||||
colLatency: "sort-latency",
|
colLatency: "sort-latency",
|
||||||
}
|
}
|
||||||
@@ -279,11 +245,11 @@ func (m Model) viewSitesTab() string {
|
|||||||
if site.Type == "group" {
|
if site.Type == "group" {
|
||||||
groupRows[i-start] = true
|
groupRows[i-start] = true
|
||||||
icon := typeIcon("group", m.collapsed[site.ID])
|
icon := typeIcon("group", m.collapsed[site.ID])
|
||||||
inMaint := m.isMonitorInMaintenance(site.ID)
|
|
||||||
cells := map[colKey]string{
|
cells := map[colKey]string{
|
||||||
colDot: m.fmtStatusDot(site.Status, site.Paused, inMaint),
|
colNum: strconv.Itoa(i + 1),
|
||||||
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)),
|
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)),
|
||||||
colType: "group",
|
colType: "group",
|
||||||
|
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
||||||
colLatency: m.st.subtleStyle.Render("—"),
|
colLatency: m.st.subtleStyle.Render("—"),
|
||||||
colUptime: m.groupUptime(site.ID),
|
colUptime: m.groupUptime(site.ID),
|
||||||
colHistory: m.groupSparkline(site.ID, sparkWidth, rowBg),
|
colHistory: m.groupSparkline(site.ID, sparkWidth, rowBg),
|
||||||
@@ -327,13 +293,13 @@ func (m Model) viewSitesTab() string {
|
|||||||
spark = m.latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg)
|
spark = m.latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg)
|
||||||
}
|
}
|
||||||
|
|
||||||
inMaint := m.isMonitorInMaintenance(site.ID)
|
|
||||||
cells := map[colKey]string{
|
cells := map[colKey]string{
|
||||||
colDot: m.fmtStatusDot(site.Status, site.Paused, inMaint),
|
colNum: strconv.Itoa(i + 1),
|
||||||
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), name),
|
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), name),
|
||||||
colType: typeIcon(site.Type, false) + " " + site.Type,
|
colType: typeIcon(site.Type, false) + " " + site.Type,
|
||||||
|
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
||||||
colLatency: m.fmtLatency(site.Latency),
|
colLatency: m.fmtLatency(site.Latency),
|
||||||
colUptime: m.fmtUptimeMaint(hist.Statuses, site.ID),
|
colUptime: m.fmtUptime(hist.Statuses),
|
||||||
colHistory: spark,
|
colHistory: spark,
|
||||||
colSSL: m.fmtSSL(site),
|
colSSL: m.fmtSSL(site),
|
||||||
colRetries: m.fmtRetries(site),
|
colRetries: m.fmtRetries(site),
|
||||||
|
|||||||
@@ -116,12 +116,7 @@ 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
|
||||||
if m.returnState == stateSettings {
|
m.state = stateDashboard
|
||||||
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)
|
||||||
|
|||||||
+51
-59
@@ -80,6 +80,14 @@ const (
|
|||||||
chromeFooter = 2 // footer: "\n" prefix + text line
|
chromeFooter = 2 // footer: "\n" prefix + text line
|
||||||
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
|
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
|
||||||
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
|
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
|
||||||
|
|
||||||
|
detailSparkWidth = 40
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tabMonitors = 0
|
||||||
|
tabMaint = 1
|
||||||
|
tabSettings = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -92,21 +100,6 @@ const (
|
|||||||
panelMonitors = 0
|
panelMonitors = 0
|
||||||
panelLogs = 1
|
panelLogs = 1
|
||||||
panelDetail = 2
|
panelDetail = 2
|
||||||
panelMaint = 3
|
|
||||||
)
|
|
||||||
|
|
||||||
type bottomPanel int
|
|
||||||
|
|
||||||
const (
|
|
||||||
bottomNone bottomPanel = iota
|
|
||||||
bottomLogs
|
|
||||||
bottomMaint
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
detailDefault = 0
|
|
||||||
detailSLA = 1
|
|
||||||
detailHistory = 2
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -119,25 +112,24 @@ const (
|
|||||||
type sessionState int
|
type sessionState int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
stateDashboard sessionState = 0
|
stateDashboard sessionState = iota
|
||||||
stateLogs sessionState = 1
|
stateLogs
|
||||||
stateDetailFullscreen sessionState = 2
|
stateUsers
|
||||||
stateAlertDetail sessionState = 3
|
stateDetail
|
||||||
stateFormSite sessionState = 4
|
stateAlertDetail
|
||||||
stateFormAlert sessionState = 5
|
stateFormSite
|
||||||
stateFormUser sessionState = 6
|
stateFormAlert
|
||||||
stateConfirmDelete sessionState = 7
|
stateFormUser
|
||||||
stateFormMaint sessionState = 8
|
stateConfirmDelete
|
||||||
stateSettings sessionState = 11
|
stateFormMaint
|
||||||
stateMaintDetail sessionState = 12
|
stateHistory
|
||||||
|
stateSLA
|
||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
state sessionState
|
state sessionState
|
||||||
returnState sessionState
|
currentTab int
|
||||||
settingsSection int
|
settingsSection int
|
||||||
settingsCursor int
|
|
||||||
settingsOffset int
|
|
||||||
cursor int
|
cursor int
|
||||||
selectedID int
|
selectedID int
|
||||||
sortColumn int
|
sortColumn int
|
||||||
@@ -148,7 +140,6 @@ type Model struct {
|
|||||||
termHeight int
|
termHeight int
|
||||||
contentWidth int
|
contentWidth int
|
||||||
focusedPanel int
|
focusedPanel int
|
||||||
detailMode int
|
|
||||||
logScrollOffset int
|
logScrollOffset int
|
||||||
editID int
|
editID int
|
||||||
editToken string
|
editToken string
|
||||||
@@ -165,25 +156,24 @@ type Model struct {
|
|||||||
logTotal int
|
logTotal int
|
||||||
logShown int
|
logShown int
|
||||||
|
|
||||||
|
historyViewport viewport.Model
|
||||||
historyChanges []models.StateChange
|
historyChanges []models.StateChange
|
||||||
historySiteName string
|
historySiteName string
|
||||||
historySiteID int
|
historySiteID int
|
||||||
|
|
||||||
slaReport monitor.SLAReport
|
slaViewport viewport.Model
|
||||||
slaDailyBreakdown []monitor.DayReport
|
slaReport monitor.SLAReport
|
||||||
slaSiteName string
|
slaDailyBreakdown []monitor.DayReport
|
||||||
slaSiteID int
|
slaSiteName string
|
||||||
slaPeriodIdx int
|
slaSiteID int
|
||||||
detailScrollOffset int
|
slaPeriodIdx int
|
||||||
|
|
||||||
isAdmin bool
|
isAdmin bool
|
||||||
zones *zone.Manager
|
zones *zone.Manager
|
||||||
|
|
||||||
deleteID int
|
deleteID int
|
||||||
deleteName string
|
deleteName string
|
||||||
deleteKind string
|
deleteTab int
|
||||||
|
|
||||||
maintDetailID int
|
|
||||||
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
collapsed map[int]bool
|
collapsed map[int]bool
|
||||||
@@ -207,17 +197,18 @@ type Model struct {
|
|||||||
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
|
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
|
||||||
tabSeq int // seq of the newest issued tab-data load
|
tabSeq int // seq of the newest issued tab-data load
|
||||||
|
|
||||||
maintSet map[int]bool
|
logsOpen bool
|
||||||
bottomPanel bottomPanel
|
|
||||||
detailOpen bool
|
detailOpen bool
|
||||||
maintCursor int
|
|
||||||
detailChanges []models.StateChange
|
detailChanges []models.StateChange
|
||||||
detailChangesSiteID int
|
detailChangesSiteID int
|
||||||
detailDailyDays []monitor.DayReport
|
detailDailyDays []monitor.DayReport
|
||||||
|
detailViewport viewport.Model
|
||||||
|
|
||||||
filterMode bool
|
filterMode bool
|
||||||
filterText string
|
filterText string
|
||||||
|
|
||||||
|
sparkTooltipIdx int // clicked sparkline data index, -1 = none
|
||||||
|
|
||||||
// demoMode renders a stable status dot instead of the animated pulse so
|
// demoMode renders a stable status dot instead of the animated pulse so
|
||||||
// screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1.
|
// screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1.
|
||||||
demoMode bool
|
demoMode bool
|
||||||
@@ -244,23 +235,24 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
|
|||||||
detailPref, _ := s.GetPreference(ctx, "detail_open")
|
detailPref, _ := s.GetPreference(ctx, "detail_open")
|
||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
state: stateDashboard,
|
state: stateDashboard,
|
||||||
logViewport: vpLogs,
|
logViewport: vpLogs,
|
||||||
maxTableRows: 5,
|
maxTableRows: 5,
|
||||||
isAdmin: isAdmin,
|
isAdmin: isAdmin,
|
||||||
store: s,
|
store: s,
|
||||||
engine: eng,
|
engine: eng,
|
||||||
zones: z,
|
zones: z,
|
||||||
pulseSpring: spring,
|
pulseSpring: spring,
|
||||||
collapsed: collapsed,
|
collapsed: collapsed,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
themeIndex: themeIdx,
|
themeIndex: themeIdx,
|
||||||
st: newStyles(theme),
|
st: newStyles(theme),
|
||||||
bottomPanel: bottomLogs,
|
logsOpen: true,
|
||||||
detailOpen: detailPref == "true",
|
detailOpen: detailPref == "true",
|
||||||
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
||||||
version: version,
|
version: version,
|
||||||
|
sparkTooltipIdx: -1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+493
-473
File diff suppressed because it is too large
Load Diff
+21
-159
@@ -118,10 +118,11 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
|
|||||||
m := newTestModel(ms)
|
m := newTestModel(ms)
|
||||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}}
|
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}}
|
||||||
m.cursor = 0
|
m.cursor = 0
|
||||||
m.detailOpen = true
|
m.state = stateDetail
|
||||||
m.termWidth = 120
|
m.termWidth = 120
|
||||||
m.termHeight = 40
|
m.termHeight = 40
|
||||||
|
|
||||||
|
// Entering detail dispatches the load Cmd.
|
||||||
cmd := m.loadDetailCmd(1)
|
cmd := m.loadDetailCmd(1)
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
t.Fatal("loadDetailCmd returned nil")
|
t.Fatal("loadDetailCmd returned nil")
|
||||||
@@ -135,14 +136,16 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
|
|||||||
t.Fatalf("expected exactly 1 store hit from the load Cmd, got %d", ms.stateChangeCalls)
|
t.Fatalf("expected exactly 1 store hit from the load Cmd, got %d", ms.stateChangeCalls)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply the msg through Update (caches into the model).
|
||||||
updated, _ := m.Update(dd)
|
updated, _ := m.Update(dd)
|
||||||
m = updated.(Model)
|
m = updated.(Model)
|
||||||
if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 {
|
if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 {
|
||||||
t.Fatalf("detail changes not cached: id=%d n=%d", m.detailChangesSiteID, len(m.detailChanges))
|
t.Fatalf("detail changes not cached: id=%d n=%d", m.detailChangesSiteID, len(m.detailChanges))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render the detail panel several times — it must read the cache, not the DB.
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
_ = m.viewDetailInline(80, 30)
|
_ = m.viewDetailPanel()
|
||||||
}
|
}
|
||||||
if ms.stateChangeCalls != 1 {
|
if ms.stateChangeCalls != 1 {
|
||||||
t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls)
|
t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls)
|
||||||
@@ -199,16 +202,16 @@ func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) {
|
|||||||
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
|
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
|
||||||
m := newTestModel(ms)
|
m := newTestModel(ms)
|
||||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}}
|
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}}
|
||||||
m.detailOpen = true
|
m.state = stateDetail
|
||||||
m.termWidth, m.termHeight = 120, 40
|
m.termWidth, m.termHeight = 120, 40
|
||||||
|
|
||||||
updated, cmd := (&m).handleDashboardKey(keyMsg("h"))
|
updated, cmd := (&m).handleDetailKey(keyMsg("h"))
|
||||||
if ms.stateChangeCalls != 0 {
|
if ms.stateChangeCalls != 0 {
|
||||||
t.Fatal("history keypress hit the store synchronously in Update")
|
t.Fatal("history keypress hit the store synchronously in Update")
|
||||||
}
|
}
|
||||||
got := updated.(*Model)
|
got := updated.(*Model)
|
||||||
if got.detailMode != detailHistory || got.historySiteID != 7 {
|
if got.state != stateHistory || got.historySiteID != 7 {
|
||||||
t.Fatalf("history mode not set: mode=%v siteID=%d", got.detailMode, got.historySiteID)
|
t.Fatalf("history view not opened: state=%v siteID=%d", got.state, got.historySiteID)
|
||||||
}
|
}
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
t.Fatal("expected a history load Cmd")
|
t.Fatal("expected a history load Cmd")
|
||||||
@@ -226,6 +229,7 @@ func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) {
|
|||||||
t.Fatal("history reply not folded into the model")
|
t.Fatal("history reply not folded into the model")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A reply for a previously opened site must not clobber the current one.
|
||||||
m2.historySiteID = 9
|
m2.historySiteID = 9
|
||||||
stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil})
|
stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil})
|
||||||
if m3 := stale.(Model); len(m3.historyChanges) != 1 {
|
if m3 := stale.(Model); len(m3.historyChanges) != 1 {
|
||||||
@@ -237,15 +241,20 @@ func TestSLAData_DropsStaleReply(t *testing.T) {
|
|||||||
m := newTestModel(&tuiMockStore{})
|
m := newTestModel(&tuiMockStore{})
|
||||||
m.termWidth, m.termHeight = 120, 40
|
m.termWidth, m.termHeight = 120, 40
|
||||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 3}, SiteState: models.SiteState{Status: "UP"}}}
|
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 3}, SiteState: models.SiteState{Status: "UP"}}}
|
||||||
m.detailOpen = true
|
|
||||||
m.slaSiteID = 3
|
|
||||||
m.slaPeriodIdx = 2
|
|
||||||
|
|
||||||
|
if cmd := (&m).openSLAView(m.sites[0]); cmd == nil {
|
||||||
|
t.Fatal("openSLAView should return a load Cmd")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply for a different period than currently selected → dropped.
|
||||||
|
// (slaDataMsg routes through a pointer-receiver handler, so Update
|
||||||
|
// returns *Model on this path.)
|
||||||
updated, _ := m.Update(slaDataMsg{siteID: 3, periodIdx: 0})
|
updated, _ := m.Update(slaDataMsg{siteID: 3, periodIdx: 0})
|
||||||
if mm := updated.(*Model); mm.slaDailyBreakdown != nil {
|
if mm := updated.(*Model); mm.slaDailyBreakdown != nil {
|
||||||
t.Error("stale SLA reply (old period) was applied")
|
t.Error("stale SLA reply (old period) was applied")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matching reply → report computed.
|
||||||
updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx})
|
updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx})
|
||||||
if mm := updated.(*Model); mm.slaDailyBreakdown == nil {
|
if mm := updated.(*Model); mm.slaDailyBreakdown == nil {
|
||||||
t.Error("matching SLA reply was not applied")
|
t.Error("matching SLA reply was not applied")
|
||||||
@@ -257,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.deleteKind = "site"
|
m.deleteTab = 0
|
||||||
m.deleteID = 4
|
m.deleteID = 4
|
||||||
|
|
||||||
updated, cmd := (&m).handleConfirmDelete(keyMsg("y"))
|
updated, cmd := (&m).handleConfirmDelete(keyMsg("y"))
|
||||||
@@ -300,81 +309,17 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnterNarrowTerminal_OpensFullscreen(t *testing.T) {
|
|
||||||
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
|
|
||||||
m := newTestModel(ms)
|
|
||||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "UP"}}}
|
|
||||||
m.termWidth = 80 // narrow, below wideBreakpoint (120)
|
|
||||||
m.termHeight = 40
|
|
||||||
m.focusedPanel = panelMonitors
|
|
||||||
|
|
||||||
updated, cmd := (&m).handleDashboardKey(keyMsg("enter"))
|
|
||||||
got := updated.(*Model)
|
|
||||||
if got.state != stateDetailFullscreen {
|
|
||||||
t.Fatalf("expected stateDetailFullscreen, got %d", got.state)
|
|
||||||
}
|
|
||||||
if cmd == nil {
|
|
||||||
t.Fatal("expected a detail load Cmd")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnterWideTerminal_TogglesSidebar(t *testing.T) {
|
|
||||||
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
|
|
||||||
m := newTestModel(ms)
|
|
||||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "UP"}}}
|
|
||||||
m.termWidth = 140 // wide, above wideBreakpoint (120)
|
|
||||||
m.termHeight = 40
|
|
||||||
m.focusedPanel = panelMonitors
|
|
||||||
|
|
||||||
updated, _ := (&m).handleDashboardKey(keyMsg("enter"))
|
|
||||||
got := updated.(*Model)
|
|
||||||
if got.state != stateDashboard {
|
|
||||||
t.Fatalf("expected stateDashboard, got %d", got.state)
|
|
||||||
}
|
|
||||||
if !got.detailOpen {
|
|
||||||
t.Fatal("expected detailOpen to be true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetailFullscreen_EscReturnsToDashboard(t *testing.T) {
|
|
||||||
m := newTestModel(&tuiMockStore{})
|
|
||||||
m.state = stateDetailFullscreen
|
|
||||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}}}
|
|
||||||
|
|
||||||
updated, _ := (&m).handleDetailFullscreen(tea.KeyMsg{Type: tea.KeyEsc})
|
|
||||||
got := updated.(*Model)
|
|
||||||
if got.state != stateDashboard {
|
|
||||||
t.Fatalf("expected stateDashboard after Esc, got %d", got.state)
|
|
||||||
}
|
|
||||||
if got.focusedPanel != panelMonitors {
|
|
||||||
t.Fatalf("expected panelMonitors focus, got %d", got.focusedPanel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetailRefreshCmd_FiresForFullscreen(t *testing.T) {
|
|
||||||
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
|
|
||||||
m := newTestModel(ms)
|
|
||||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
|
|
||||||
m.state = stateDetailFullscreen
|
|
||||||
m.detailOpen = false
|
|
||||||
|
|
||||||
cmd := (&m).detailRefreshCmd()
|
|
||||||
if cmd == nil {
|
|
||||||
t.Fatal("detailRefreshCmd should fire for stateDetailFullscreen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
|
func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
|
||||||
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
|
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
|
||||||
m := newTestModel(ms)
|
m := newTestModel(ms)
|
||||||
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
|
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
|
||||||
|
|
||||||
m.detailOpen = false
|
m.state = stateDashboard
|
||||||
if (&m).detailRefreshCmd() != nil {
|
if (&m).detailRefreshCmd() != nil {
|
||||||
t.Error("refresh Cmd issued outside the detail view")
|
t.Error("refresh Cmd issued outside the detail view")
|
||||||
}
|
}
|
||||||
|
|
||||||
m.detailOpen = true
|
m.state = stateDetail
|
||||||
cmd := (&m).detailRefreshCmd()
|
cmd := (&m).detailRefreshCmd()
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
t.Fatal("open detail panel should refresh on the tab-data cadence")
|
t.Fatal("open detail panel should refresh on the tab-data cadence")
|
||||||
@@ -389,86 +334,3 @@ func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
|
|||||||
t.Error("refresh Cmd issued for an out-of-range cursor")
|
t.Error("refresh Cmd issued for an out-of-range cursor")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildMaintSet_GlobalWindow(t *testing.T) {
|
|
||||||
m := newTestModel(&tuiMockStore{})
|
|
||||||
m.sites = []models.Site{
|
|
||||||
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
|
|
||||||
{SiteConfig: models.SiteConfig{ID: 2, Name: "b"}},
|
|
||||||
}
|
|
||||||
m.maintenanceWindows = []models.MaintenanceWindow{
|
|
||||||
{ID: 1, MonitorID: 0, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
|
|
||||||
}
|
|
||||||
m.buildMaintSet()
|
|
||||||
if !m.maintSet[1] || !m.maintSet[2] {
|
|
||||||
t.Error("global maint window should mark all monitors")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMaintSet_TargetedWindow(t *testing.T) {
|
|
||||||
m := newTestModel(&tuiMockStore{})
|
|
||||||
m.sites = []models.Site{
|
|
||||||
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
|
|
||||||
{SiteConfig: models.SiteConfig{ID: 2, Name: "b"}},
|
|
||||||
}
|
|
||||||
m.maintenanceWindows = []models.MaintenanceWindow{
|
|
||||||
{ID: 1, MonitorID: 1, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
|
|
||||||
}
|
|
||||||
m.buildMaintSet()
|
|
||||||
if !m.maintSet[1] {
|
|
||||||
t.Error("targeted window should mark monitor 1")
|
|
||||||
}
|
|
||||||
if m.maintSet[2] {
|
|
||||||
t.Error("targeted window should NOT mark monitor 2")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMaintSet_GroupPropagates(t *testing.T) {
|
|
||||||
m := newTestModel(&tuiMockStore{})
|
|
||||||
m.sites = []models.Site{
|
|
||||||
{SiteConfig: models.SiteConfig{ID: 10, Name: "group", Type: "group"}},
|
|
||||||
{SiteConfig: models.SiteConfig{ID: 11, Name: "child1", ParentID: 10}},
|
|
||||||
{SiteConfig: models.SiteConfig{ID: 12, Name: "child2", ParentID: 10}},
|
|
||||||
{SiteConfig: models.SiteConfig{ID: 20, Name: "other"}},
|
|
||||||
}
|
|
||||||
m.maintenanceWindows = []models.MaintenanceWindow{
|
|
||||||
{ID: 1, MonitorID: 10, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
|
|
||||||
}
|
|
||||||
m.buildMaintSet()
|
|
||||||
if !m.maintSet[10] || !m.maintSet[11] || !m.maintSet[12] {
|
|
||||||
t.Error("group maint window should mark group + children")
|
|
||||||
}
|
|
||||||
if m.maintSet[20] {
|
|
||||||
t.Error("unrelated monitor should NOT be marked")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMaintSet_ExpiredIgnored(t *testing.T) {
|
|
||||||
m := newTestModel(&tuiMockStore{})
|
|
||||||
m.sites = []models.Site{
|
|
||||||
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
|
|
||||||
}
|
|
||||||
m.maintenanceWindows = []models.MaintenanceWindow{
|
|
||||||
{ID: 1, MonitorID: 1, Type: "maintenance",
|
|
||||||
StartTime: time.Now().Add(-2 * time.Hour),
|
|
||||||
EndTime: time.Now().Add(-1 * time.Hour)},
|
|
||||||
}
|
|
||||||
m.buildMaintSet()
|
|
||||||
if m.maintSet[1] {
|
|
||||||
t.Error("expired maint window should not mark monitor")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildMaintSet_IncidentIgnored(t *testing.T) {
|
|
||||||
m := newTestModel(&tuiMockStore{})
|
|
||||||
m.sites = []models.Site{
|
|
||||||
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
|
|
||||||
}
|
|
||||||
m.maintenanceWindows = []models.MaintenanceWindow{
|
|
||||||
{ID: 1, MonitorID: 0, Type: "incident", StartTime: time.Now().Add(-time.Hour)},
|
|
||||||
}
|
|
||||||
m.buildMaintSet()
|
|
||||||
if m.maintSet[1] {
|
|
||||||
t.Error("incident windows should not mark monitors as in maintenance")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+162
-135
@@ -47,12 +47,12 @@ func (m Model) View() string {
|
|||||||
switch m.state {
|
switch m.state {
|
||||||
case stateConfirmDelete:
|
case stateConfirmDelete:
|
||||||
kind := "monitor"
|
kind := "monitor"
|
||||||
switch m.deleteKind {
|
switch m.deleteTab {
|
||||||
case "maint":
|
case 1:
|
||||||
kind = "maintenance window"
|
|
||||||
case "alert":
|
|
||||||
kind = "alert"
|
kind = "alert"
|
||||||
case "user":
|
case 4:
|
||||||
|
kind = "maintenance window"
|
||||||
|
case 5:
|
||||||
kind = "user"
|
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))
|
||||||
@@ -90,16 +90,16 @@ func (m Model) View() string {
|
|||||||
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
|
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
case stateDetail:
|
||||||
|
return m.zones.Scan(m.viewDetailPanel())
|
||||||
case stateLogs:
|
case stateLogs:
|
||||||
return m.viewLogsFullscreen()
|
return m.viewLogsFullscreen()
|
||||||
case stateDetailFullscreen:
|
case stateHistory:
|
||||||
return m.viewDetailFullscreen()
|
return m.viewHistoryPanel()
|
||||||
|
case stateSLA:
|
||||||
|
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())
|
||||||
}
|
}
|
||||||
@@ -145,67 +145,61 @@ func (m Model) computeStats() dashboardStats {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewMonitorsLayout() string {
|
|
||||||
availW := m.termWidth - chromePadH
|
|
||||||
wide := m.termWidth >= wideBreakpoint
|
|
||||||
|
|
||||||
showDetail := m.detailOpen && wide
|
|
||||||
|
|
||||||
var detailW, monW int
|
|
||||||
if showDetail {
|
|
||||||
monW = availW * 60 / 100
|
|
||||||
detailW = availW - monW
|
|
||||||
} else {
|
|
||||||
monW = availW
|
|
||||||
}
|
|
||||||
|
|
||||||
m.contentWidth = monW - 2
|
|
||||||
|
|
||||||
monTargetH := m.maxTableRows + 5
|
|
||||||
monitors := m.viewSitesTab()
|
|
||||||
monPanel := m.zones.Mark("panel-monitors", m.titledPanelH("Monitors", monitors, "", monW, monTargetH, 0, m.focusedPanel == panelMonitors))
|
|
||||||
|
|
||||||
var topParts []string
|
|
||||||
topParts = append(topParts, monPanel)
|
|
||||||
if showDetail {
|
|
||||||
title := ""
|
|
||||||
if m.cursor < len(m.sites) {
|
|
||||||
title = m.sites[m.cursor].Name
|
|
||||||
}
|
|
||||||
switch m.detailMode {
|
|
||||||
case detailSLA:
|
|
||||||
title = "SLA · " + title
|
|
||||||
case detailHistory:
|
|
||||||
title = "History · " + title
|
|
||||||
}
|
|
||||||
monHeight := lipgloss.Height(monPanel)
|
|
||||||
detail := m.viewDetailInline(detailW-2, monHeight)
|
|
||||||
footer := m.detailFooter(detailW - 2)
|
|
||||||
detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, footer, detailW, monHeight, m.detailScrollOffset, m.focusedPanel == panelDetail))
|
|
||||||
topParts = append(topParts, detailPanel)
|
|
||||||
}
|
|
||||||
|
|
||||||
top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...)
|
|
||||||
|
|
||||||
switch m.bottomPanel {
|
|
||||||
case bottomLogs:
|
|
||||||
logContent := m.viewLogsStrip(availW-2, logsStripHeight-2)
|
|
||||||
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, availW, m.focusedPanel == panelLogs))
|
|
||||||
return top + "\n" + logPanel
|
|
||||||
case bottomMaint:
|
|
||||||
maintContent := m.viewMaintStrip(availW-2, logsStripHeight-2)
|
|
||||||
maintPanel := m.zones.Mark("panel-maint", m.titledPanel("Maint", maintContent, availW, m.focusedPanel == panelMaint))
|
|
||||||
return top + "\n" + maintPanel
|
|
||||||
}
|
|
||||||
return top
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) viewDashboard() string {
|
func (m Model) viewDashboard() string {
|
||||||
stats := m.computeStats()
|
stats := m.computeStats()
|
||||||
|
|
||||||
header := m.renderStatusLine(stats)
|
header := m.renderTabBar(stats)
|
||||||
|
header = m.pulseIndicator() + " " + header
|
||||||
|
|
||||||
content := m.viewMonitorsLayout()
|
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
|
||||||
|
content = m.viewMaintTab()
|
||||||
|
case tabSettings:
|
||||||
|
m.contentWidth = m.termWidth
|
||||||
|
content = m.viewSettingsTab()
|
||||||
|
}
|
||||||
|
|
||||||
content = strings.TrimSpace(content)
|
content = strings.TrimSpace(content)
|
||||||
footer := m.renderFooter(stats)
|
footer := m.renderFooter(stats)
|
||||||
@@ -217,17 +211,74 @@ func (m Model) viewDashboard() string {
|
|||||||
availHeight = 5
|
availHeight = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
contentHeight := availHeight - lipgloss.Height(header) - lipgloss.Height(footer)
|
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)
|
||||||
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, paddedContent, footer))
|
return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, tabDivider, paddedContent, footer))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderStatusLine(stats dashboardStats) string {
|
type tabEntry struct {
|
||||||
dot := m.st.subtleStyle.Render(" · ")
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
upCount := stats.totalMonitors - stats.downCount - stats.lateCount
|
upCount := stats.totalMonitors - stats.downCount - stats.lateCount
|
||||||
var upStr string
|
var upStr string
|
||||||
@@ -238,14 +289,9 @@ func (m Model) renderStatusLine(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 {
|
||||||
parts = append(parts, m.st.warnStyle.Render(fmt.Sprintf("%d late", stats.lateCount)))
|
statusParts = append(statusParts, 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
|
||||||
@@ -254,67 +300,48 @@ func (m Model) renderStatusLine(stats dashboardStats) string {
|
|||||||
online++
|
online++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
label := "probes"
|
probeLabel := "probes"
|
||||||
if online == 1 {
|
if online == 1 {
|
||||||
label = "probe"
|
probeLabel = "probe"
|
||||||
}
|
}
|
||||||
parts = append(parts, fmt.Sprintf("%d %s", online, label))
|
statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel))
|
||||||
|
}
|
||||||
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
left := strings.Join(parts, dot)
|
|
||||||
ver := m.st.subtleStyle.Render("v" + m.version)
|
ver := m.st.subtleStyle.Render("v" + m.version)
|
||||||
|
line := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
|
||||||
padW := m.termWidth - chromePadH - lipgloss.Width(left) - lipgloss.Width(ver)
|
if m.filterText != "" && m.currentTab == tabMonitors {
|
||||||
if padW < 2 {
|
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
|
||||||
padW = 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return left + strings.Repeat(" ", padW) + ver
|
divW := m.termWidth - chromePadH
|
||||||
}
|
if divW < 40 {
|
||||||
|
divW = 40
|
||||||
func (m Model) hotkey(key, desc string) string {
|
}
|
||||||
k := lipgloss.NewStyle().Foreground(m.theme.Accent).Render(key)
|
return m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n" + line
|
||||||
d := m.st.subtleStyle.Render(desc)
|
|
||||||
return k + " " + d
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderFooter(_ dashboardStats) string {
|
|
||||||
dot := m.st.subtleStyle.Render(" · ")
|
|
||||||
|
|
||||||
if m.filterMode {
|
|
||||||
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
|
|
||||||
keys := m.hotkey("Enter", "Apply") + dot + m.hotkey("Esc", "Clear")
|
|
||||||
return "\n" + m.st.titleStyle.Render("/") + " " + m.filterText + cursor + " " + keys
|
|
||||||
}
|
|
||||||
|
|
||||||
var parts []string
|
|
||||||
if m.focusedPanel == panelMaint {
|
|
||||||
parts = []string{m.hotkey("n", "New"), m.hotkey("Enter", "Detail"), m.hotkey("x", "End"), m.hotkey("d", "Del"), m.hotkey("m/Esc", "Back")}
|
|
||||||
} else if m.focusedPanel == panelLogs {
|
|
||||||
parts = []string{m.hotkey("↑/↓", "Scroll"), m.hotkey("Enter", "Expand"), m.hotkey("l/Esc", "Back")}
|
|
||||||
} else if m.detailOpen && m.detailMode == detailSLA {
|
|
||||||
parts = []string{m.hotkey("1-4", "Period"), m.hotkey("Esc", "Back")}
|
|
||||||
} else if m.detailOpen && m.detailMode == detailHistory {
|
|
||||||
parts = []string{m.hotkey("Esc", "Back")}
|
|
||||||
} else if m.detailOpen {
|
|
||||||
parts = []string{m.hotkey("Enter", "Close"), m.hotkey("h", "History"), m.hotkey("s", "SLA"), m.hotkey("e", "Edit")}
|
|
||||||
} else {
|
|
||||||
parts = []string{m.hotkey("/", "Filter"), m.hotkey("Enter", "Detail")}
|
|
||||||
if m.cursor < len(m.sites) && m.sites[m.cursor].Type == "group" {
|
|
||||||
if m.collapsed[m.sites[m.cursor].ID] {
|
|
||||||
parts = append(parts, m.hotkey("Space", "Expand"))
|
|
||||||
} else {
|
|
||||||
parts = append(parts, m.hotkey("Space", "Collapse"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts = append(parts, m.hotkey("n", "New"), m.hotkey("e", "Edit"), m.hotkey("d", "Del"))
|
|
||||||
}
|
|
||||||
parts = append(parts, m.hotkey("m", "Maint"), m.hotkey("l", "Logs"), m.hotkey("S", "Settings"), m.hotkey("T", "Theme"), m.hotkey("q", "Quit"))
|
|
||||||
|
|
||||||
line := strings.Join(parts, dot)
|
|
||||||
if m.filterText != "" {
|
|
||||||
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s ", m.filterText)) + line
|
|
||||||
}
|
|
||||||
|
|
||||||
return "\n" + line
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Model) viewDetailPanel() string {
|
||||||
|
if m.cursor >= len(m.sites) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
site := m.sites[m.cursor]
|
||||||
|
hist, _ := m.engine.GetHistory(site.ID)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
totalW := m.termWidth - chromePadH
|
||||||
|
|
||||||
|
var breadcrumb string
|
||||||
|
if site.ParentID > 0 {
|
||||||
|
for _, s := range m.sites {
|
||||||
|
if s.ID == site.ParentID {
|
||||||
|
breadcrumb = m.st.subtleStyle.Render(" Monitors > "+s.Name+" > ") + m.st.titleStyle.Render(site.Name)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if breadcrumb == "" {
|
||||||
|
breadcrumb = m.st.subtleStyle.Render(" Monitors > ") + m.st.titleStyle.Render(site.Name)
|
||||||
|
}
|
||||||
|
b.WriteString(breadcrumb + "\n")
|
||||||
|
b.WriteString(m.divider() + "\n")
|
||||||
|
|
||||||
|
// Two-column layout for key info
|
||||||
|
colW := (totalW - 4) / 2
|
||||||
|
if colW < 30 {
|
||||||
|
colW = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
row := func(label, value string) string {
|
||||||
|
return fmt.Sprintf(" %-16s %s", m.st.subtleStyle.Render(label), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
divW := totalW - 4
|
||||||
|
if divW < 20 {
|
||||||
|
divW = 20
|
||||||
|
}
|
||||||
|
sectionDiv := m.st.subtleStyle.Render(strings.Repeat("─", divW))
|
||||||
|
sectionHead := func(title string) string {
|
||||||
|
return m.st.titleStyle.Render(" "+title) + " " + m.st.subtleStyle.Render(strings.Repeat("─", divW-len(title)-3))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left column: endpoint details
|
||||||
|
var left []string
|
||||||
|
left = append(left, m.st.titleStyle.Render(" ENDPOINT"))
|
||||||
|
left = append(left, row("Type", site.Type))
|
||||||
|
if site.URL != "" {
|
||||||
|
left = append(left, row("URL", limitStr(site.URL, colW-19)))
|
||||||
|
}
|
||||||
|
if site.Hostname != "" {
|
||||||
|
left = append(left, row("Host", site.Hostname))
|
||||||
|
}
|
||||||
|
if site.Port > 0 {
|
||||||
|
left = append(left, row("Port", strconv.Itoa(site.Port)))
|
||||||
|
}
|
||||||
|
left = append(left, row("Interval", fmt.Sprintf("%ds", site.Interval)))
|
||||||
|
if site.MaxRetries > 0 {
|
||||||
|
left = append(left, row("Retries", m.fmtRetries(site)))
|
||||||
|
}
|
||||||
|
if site.Regions != "" {
|
||||||
|
left = append(left, row("Regions", site.Regions))
|
||||||
|
}
|
||||||
|
if site.Description != "" {
|
||||||
|
left = append(left, row("Description", limitStr(site.Description, colW-19)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right column: status + timing + HTTP
|
||||||
|
var right []string
|
||||||
|
right = append(right, m.st.titleStyle.Render(" STATUS"))
|
||||||
|
right = append(right, row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))))
|
||||||
|
right = append(right, row("Latency", m.fmtLatency(site.Latency)))
|
||||||
|
right = append(right, row("Uptime", m.fmtUptime(hist.Statuses)))
|
||||||
|
if !site.StatusChangedAt.IsZero() {
|
||||||
|
dur := time.Since(site.StatusChangedAt)
|
||||||
|
right = append(right, row("State Since", fmtDuration(dur)+" ago"))
|
||||||
|
}
|
||||||
|
if !site.LastCheck.IsZero() {
|
||||||
|
right = append(right, row("Last Check", m.fmtTimeAgo(site.LastCheck)))
|
||||||
|
}
|
||||||
|
if !site.LastSuccessAt.IsZero() {
|
||||||
|
right = append(right, row("Last Success", m.fmtTimeAgo(site.LastSuccessAt)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||||
|
errW := colW - 19
|
||||||
|
if errW < 20 {
|
||||||
|
errW = 20
|
||||||
|
}
|
||||||
|
right = append(right, row("Error", m.st.dangerStyle.Render(limitStr(site.LastError, errW))))
|
||||||
|
}
|
||||||
|
|
||||||
|
if site.Type == "http" {
|
||||||
|
if site.StatusCode > 0 {
|
||||||
|
right = append(right, row("HTTP Code", strconv.Itoa(site.StatusCode)))
|
||||||
|
}
|
||||||
|
codes := site.AcceptedCodes
|
||||||
|
if codes == "" {
|
||||||
|
codes = "200-299"
|
||||||
|
}
|
||||||
|
right = append(right, row("Codes", codes))
|
||||||
|
right = append(right, row("SSL", m.fmtSSL(site)))
|
||||||
|
if site.Method != "" && site.Method != "GET" {
|
||||||
|
right = append(right, row("Method", site.Method))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad shorter column
|
||||||
|
for len(left) < len(right) {
|
||||||
|
left = append(left, "")
|
||||||
|
}
|
||||||
|
for len(right) < len(left) {
|
||||||
|
right = append(right, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
leftCol := lipgloss.NewStyle().Width(colW).Render(strings.Join(left, "\n"))
|
||||||
|
rightCol := lipgloss.NewStyle().Width(colW).Render(strings.Join(right, "\n"))
|
||||||
|
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) + "\n")
|
||||||
|
b.WriteString("\n" + sectionDiv + "\n")
|
||||||
|
|
||||||
|
// Connection chain (full width, only on errors)
|
||||||
|
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" {
|
||||||
|
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
|
||||||
|
if len(chain) > 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
for _, step := range chain {
|
||||||
|
var icon string
|
||||||
|
switch step.Status {
|
||||||
|
case stepPassed:
|
||||||
|
icon = m.st.specialStyle.Render("✓")
|
||||||
|
case stepFailed:
|
||||||
|
icon = m.st.dangerStyle.Render("✗")
|
||||||
|
case stepSkipped:
|
||||||
|
icon = m.st.subtleStyle.Render("·")
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf(" %s %-16s", icon, step.Name)
|
||||||
|
if step.Detail != "" {
|
||||||
|
switch step.Status {
|
||||||
|
case stepFailed:
|
||||||
|
line += " " + m.st.dangerStyle.Render(step.Detail)
|
||||||
|
case stepSkipped:
|
||||||
|
line += " " + m.st.subtleStyle.Render(step.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintenance
|
||||||
|
if m.isMonitorInMaintenance(site.ID) {
|
||||||
|
for _, mw := range m.maintenanceWindows {
|
||||||
|
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
|
||||||
|
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Maintenance"), m.st.maintStyle.Render(mw.Title))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push token
|
||||||
|
if site.Type == "push" && site.Token != "" {
|
||||||
|
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Token"), site.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe results
|
||||||
|
probeResults := m.engine.GetProbeResults(site.ID)
|
||||||
|
if len(probeResults) > 0 {
|
||||||
|
nodeIDs := make([]string, 0, len(probeResults))
|
||||||
|
for id := range probeResults {
|
||||||
|
nodeIDs = append(nodeIDs, id)
|
||||||
|
}
|
||||||
|
sort.Strings(nodeIDs)
|
||||||
|
b.WriteString("\n" + sectionHead("PROBE RESULTS") + "\n")
|
||||||
|
for _, nodeID := range nodeIDs {
|
||||||
|
result := probeResults[nodeID]
|
||||||
|
status := m.st.specialStyle.Render("UP")
|
||||||
|
if !result.IsUp {
|
||||||
|
status = m.st.dangerStyle.Render("DN")
|
||||||
|
}
|
||||||
|
latency := time.Duration(result.LatencyNs).Milliseconds()
|
||||||
|
ago := time.Since(result.CheckedAt).Truncate(time.Second)
|
||||||
|
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago)
|
||||||
|
if !result.IsUp && result.ErrorReason != "" {
|
||||||
|
line += " " + m.st.dangerStyle.Render(result.ErrorReason)
|
||||||
|
}
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom two-column: graphs left, state changes right
|
||||||
|
graphW := (totalW - 4) * 70 / 100
|
||||||
|
changeW := totalW - 4 - graphW
|
||||||
|
if graphW < 30 {
|
||||||
|
graphW = 30
|
||||||
|
}
|
||||||
|
if changeW < 20 {
|
||||||
|
changeW = 20
|
||||||
|
}
|
||||||
|
bottomColW := graphW
|
||||||
|
|
||||||
|
// Left: latency + histogram
|
||||||
|
var graphLines []string
|
||||||
|
sectionLabel := func(title string) string {
|
||||||
|
return m.st.titleStyle.Render(" " + title)
|
||||||
|
}
|
||||||
|
|
||||||
|
graphLines = append(graphLines, sectionLabel("LATENCY"))
|
||||||
|
if site.Type == "push" {
|
||||||
|
sparkW := bottomColW - 4
|
||||||
|
if sparkW > detailSparkWidth {
|
||||||
|
sparkW = detailSparkWidth
|
||||||
|
}
|
||||||
|
graphLines = append(graphLines, " "+m.heartbeatSparkline(hist.Statuses, sparkW, nil))
|
||||||
|
if len(hist.Statuses) > 0 {
|
||||||
|
up := 0
|
||||||
|
for _, s := range hist.Statuses {
|
||||||
|
if s {
|
||||||
|
up++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graphLines = append(graphLines, fmt.Sprintf(" %s %d/%d checks up",
|
||||||
|
m.st.subtleStyle.Render("Heartbeats"), up, len(hist.Statuses)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sparkW := bottomColW - 4
|
||||||
|
if sparkW > detailSparkWidth {
|
||||||
|
sparkW = detailSparkWidth
|
||||||
|
}
|
||||||
|
graphLines = append(graphLines, " "+m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, nil))
|
||||||
|
var minL, maxL, total time.Duration
|
||||||
|
count := 0
|
||||||
|
for i, l := range hist.Latencies {
|
||||||
|
if i < len(hist.Statuses) && !hist.Statuses[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
minL, maxL = l, l
|
||||||
|
} else if l < minL {
|
||||||
|
minL = l
|
||||||
|
} else if l > maxL {
|
||||||
|
maxL = l
|
||||||
|
}
|
||||||
|
total += l
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
avg := total / time.Duration(count)
|
||||||
|
graphLines = append(graphLines, fmt.Sprintf(" %s %dms %s %dms %s %dms",
|
||||||
|
m.st.subtleStyle.Render("Min"), minL.Milliseconds(),
|
||||||
|
m.st.subtleStyle.Render("Avg"), avg.Milliseconds(),
|
||||||
|
m.st.subtleStyle.Render("Max"), maxL.Milliseconds()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if site.Type != "push" && len(hist.Latencies) > 5 {
|
||||||
|
graphLines = append(graphLines, "")
|
||||||
|
graphLines = append(graphLines, sectionLabel("DISTRIBUTION"))
|
||||||
|
graphLines = append(graphLines, m.latencyHistogram(hist.Latencies, hist.Statuses, bottomColW))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right: state changes
|
||||||
|
var changeLines []string
|
||||||
|
var stateChanges []models.StateChange
|
||||||
|
if m.detailChangesSiteID == site.ID {
|
||||||
|
stateChanges = m.detailChanges
|
||||||
|
}
|
||||||
|
changeLines = append(changeLines, sectionLabel("STATE CHANGES"))
|
||||||
|
if len(stateChanges) > 0 {
|
||||||
|
for i, sc := range stateChanges {
|
||||||
|
from := m.fmtStatusWord(string(sc.FromStatus))
|
||||||
|
to := m.fmtStatusWord(string(sc.ToStatus))
|
||||||
|
ago := fmtDuration(time.Since(sc.ChangedAt))
|
||||||
|
line := fmt.Sprintf(" %s → %s %s ago", from, to, ago)
|
||||||
|
if sc.ToStatus == "UP" {
|
||||||
|
dur := computeOutageDuration(stateChanges, i)
|
||||||
|
if dur > 0 {
|
||||||
|
line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sc.ErrorReason != "" {
|
||||||
|
line += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, changeW-30))
|
||||||
|
}
|
||||||
|
changeLines = append(changeLines, line)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
changeLines = append(changeLines, m.st.subtleStyle.Render(" No state changes"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad and join
|
||||||
|
for len(graphLines) < len(changeLines) {
|
||||||
|
graphLines = append(graphLines, "")
|
||||||
|
}
|
||||||
|
for len(changeLines) < len(graphLines) {
|
||||||
|
changeLines = append(changeLines, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
graphCol := lipgloss.NewStyle().Width(graphW).Render(strings.Join(graphLines, "\n"))
|
||||||
|
changeCol := lipgloss.NewStyle().Width(changeW).Render(strings.Join(changeLines, "\n"))
|
||||||
|
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, graphCol, changeCol) + "\n")
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(m.divider() + "\n")
|
||||||
|
b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [h] History [s] SLA [click] Inspect"))
|
||||||
|
|
||||||
|
// Wrap in a viewport for scrolling
|
||||||
|
content := b.String()
|
||||||
|
contentH := m.termHeight - 4
|
||||||
|
if contentH < 10 {
|
||||||
|
contentH = 10
|
||||||
|
}
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
if len(lines) > contentH {
|
||||||
|
m.detailViewport.SetContent(content)
|
||||||
|
m.detailViewport.Width = totalW
|
||||||
|
m.detailViewport.Height = contentH
|
||||||
|
return lipgloss.NewStyle().Padding(1, 2).Render(m.detailViewport.View())
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().Padding(1, 2).Render(content)
|
||||||
|
}
|
||||||
+164
-411
@@ -2,7 +2,6 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -11,250 +10,128 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) viewDetailInline(width, height int) string {
|
const detailTwoColMinWidth = 80
|
||||||
|
|
||||||
|
func (m Model) viewDetailInline(width int) string {
|
||||||
if m.cursor >= len(m.sites) {
|
if m.cursor >= len(m.sites) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
switch m.detailMode {
|
|
||||||
case detailSLA:
|
|
||||||
return m.viewSLASidebar(width, height)
|
|
||||||
case detailHistory:
|
|
||||||
return m.viewHistorySidebar(width, height)
|
|
||||||
default:
|
|
||||||
site := m.sites[m.cursor]
|
|
||||||
hist, _ := m.engine.GetHistory(site.ID)
|
|
||||||
return m.buildDetailContent(site, hist, width, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) viewDetailFullscreen() string {
|
|
||||||
if m.cursor >= len(m.sites) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
availW := m.termWidth - chromePadH
|
|
||||||
site := m.sites[m.cursor]
|
site := m.sites[m.cursor]
|
||||||
|
hist, _ := m.engine.GetHistory(site.ID)
|
||||||
|
|
||||||
var title string
|
if width < detailTwoColMinWidth {
|
||||||
switch m.detailMode {
|
return m.viewDetailSingleCol(site, hist, width)
|
||||||
case detailSLA:
|
|
||||||
title = "SLA · " + site.Name
|
|
||||||
case detailHistory:
|
|
||||||
title = "History · " + site.Name
|
|
||||||
default:
|
|
||||||
title = site.Name
|
|
||||||
}
|
}
|
||||||
|
return m.viewDetailTwoCol(site, hist, width)
|
||||||
if site.ParentID > 0 {
|
|
||||||
for _, s := range m.sites {
|
|
||||||
if s.ID == site.ParentID {
|
|
||||||
title = s.Name + " > " + title
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
innerW := availW - 2
|
|
||||||
|
|
||||||
var content string
|
|
||||||
switch m.detailMode {
|
|
||||||
case detailSLA:
|
|
||||||
content = m.viewSLASidebar(innerW, 0)
|
|
||||||
case detailHistory:
|
|
||||||
content = m.viewHistorySidebar(innerW, 0)
|
|
||||||
default:
|
|
||||||
hist, _ := m.engine.GetHistory(site.ID)
|
|
||||||
content = m.buildDetailContent(site, hist, innerW, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
footer := m.detailFooter(innerW)
|
|
||||||
|
|
||||||
panelH := m.termHeight - chromePadV
|
|
||||||
if panelH < 10 {
|
|
||||||
panelH = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
return lipgloss.NewStyle().Padding(1, 2).Render(
|
|
||||||
m.titledPanelH(title, content, footer, availW, panelH, m.detailScrollOffset, true))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) buildDetailContent(site models.Site, hist monitor.SiteHistory, width int, fullscreen bool) string {
|
func (m Model) viewDetailTwoCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||||
dot := m.st.subtleStyle.Render(" · ")
|
leftW := width * 55 / 100
|
||||||
label := m.st.subtleStyle
|
rightW := width - leftW - 3 // 3 for " │ " divider
|
||||||
innerW := width - 4
|
|
||||||
if innerW < 20 {
|
left := m.detailLeftCol(site, hist, leftW)
|
||||||
innerW = 20
|
right := m.detailRightCol(site, hist, rightW)
|
||||||
|
|
||||||
|
leftLines := strings.Split(left, "\n")
|
||||||
|
rightLines := strings.Split(right, "\n")
|
||||||
|
|
||||||
|
lineCount := len(leftLines)
|
||||||
|
if len(rightLines) > lineCount {
|
||||||
|
lineCount = len(rightLines)
|
||||||
}
|
}
|
||||||
|
for len(leftLines) < lineCount {
|
||||||
|
leftLines = append(leftLines, "")
|
||||||
|
}
|
||||||
|
for len(rightLines) < lineCount {
|
||||||
|
rightLines = append(rightLines, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
divChar := m.st.subtleStyle.Render("│")
|
||||||
|
leftStyle := lipgloss.NewStyle().Width(leftW).MaxWidth(leftW)
|
||||||
|
rightStyle := lipgloss.NewStyle().Width(rightW).MaxWidth(rightW)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
for i := range lineCount {
|
||||||
// Status + latency + last check + state since
|
l := leftStyle.Render(leftLines[i])
|
||||||
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
r := rightStyle.Render(rightLines[i])
|
||||||
statusParts := []string{status}
|
b.WriteString(l + " " + divChar + " " + r + "\n")
|
||||||
if site.Latency > 0 {
|
|
||||||
statusParts = append(statusParts, m.fmtLatency(site.Latency))
|
|
||||||
}
|
|
||||||
if !site.LastCheck.IsZero() {
|
|
||||||
statusParts = append(statusParts, m.fmtTimeAgo(site.LastCheck))
|
|
||||||
}
|
|
||||||
if !site.StatusChangedAt.IsZero() {
|
|
||||||
dur := time.Since(site.StatusChangedAt)
|
|
||||||
statusParts = append(statusParts, label.Render("for")+" "+fmtDuration(dur))
|
|
||||||
}
|
|
||||||
b.WriteString(" " + strings.Join(statusParts, dot) + "\n")
|
|
||||||
|
|
||||||
// Type-specific details
|
|
||||||
typeParts := m.detailTypeLine(site)
|
|
||||||
if len(typeParts) > 0 {
|
|
||||||
b.WriteString(" " + strings.Join(typeParts, dot) + "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extended endpoint fields
|
keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close")
|
||||||
m.writeEndpointFields(&b, site, label, innerW, fullscreen)
|
b.WriteString(" " + keys + "\n")
|
||||||
|
|
||||||
// Uptime + retries + last success
|
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||||
uptimeStr := m.fmtUptime(hist.Statuses)
|
}
|
||||||
if m.isMonitorInMaintenance(site.ID) {
|
|
||||||
uptimeStr = m.st.subtleStyle.Render("—")
|
|
||||||
}
|
|
||||||
uptimeParts := []string{label.Render("Uptime") + " " + uptimeStr}
|
|
||||||
if site.Type != "group" && site.MaxRetries > 0 {
|
|
||||||
uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site))
|
|
||||||
}
|
|
||||||
if site.Type != "push" && !site.LastSuccessAt.IsZero() {
|
|
||||||
uptimeParts = append(uptimeParts, label.Render("Last OK")+" "+m.fmtTimeAgo(site.LastSuccessAt))
|
|
||||||
}
|
|
||||||
b.WriteString(" " + strings.Join(uptimeParts, dot) + "\n")
|
|
||||||
|
|
||||||
// Maintenance window name
|
func (m Model) detailLeftCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||||
if m.isMonitorInMaintenance(site.ID) {
|
var b strings.Builder
|
||||||
for _, mw := range m.maintenanceWindows {
|
|
||||||
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
|
|
||||||
b.WriteString(" " + label.Render("Maint") + " " + m.st.maintStyle.Render(mw.Title) + "\n")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error line
|
|
||||||
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
|
||||||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
|
||||||
errW := innerW
|
|
||||||
if errW < 20 {
|
|
||||||
errW = 20
|
|
||||||
}
|
|
||||||
b.WriteString(" " + label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection chain
|
|
||||||
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" {
|
|
||||||
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
|
|
||||||
if len(chain) > 0 {
|
|
||||||
b.WriteString("\n")
|
|
||||||
for _, step := range chain {
|
|
||||||
var icon string
|
|
||||||
switch step.Status {
|
|
||||||
case stepPassed:
|
|
||||||
icon = m.st.specialStyle.Render("✓")
|
|
||||||
case stepFailed:
|
|
||||||
icon = m.st.dangerStyle.Render("✗")
|
|
||||||
case stepSkipped:
|
|
||||||
icon = m.st.subtleStyle.Render("·")
|
|
||||||
}
|
|
||||||
line := fmt.Sprintf(" %s %-16s", icon, step.Name)
|
|
||||||
if step.Detail != "" {
|
|
||||||
switch step.Status {
|
|
||||||
case stepFailed:
|
|
||||||
line += " " + m.st.dangerStyle.Render(step.Detail)
|
|
||||||
case stepSkipped:
|
|
||||||
line += " " + m.st.subtleStyle.Render(step.Detail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString(line + "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// Probe results
|
|
||||||
probeResults := m.engine.GetProbeResults(site.ID)
|
|
||||||
if len(probeResults) > 0 {
|
|
||||||
nodeIDs := make([]string, 0, len(probeResults))
|
|
||||||
for id := range probeResults {
|
|
||||||
nodeIDs = append(nodeIDs, id)
|
|
||||||
}
|
|
||||||
sort.Strings(nodeIDs)
|
|
||||||
for _, nodeID := range nodeIDs {
|
|
||||||
result := probeResults[nodeID]
|
|
||||||
probeStatus := m.st.specialStyle.Render("UP")
|
|
||||||
if !result.IsUp {
|
|
||||||
probeStatus = m.st.dangerStyle.Render("DN")
|
|
||||||
}
|
|
||||||
latency := time.Duration(result.LatencyNs).Milliseconds()
|
|
||||||
ago := time.Since(result.CheckedAt).Truncate(time.Second)
|
|
||||||
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, probeStatus, latency, ago)
|
|
||||||
if !result.IsUp && result.ErrorReason != "" {
|
|
||||||
line += " " + m.st.dangerStyle.Render(result.ErrorReason)
|
|
||||||
}
|
|
||||||
b.WriteString(line + "\n")
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Latency chart
|
|
||||||
if len(hist.Latencies) > 0 {
|
if len(hist.Latencies) > 0 {
|
||||||
chart := m.latencyChart(hist.Latencies, hist.Statuses, innerW, 3)
|
chartW := width - 2
|
||||||
|
if chartW < 20 {
|
||||||
|
chartW = 20
|
||||||
|
}
|
||||||
|
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3)
|
||||||
if chart != "" {
|
if chart != "" {
|
||||||
b.WriteString(chart + "\n")
|
b.WriteString(chart + "\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 30d uptime timeline
|
|
||||||
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
|
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
|
||||||
b.WriteString(" " + label.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, innerW) + "\n")
|
timelineW := width - 2
|
||||||
|
if timelineW < 20 {
|
||||||
|
timelineW = 20
|
||||||
|
}
|
||||||
|
b.WriteString(" " + m.st.subtleStyle.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, timelineW) + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sparkline + min/avg/max
|
return strings.TrimRight(b.String(), "\n")
|
||||||
if site.Type != "push" && len(hist.Latencies) > 0 {
|
}
|
||||||
b.WriteString(" " + m.latencySparkline(hist.Latencies, hist.Statuses, innerW, nil) + "\n")
|
|
||||||
var minL, maxL, total time.Duration
|
func (m Model) detailRightCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||||
count := 0
|
dot := m.st.subtleStyle.Render(" · ")
|
||||||
for i, l := range hist.Latencies {
|
label := m.st.subtleStyle
|
||||||
if i < len(hist.Statuses) && !hist.Statuses[i] {
|
|
||||||
continue
|
var b strings.Builder
|
||||||
}
|
|
||||||
if count == 0 {
|
// Line 1: status + latency + last check
|
||||||
minL, maxL = l, l
|
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
||||||
} else if l < minL {
|
parts := []string{status}
|
||||||
minL = l
|
if site.Latency > 0 {
|
||||||
} else if l > maxL {
|
parts = append(parts, m.fmtLatency(site.Latency))
|
||||||
maxL = l
|
}
|
||||||
}
|
if !site.LastCheck.IsZero() {
|
||||||
total += l
|
parts = append(parts, m.fmtTimeAgo(site.LastCheck))
|
||||||
count++
|
}
|
||||||
}
|
b.WriteString(strings.Join(parts, dot) + "\n")
|
||||||
if count > 0 {
|
|
||||||
avg := total / time.Duration(count)
|
// Line 2: type-specific details
|
||||||
fmt.Fprintf(&b, " %s %dms %s %dms %s %dms\n",
|
typeParts := m.detailTypeLine(site)
|
||||||
label.Render("Min"), minL.Milliseconds(),
|
if len(typeParts) > 0 {
|
||||||
label.Render("Avg"), avg.Milliseconds(),
|
b.WriteString(strings.Join(typeParts, dot) + "\n")
|
||||||
label.Render("Max"), maxL.Milliseconds())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Latency histogram
|
// Line 3: uptime + retries
|
||||||
if site.Type != "push" && len(hist.Latencies) > 5 {
|
uptimeParts := []string{label.Render("Uptime") + " " + m.fmtUptime(hist.Statuses)}
|
||||||
histContent := m.latencyHistogram(hist.Latencies, hist.Statuses, innerW)
|
if site.Type != "group" && site.MaxRetries > 0 {
|
||||||
if histContent != "" {
|
uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site))
|
||||||
b.WriteString("\n")
|
}
|
||||||
b.WriteString(histContent)
|
b.WriteString(strings.Join(uptimeParts, dot) + "\n")
|
||||||
|
|
||||||
|
// Error line (if down/broken)
|
||||||
|
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
||||||
|
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||||
|
errW := width - 8
|
||||||
|
if errW < 20 {
|
||||||
|
errW = 20
|
||||||
}
|
}
|
||||||
|
b.WriteString(label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blank line before state changes
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
// State changes
|
// State changes (one per line, compact)
|
||||||
var stateChanges []models.StateChange
|
var stateChanges []models.StateChange
|
||||||
if m.detailChangesSiteID == site.ID {
|
if m.detailChangesSiteID == site.ID {
|
||||||
stateChanges = m.detailChanges
|
stateChanges = m.detailChanges
|
||||||
@@ -269,62 +146,19 @@ func (m Model) buildDetailContent(site models.Site, hist monitor.SiteHistory, wi
|
|||||||
arrow := m.st.subtleStyle.Render("→")
|
arrow := m.st.subtleStyle.Render("→")
|
||||||
from := m.fmtStatusWord(sc.FromStatus)
|
from := m.fmtStatusWord(sc.FromStatus)
|
||||||
to := m.fmtStatusWord(sc.ToStatus)
|
to := m.fmtStatusWord(sc.ToStatus)
|
||||||
entry := from + " " + arrow + " " + to + " " + label.Render(ago+" ago")
|
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
|
||||||
if sc.ErrorReason != "" {
|
if sc.ErrorReason != "" {
|
||||||
reasonW := innerW - 25
|
reasonW := width - 30
|
||||||
if reasonW < 15 {
|
if reasonW < 15 {
|
||||||
reasonW = 15
|
reasonW = 15
|
||||||
}
|
}
|
||||||
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
|
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
|
||||||
}
|
}
|
||||||
b.WriteString(" " + entry + "\n")
|
b.WriteString(entry + "\n")
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
b.WriteString(" " + label.Render("No state changes") + "\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
return strings.TrimRight(b.String(), "\n")
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) writeEndpointFields(b *strings.Builder, site models.Site, label lipgloss.Style, innerW int, fullscreen bool) {
|
|
||||||
dot := m.st.subtleStyle.Render(" · ")
|
|
||||||
var fields []string
|
|
||||||
|
|
||||||
if site.Interval > 0 {
|
|
||||||
fields = append(fields, label.Render("Every")+" "+fmt.Sprintf("%ds", site.Interval))
|
|
||||||
}
|
|
||||||
if site.Timeout > 0 {
|
|
||||||
fields = append(fields, label.Render("Timeout")+" "+fmt.Sprintf("%ds", site.Timeout))
|
|
||||||
}
|
|
||||||
if site.Type == "http" && site.Method != "" && site.Method != "GET" {
|
|
||||||
fields = append(fields, label.Render("Method")+" "+site.Method)
|
|
||||||
}
|
|
||||||
if site.Type == "http" {
|
|
||||||
codes := site.AcceptedCodes
|
|
||||||
if codes == "" {
|
|
||||||
codes = "200-299"
|
|
||||||
}
|
|
||||||
fields = append(fields, label.Render("Codes")+" "+codes)
|
|
||||||
}
|
|
||||||
if site.Regions != "" {
|
|
||||||
fields = append(fields, label.Render("Regions")+" "+site.Regions)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(fields) > 0 {
|
|
||||||
b.WriteString(" " + strings.Join(fields, dot) + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if site.Description != "" {
|
|
||||||
maxDescW := innerW
|
|
||||||
if !fullscreen && maxDescW > 60 {
|
|
||||||
maxDescW = 60
|
|
||||||
}
|
|
||||||
b.WriteString(" " + label.Render(limitStr(site.Description, maxDescW)) + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if site.Type == "push" && site.Token != "" {
|
|
||||||
b.WriteString(" " + label.Render("Token") + " " + site.Token + "\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) detailTypeLine(site models.Site) []string {
|
func (m Model) detailTypeLine(site models.Site) []string {
|
||||||
@@ -391,168 +225,87 @@ func (m Model) detailTypeLine(site models.Site) []string {
|
|||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) detailFooter(width int) string {
|
// viewDetailSingleCol is the narrow-terminal fallback (original stacked layout).
|
||||||
dot := m.st.subtleStyle.Render(" · ")
|
func (m Model) viewDetailSingleCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||||
var parts []string
|
var b strings.Builder
|
||||||
|
dot := m.st.subtleStyle.Render(" · ")
|
||||||
|
|
||||||
switch m.detailMode {
|
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
||||||
case detailSLA:
|
parts := []string{status}
|
||||||
for i, p := range slaPeriods {
|
if site.Latency > 0 {
|
||||||
if i == m.slaPeriodIdx {
|
parts = append(parts, m.fmtLatency(site.Latency))
|
||||||
parts = append(parts, m.st.titleStyle.Render(p.key)+" "+m.st.titleStyle.Render(p.label))
|
}
|
||||||
} else {
|
parts = append(parts, fmt.Sprintf("Uptime %s", m.fmtUptime(hist.Statuses)))
|
||||||
parts = append(parts, m.hotkey(p.key, p.label))
|
if !site.LastCheck.IsZero() {
|
||||||
}
|
parts = append(parts, m.fmtTimeAgo(site.LastCheck))
|
||||||
|
}
|
||||||
|
b.WriteString(" " + strings.Join(parts, dot) + "\n")
|
||||||
|
|
||||||
|
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
||||||
|
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||||
|
errW := width - 12
|
||||||
|
if errW < 20 {
|
||||||
|
errW = 20
|
||||||
}
|
}
|
||||||
parts = append(parts, m.hotkey("Esc", "Back"))
|
b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
|
||||||
case detailHistory:
|
|
||||||
parts = append(parts, m.hotkey("Esc", "Back"))
|
|
||||||
default:
|
|
||||||
parts = append(parts, m.hotkey("e", "Edit"), m.hotkey("h", "History"), m.hotkey("s", "SLA"), m.hotkey("Esc", "Back"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
content := " " + strings.Join(parts, dot)
|
var stateChanges []models.StateChange
|
||||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(content)
|
if m.detailChangesSiteID == site.ID {
|
||||||
|
stateChanges = m.detailChanges
|
||||||
|
}
|
||||||
|
if len(stateChanges) > 0 {
|
||||||
|
limit := 3
|
||||||
|
if len(stateChanges) < limit {
|
||||||
|
limit = len(stateChanges)
|
||||||
|
}
|
||||||
|
var scParts []string
|
||||||
|
for _, sc := range stateChanges[:limit] {
|
||||||
|
ago := fmtDuration(time.Since(sc.ChangedAt))
|
||||||
|
arrow := m.st.subtleStyle.Render("→")
|
||||||
|
from := m.fmtStatusWord(sc.FromStatus)
|
||||||
|
to := m.fmtStatusWord(sc.ToStatus)
|
||||||
|
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
|
||||||
|
if sc.ErrorReason != "" {
|
||||||
|
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30))
|
||||||
|
}
|
||||||
|
scParts = append(scParts, entry)
|
||||||
|
}
|
||||||
|
b.WriteString(" " + strings.Join(scParts, dot) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hist.Latencies) > 0 {
|
||||||
|
chartW := width - 4
|
||||||
|
if chartW < 20 {
|
||||||
|
chartW = 20
|
||||||
|
}
|
||||||
|
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3)
|
||||||
|
if chart != "" {
|
||||||
|
b.WriteString(chart + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
|
||||||
|
timelineW := width - 4
|
||||||
|
if timelineW < 20 {
|
||||||
|
timelineW = 20
|
||||||
|
}
|
||||||
|
b.WriteString(" " + m.st.subtleStyle.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, timelineW) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close")
|
||||||
|
b.WriteString(" " + keys + "\n")
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) fmtStatusWord(status string) string {
|
func (m Model) fmtStatusWord(status string) string {
|
||||||
switch status {
|
switch status {
|
||||||
case "DOWN", "SSL EXP":
|
case "DOWN":
|
||||||
return m.st.dangerStyle.Render(status)
|
return m.st.dangerStyle.Render("DOWN")
|
||||||
case "UP":
|
case "UP":
|
||||||
return m.st.specialStyle.Render("UP")
|
return m.st.specialStyle.Render("UP")
|
||||||
case "LATE":
|
|
||||||
return m.st.warnStyle.Render("LATE")
|
|
||||||
case "STALE":
|
|
||||||
return m.st.staleStyle.Render("STALE")
|
|
||||||
case "PENDING":
|
|
||||||
return m.st.subtleStyle.Render("PENDING")
|
|
||||||
case "PAUSED":
|
|
||||||
return m.st.warnStyle.Render("PAUSED")
|
|
||||||
default:
|
default:
|
||||||
return m.st.subtleStyle.Render(status)
|
return m.st.subtleStyle.Render(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewSLASidebar(width, _ int) string {
|
|
||||||
var b strings.Builder
|
|
||||||
label := m.st.subtleStyle
|
|
||||||
innerW := width - 4
|
|
||||||
if innerW < 20 {
|
|
||||||
innerW = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
period := slaPeriods[m.slaPeriodIdx]
|
|
||||||
b.WriteString(" " + label.Render("Period: Last "+period.label) + "\n\n")
|
|
||||||
|
|
||||||
r := m.slaReport
|
|
||||||
barWidth := innerW - 25
|
|
||||||
if barWidth < 10 {
|
|
||||||
barWidth = 10
|
|
||||||
}
|
|
||||||
bar := m.uptimeBar(r.UptimePct, barWidth)
|
|
||||||
uptimeColor := m.st.specialStyle
|
|
||||||
if r.UptimePct < uptimeExcellentPct {
|
|
||||||
uptimeColor = m.st.warnStyle
|
|
||||||
}
|
|
||||||
if r.UptimePct < uptimeGoodPct {
|
|
||||||
uptimeColor = m.st.dangerStyle
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, " %-14s %s %s\n", label.Render("Uptime"), uptimeColor.Render(fmtPct(r.UptimePct)+"%"), bar)
|
|
||||||
fmt.Fprintf(&b, " %-14s %s\n", label.Render("Downtime"), fmtDuration(r.Downtime))
|
|
||||||
fmt.Fprintf(&b, " %-14s %d\n", label.Render("Outages"), r.OutageCount)
|
|
||||||
|
|
||||||
if r.OutageCount > 0 {
|
|
||||||
fmt.Fprintf(&b, " %-14s %s\n", label.Render("Longest"), fmtDuration(r.LongestOut))
|
|
||||||
fmt.Fprintf(&b, " %-14s %s\n", label.Render("MTTR"), fmtDuration(r.MTTR))
|
|
||||||
fmt.Fprintf(&b, " %-14s %s\n", label.Render("MTBF"), fmtDuration(r.MTBF))
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
if len(m.slaDailyBreakdown) > 0 {
|
|
||||||
b.WriteString(" " + m.st.titleStyle.Render("DAILY BREAKDOWN") + "\n")
|
|
||||||
dayBarW := innerW - 20
|
|
||||||
if dayBarW < 10 {
|
|
||||||
dayBarW = 10
|
|
||||||
}
|
|
||||||
for _, day := range m.slaDailyBreakdown {
|
|
||||||
dateStr := day.Date.Format("Jan 02")
|
|
||||||
dayBar := m.uptimeBar(day.UptimePct, dayBarW)
|
|
||||||
pctStr := fmtPct(day.UptimePct) + "%"
|
|
||||||
color := m.st.specialStyle
|
|
||||||
if day.UptimePct < uptimeExcellentPct {
|
|
||||||
color = m.st.warnStyle
|
|
||||||
}
|
|
||||||
if day.UptimePct < uptimeGoodPct {
|
|
||||||
color = m.st.dangerStyle
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, " %-8s %s %s\n", label.Render(dateStr), dayBar, color.Render(pctStr))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) viewHistorySidebar(width, _ int) string {
|
|
||||||
var b strings.Builder
|
|
||||||
label := m.st.subtleStyle
|
|
||||||
innerW := width - 4
|
|
||||||
if innerW < 20 {
|
|
||||||
innerW = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
sparkline := m.stateChangeSparkline(m.historyChanges, innerW)
|
|
||||||
if sparkline != "" {
|
|
||||||
b.WriteString(" " + sparkline + "\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(m.historyChanges) == 0 {
|
|
||||||
b.WriteString(" " + label.Render("No state changes recorded") + "\n")
|
|
||||||
} else {
|
|
||||||
reasonW := innerW - 45
|
|
||||||
if reasonW < 10 {
|
|
||||||
reasonW = 10
|
|
||||||
}
|
|
||||||
for i, sc := range m.historyChanges {
|
|
||||||
ts := sc.ChangedAt.Format("01/02 15:04")
|
|
||||||
|
|
||||||
arrow := label.Render(sc.FromStatus) + " → "
|
|
||||||
switch sc.ToStatus {
|
|
||||||
case string(models.StatusUp):
|
|
||||||
arrow += m.st.specialStyle.Render(sc.ToStatus)
|
|
||||||
case string(models.StatusLate):
|
|
||||||
arrow += m.st.warnStyle.Render(sc.ToStatus)
|
|
||||||
case string(models.StatusStale):
|
|
||||||
arrow += m.st.staleStyle.Render(sc.ToStatus)
|
|
||||||
default:
|
|
||||||
arrow += m.st.dangerStyle.Render(sc.ToStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
durStr := ""
|
|
||||||
if dur := computeOutageDuration(m.historyChanges, i); dur > 0 {
|
|
||||||
durStr = m.st.warnStyle.Render(fmtDuration(dur))
|
|
||||||
}
|
|
||||||
|
|
||||||
reason := ""
|
|
||||||
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
|
|
||||||
reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(&b, " %-12s %s %s %s\n", ts, arrow, durStr, reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
stats := computeHistoryStats(m.historyChanges)
|
|
||||||
statParts := []string{fmt.Sprintf("%d events", stats.totalEvents)}
|
|
||||||
if stats.outageCount > 0 {
|
|
||||||
statParts = append(statParts, fmt.Sprintf("%d outages", stats.outageCount))
|
|
||||||
avg := stats.totalDowntime / time.Duration(stats.outageCount)
|
|
||||||
statParts = append(statParts, "avg "+fmtDuration(avg))
|
|
||||||
}
|
|
||||||
b.WriteString(" " + label.Render(strings.Join(statParts, " │ ")) + "\n")
|
|
||||||
|
|
||||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
type historyStats struct {
|
type historyStats struct {
|
||||||
@@ -103,3 +105,88 @@ func (m Model) stateChangeSparkline(changes []models.StateChange, width int) str
|
|||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) buildHistoryContent() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
reasonWidth := m.termWidth - chromePadH - 55
|
||||||
|
if reasonWidth < 10 {
|
||||||
|
reasonWidth = 10
|
||||||
|
}
|
||||||
|
if reasonWidth > 60 {
|
||||||
|
reasonWidth = 60
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, sc := range m.historyChanges {
|
||||||
|
ts := sc.ChangedAt.Format("2006-01-02 15:04")
|
||||||
|
|
||||||
|
arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → "
|
||||||
|
switch sc.ToStatus {
|
||||||
|
case string(models.StatusUp):
|
||||||
|
arrow += m.st.specialStyle.Render(sc.ToStatus)
|
||||||
|
case string(models.StatusLate):
|
||||||
|
arrow += m.st.warnStyle.Render(sc.ToStatus)
|
||||||
|
case string(models.StatusStale):
|
||||||
|
arrow += m.st.staleStyle.Render(sc.ToStatus)
|
||||||
|
default:
|
||||||
|
arrow += m.st.dangerStyle.Render(sc.ToStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
durStr := ""
|
||||||
|
if dur := computeOutageDuration(m.historyChanges, i); dur > 0 {
|
||||||
|
durStr = m.st.warnStyle.Render("outage " + fmtDuration(dur))
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := ""
|
||||||
|
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
|
||||||
|
reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, " %-18s %s %-12s %s\n", ts, arrow, durStr, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewHistoryPanel() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
header := " " + m.st.titleStyle.Render("STATE HISTORY: "+m.historySiteName)
|
||||||
|
header += " " + m.st.subtleStyle.Render("[q] Back")
|
||||||
|
b.WriteString(header + "\n")
|
||||||
|
|
||||||
|
divWidth := m.dividerWidth()
|
||||||
|
b.WriteString(m.divider() + "\n")
|
||||||
|
|
||||||
|
sparkline := m.stateChangeSparkline(m.historyChanges, divWidth)
|
||||||
|
if sparkline != "" {
|
||||||
|
b.WriteString(" " + sparkline + "\n")
|
||||||
|
b.WriteString(m.divider() + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, " %-18s %-17s %-12s %s\n",
|
||||||
|
m.st.subtleStyle.Render("TIME"),
|
||||||
|
m.st.subtleStyle.Render("TRANSITION"),
|
||||||
|
m.st.subtleStyle.Render("DURATION"),
|
||||||
|
m.st.subtleStyle.Render("REASON"))
|
||||||
|
|
||||||
|
if len(m.historyChanges) == 0 {
|
||||||
|
b.WriteString("\n " + m.st.subtleStyle.Render("No state changes recorded") + "\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(m.historyViewport.View())
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n" + m.divider() + "\n")
|
||||||
|
|
||||||
|
stats := computeHistoryStats(m.historyChanges)
|
||||||
|
parts := []string{fmt.Sprintf("%d events", stats.totalEvents)}
|
||||||
|
if stats.outageCount > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d outages", stats.outageCount))
|
||||||
|
avg := stats.totalDowntime / time.Duration(stats.outageCount)
|
||||||
|
parts = append(parts, "avg outage "+fmtDuration(avg))
|
||||||
|
}
|
||||||
|
b.WriteString(" " + m.st.subtleStyle.Render(strings.Join(parts, " │ ")) + "\n")
|
||||||
|
b.WriteString(" " + m.st.subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back"))
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/charmbracelet/lipgloss/table"
|
|
||||||
)
|
|
||||||
|
|
||||||
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) monitorNameByID(id int) string {
|
|
||||||
for _, s := range m.engine.GetAllSites() {
|
|
||||||
if s.ID == id {
|
|
||||||
return s.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("#%d", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
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) viewMaintStrip(width, maxLines int) string {
|
|
||||||
windows := m.activeMaintWindows()
|
|
||||||
if len(windows) == 0 {
|
|
||||||
return m.st.subtleStyle.Render(" No active maintenance")
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
end := maxLines
|
|
||||||
if end > len(windows) {
|
|
||||||
end = len(windows)
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedVisual := -1
|
|
||||||
if m.focusedPanel == panelMaint {
|
|
||||||
selectedVisual = m.maintCursor
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows [][]string
|
|
||||||
for i := 0; i < end; i++ {
|
|
||||||
mw := windows[i]
|
|
||||||
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("○")
|
|
||||||
}
|
|
||||||
|
|
||||||
monName := "All Monitors"
|
|
||||||
if mw.MonitorID > 0 {
|
|
||||||
monName = m.monitorNameByID(mw.MonitorID)
|
|
||||||
}
|
|
||||||
|
|
||||||
var status string
|
|
||||||
if isActive {
|
|
||||||
if mw.EndTime.IsZero() {
|
|
||||||
status = "indefinite"
|
|
||||||
} else {
|
|
||||||
status = fmtDuration(time.Until(mw.EndTime)) + " left"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
status = "starts " + mw.StartTime.Format("Jan 02 15:04")
|
|
||||||
}
|
|
||||||
|
|
||||||
rows = append(rows, []string{icon, mw.Title, monName, status})
|
|
||||||
}
|
|
||||||
|
|
||||||
colWidths := []int{3, 0, 0, 0}
|
|
||||||
remaining := width - colWidths[0] - 6
|
|
||||||
colWidths[1] = remaining * 40 / 100
|
|
||||||
colWidths[2] = remaining * 30 / 100
|
|
||||||
colWidths[3] = remaining - colWidths[1] - colWidths[2]
|
|
||||||
|
|
||||||
t := table.New().
|
|
||||||
Border(lipgloss.HiddenBorder()).
|
|
||||||
Width(width).
|
|
||||||
Rows(rows...).
|
|
||||||
StyleFunc(func(row, col int) lipgloss.Style {
|
|
||||||
isSelected := row == selectedVisual
|
|
||||||
base := m.st.tableCellStyle
|
|
||||||
if row%2 == 1 {
|
|
||||||
base = m.st.tableZebraStyle
|
|
||||||
}
|
|
||||||
if isSelected {
|
|
||||||
base = m.st.tableSelectedStyle
|
|
||||||
}
|
|
||||||
if col < len(colWidths) && colWidths[col] > 0 {
|
|
||||||
base = base.Width(colWidths[col]).MaxWidth(colWidths[col])
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
})
|
|
||||||
|
|
||||||
return t.Render()
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
var slaPeriods = []struct {
|
var slaPeriods = []struct {
|
||||||
@@ -19,6 +21,92 @@ var slaPeriods = []struct {
|
|||||||
{"90d", "4", 90 * 24 * time.Hour, 90},
|
{"90d", "4", 90 * 24 * time.Hour, 90},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) viewSLAPanel() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
header := " " + m.st.titleStyle.Render("SLA REPORT: "+m.slaSiteName)
|
||||||
|
header += " " + m.st.subtleStyle.Render("[q] Back")
|
||||||
|
b.WriteString(header + "\n")
|
||||||
|
b.WriteString(m.divider() + "\n")
|
||||||
|
|
||||||
|
period := slaPeriods[m.slaPeriodIdx]
|
||||||
|
b.WriteString(" " + m.st.subtleStyle.Render("Period: Last "+period.label) + "\n\n")
|
||||||
|
|
||||||
|
r := m.slaReport
|
||||||
|
|
||||||
|
barWidth := m.dividerWidth() - 30
|
||||||
|
if barWidth < 10 {
|
||||||
|
barWidth = 10
|
||||||
|
}
|
||||||
|
bar := m.uptimeBar(r.UptimePct, barWidth)
|
||||||
|
uptimeColor := m.st.specialStyle
|
||||||
|
if r.UptimePct < uptimeExcellentPct {
|
||||||
|
uptimeColor = m.st.warnStyle
|
||||||
|
}
|
||||||
|
if r.UptimePct < uptimeGoodPct {
|
||||||
|
uptimeColor = m.st.dangerStyle
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " %-16s %s %s\n", m.st.subtleStyle.Render("Uptime"), uptimeColor.Render(fmt.Sprintf("%s%%", fmtPct(r.UptimePct))), bar)
|
||||||
|
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Downtime"), fmtDuration(r.Downtime))
|
||||||
|
fmt.Fprintf(&b, " %-16s %d\n", m.st.subtleStyle.Render("Outages"), r.OutageCount)
|
||||||
|
|
||||||
|
if r.OutageCount > 0 {
|
||||||
|
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Longest"), fmtDuration(r.LongestOut))
|
||||||
|
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTTR"), fmtDuration(r.MTTR))
|
||||||
|
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTBF"), fmtDuration(r.MTBF))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n" + m.divider() + "\n")
|
||||||
|
|
||||||
|
if len(m.slaDailyBreakdown) > 0 {
|
||||||
|
b.WriteString(m.slaViewport.View())
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n" + m.divider() + "\n")
|
||||||
|
|
||||||
|
var keys []string
|
||||||
|
for i, p := range slaPeriods {
|
||||||
|
label := fmt.Sprintf("[%s] %s", p.key, p.label)
|
||||||
|
if i == m.slaPeriodIdx {
|
||||||
|
keys = append(keys, m.st.titleStyle.Render(label))
|
||||||
|
} else {
|
||||||
|
keys = append(keys, m.st.subtleStyle.Render(label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteString(" " + strings.Join(keys, " "))
|
||||||
|
b.WriteString(" " + m.st.subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back"))
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) buildSLADailyContent() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
barWidth := m.dividerWidth() - 30
|
||||||
|
if barWidth < 10 {
|
||||||
|
barWidth = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(" " + m.st.subtleStyle.Render("DAILY BREAKDOWN") + "\n")
|
||||||
|
for _, day := range m.slaDailyBreakdown {
|
||||||
|
dateStr := day.Date.Format("Jan 02")
|
||||||
|
bar := m.uptimeBar(day.UptimePct, barWidth)
|
||||||
|
pctStr := fmtPct(day.UptimePct) + "%"
|
||||||
|
|
||||||
|
color := m.st.specialStyle
|
||||||
|
if day.UptimePct < uptimeExcellentPct {
|
||||||
|
color = m.st.warnStyle
|
||||||
|
}
|
||||||
|
if day.UptimePct < uptimeGoodPct {
|
||||||
|
color = m.st.dangerStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, " %-8s %s %s\n", m.st.subtleStyle.Render(dateStr), bar, color.Render(pctStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) uptimeBar(pct float64, width int) string {
|
func (m Model) uptimeBar(pct float64, width int) string {
|
||||||
filled := int(math.Round(pct / 100 * float64(width)))
|
filled := int(math.Round(pct / 100 * float64(width)))
|
||||||
if filled > width {
|
if filled > width {
|
||||||
|
|||||||
Reference in New Issue
Block a user