1 Commits

Author SHA1 Message Date
lerko 650e6c4b16 feat(tui): mouse click for settings sections, column sort, logs panel
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 51s
- Click Alerts/Nodes/Users sub-tabs in settings to switch sections
- Click STATUS/NAME/LATENCY column headers to sort (click again
  to reverse direction)
- Logs panel show/hide: [l] toggles sidebar visibility, click
  focused panel to hide. Monitors expand to full width when hidden.
- Logs full-screen: Enter when focused on logs opens scrollable
  full-screen log view with severity tags, filter toggle [f],
  and live updates
2026-06-28 11:42:14 -04:00
23 changed files with 1562 additions and 1744 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

+2 -3
View File
@@ -123,10 +123,9 @@ func (m *Model) refreshLive() {
ordered = filterSites(ordered, m.filterText)
}
m.sites = ordered
m.buildMaintSet()
m.refreshLogContent()
if m.selectedID != 0 {
if m.currentTab == tabMonitors && m.selectedID != 0 {
for i, s := range m.sites {
if s.ID == m.selectedID {
m.cursor = i
@@ -138,7 +137,7 @@ func (m *Model) refreshLive() {
}
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
}
}
+4 -32
View File
@@ -49,13 +49,13 @@ func siteOrder(s models.Site) int {
return 3
}
switch s.Status {
case models.StatusDown, models.StatusSSLExp:
case "DOWN", "SSL EXP":
return 0
case models.StatusStale:
case "STALE":
return 1
case models.StatusLate:
case "LATE":
return 1
case models.StatusPending:
case "PENDING":
return 3
default:
return 2
@@ -104,13 +104,6 @@ func (m Model) fmtLatency(d time.Duration) string {
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 {
if len(statuses) == 0 {
return m.st.subtleStyle.Render("—")
@@ -162,27 +155,6 @@ func (m Model) fmtRetries(site models.Site) string {
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 {
if paused {
return m.st.warnStyle.Render("◇ PAUSED")
-83
View File
@@ -6,85 +6,6 @@ import (
"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 {
borderColor := m.theme.Border
titleColor := m.theme.Muted
@@ -129,7 +50,3 @@ func max(a, b int) int {
}
return b
}
func placeOverlay(fg string, termW, termH int) string {
return lipgloss.Place(termW, termH, lipgloss.Center, lipgloss.Center, fg)
}
+4 -2
View File
@@ -156,8 +156,9 @@ func resolveSparklineIndex(x, sparkWidth, dataLen int) int {
}
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor) string {
allSites := m.engine.GetAllSites()
var childStatuses [][]bool
for _, s := range m.sites {
for _, s := range allSites {
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
hist, _ := m.engine.GetHistory(s.ID)
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 {
allSites := m.engine.GetAllSites()
var allStatuses [][]bool
for _, s := range m.sites {
for _, s := range allSites {
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
hist, _ := m.engine.GetHistory(s.ID)
if len(hist.Statuses) > 0 {
+5 -14
View File
@@ -292,14 +292,10 @@ func (m Model) fmtAlertConfigFull(alert models.AlertConfig) string {
}
func (m Model) viewAlertDetailPanel() string {
idx := m.cursor
if m.returnState == stateSettings {
idx = m.settingsCursor
}
if idx >= len(m.alerts) {
if m.cursor >= len(m.alerts) {
return ""
}
a := m.alerts[idx]
a := m.alerts[m.cursor]
h := m.engine.GetAlertHealth(a.ID)
var b strings.Builder
@@ -332,7 +328,7 @@ func (m Model) viewAlertDetailPanel() string {
}
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
// ends up in screen shares and asciinema recordings. Keys are sorted so
// rows don't reshuffle every render.
@@ -352,7 +348,7 @@ func (m Model) viewAlertDetailPanel() string {
}
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())
}
@@ -608,12 +604,7 @@ func (m *Model) submitAlertForm() tea.Cmd {
ctx := m.ctx
id := m.editID
name, aType := d.Name, d.AlertType
if m.returnState == stateSettings {
m.state = stateSettings
} else {
m.state = stateDashboard
}
m.returnState = 0
m.state = stateDashboard
if id > 0 {
return writeCmd("Update alert", func() error {
return st.UpdateAlert(ctx, id, name, aType, settings)
+2 -2
View File
@@ -74,14 +74,14 @@ func (m Model) renderLogLine(entry models.LogEntry) 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")
if m.logFilterImportant {
filterLabel = m.st.subtleStyle.Render("[f] Important only")
}
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.Height = m.termHeight - 8
+3 -3
View File
@@ -40,13 +40,13 @@ func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string {
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()
if len(logs) == 0 {
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
for _, entry := range logs {
@@ -69,7 +69,7 @@ func (m Model) viewLogsStrip(width, maxLines int) string {
}
visible := all[start:end]
return style.Render(strings.Join(visible, "\n"))
return sidebarStyle.Render(strings.Join(visible, "\n"))
}
func (m *Model) scrollLogs(delta int) {
+106 -13
View File
@@ -20,37 +20,130 @@ type maintFormData struct {
CustomHours string
}
func (m Model) isMonitorInMaintenance(monitorID int) bool {
return m.maintSet[monitorID]
func (m Model) fmtMaintStatus(mw models.MaintenanceWindow) string {
now := time.Now()
if mw.StartTime.After(now) {
return m.st.warnStyle.Render("SCHEDULED")
}
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
return m.st.subtleStyle.Render("ENDED")
}
return m.st.specialStyle.Render("ACTIVE")
}
func (m *Model) buildMaintSet() {
set := make(map[int]bool)
func (m Model) fmtMaintType(t string) string {
if t == "incident" {
return m.st.dangerStyle.Render("incident")
}
return m.st.maintStyle.Render("maintenance")
}
func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string {
if monitorID == 0 {
return "All"
}
for _, s := range sites {
if s.ID == monitorID {
return limitStr(s.Name, maxW)
}
}
return fmt.Sprintf("#%d", monitorID)
}
func (m Model) fmtMaintTime(t time.Time, colW int) string {
if t.IsZero() {
return m.st.subtleStyle.Render("—")
}
now := time.Now()
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return t.Format("15:04")
}
if colW >= 14 {
return t.Format("15:04 Jan 02")
}
return t.Format("Jan 02")
}
func (m Model) isMonitorInMaintenance(monitorID int) bool {
for _, mw := range m.maintenanceWindows {
if mw.Type != "maintenance" {
continue
}
now := time.Now()
if mw.StartTime.After(now) {
continue
}
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
continue
}
if mw.MonitorID == 0 {
for _, s := range m.sites {
set[s.ID] = true
}
break
if mw.MonitorID == 0 || mw.MonitorID == monitorID {
return true
}
set[mw.MonitorID] = true
for _, s := range m.sites {
if s.ParentID == mw.MonitorID {
set[s.ID] = true
if s.ID == monitorID && s.ParentID > 0 && mw.MonitorID == s.ParentID {
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 {
+7 -7
View File
@@ -8,19 +8,19 @@ import (
func (m Model) viewNodesTab() string {
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 widths []int
if m.isWide() {
headers = []string{"#", "NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}
widths = []int{4, 24, 14, 16, 12, 10}
headers = []string{"NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}
widths = []int{24, 14, 16, 12, 10}
} else {
headers = []string{"#", "NAME", "REGION", "SEEN", "VER", "STATUS"}
widths = []int{4, 16, 10, 10, 8, 8}
headers = []string{"NAME", "REGION", "SEEN", "VER", "STATUS"}
widths = []int{16, 10, 10, 8, 8}
}
nameW := widths[1]
nameW := widths[0]
tbl := m.renderTable(
headers,
@@ -43,7 +43,7 @@ func (m Model) viewNodesTab() string {
version = m.st.subtleStyle.Render("—")
}
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
},
+6
View File
@@ -7,10 +7,16 @@ import (
)
func (m Model) viewSettingsTab() string {
maxSections := 2
if m.isAdmin {
maxSections = 3
}
sections := []string{"Alerts", "Nodes"}
if m.isAdmin {
sections = append(sections, "Users")
}
_ = maxSections
var tabs []string
for i, name := range sections {
+22 -56
View File
@@ -34,9 +34,10 @@ type siteFormData struct {
type colKey int
const (
colDot colKey = iota
colNum colKey = iota
colName
colType
colStatus
colLatency
colUptime
colHistory
@@ -54,18 +55,17 @@ type columnDef struct {
}
var siteColumns = []columnDef{
{colDot, "", "", 3, 3, 0},
{colNum, "#", "#", 4, 4, 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},
{colUptime, "UPTIME", "UP%", 8, 8, 0},
{colHistory, "HISTORY", "HISTORY", 0, 0, 0},
{colSSL, "SSL", "SSL", 7, 5, 0},
{colRetries, "RETRIES", "RT", 9, 5, 0},
{colUptime, "UPTIME", "UP%", 8, 8, mediumBreakpoint},
{colHistory, "HISTORY", "HISTORY", 0, 0, mediumBreakpoint},
{colSSL, "SSL", "SSL", 7, 5, wideBreakpoint},
{colRetries, "RETRIES", "RT", 9, 5, wideBreakpoint},
}
var columnDropOrder = []colKey{colHistory, colRetries, colSSL, colUptime, colType}
type tableLayout struct {
nameW, sparkW int
headers []string
@@ -76,51 +76,17 @@ type tableLayout struct {
func (m Model) computeLayout() tableLayout {
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 headers []string
var widths []int
var fixed int
cw := m.contentWidth
if cw == 0 {
cw = m.termWidth
}
for _, c := range siteColumns {
if dropped[c.key] {
if c.minTerm > 0 && cw < c.minTerm {
continue
}
active = append(active, c.key)
@@ -140,12 +106,12 @@ func (m Model) computeLayout() tableLayout {
}
sortColMap := map[int]colKey{
sortStatus: colDot,
sortStatus: colStatus,
sortName: colName,
sortLatency: colLatency,
}
sortableKeys := map[colKey]string{
colDot: "sort-status",
colStatus: "sort-status",
colName: "sort-name",
colLatency: "sort-latency",
}
@@ -279,11 +245,11 @@ func (m Model) viewSitesTab() string {
if site.Type == "group" {
groupRows[i-start] = true
icon := typeIcon("group", m.collapsed[site.ID])
inMaint := m.isMonitorInMaintenance(site.ID)
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)),
colType: "group",
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
colLatency: m.st.subtleStyle.Render("—"),
colUptime: m.groupUptime(site.ID),
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)
}
inMaint := m.isMonitorInMaintenance(site.ID)
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),
colType: typeIcon(site.Type, false) + " " + site.Type,
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
colLatency: m.fmtLatency(site.Latency),
colUptime: m.fmtUptimeMaint(hist.Statuses, site.ID),
colUptime: m.fmtUptime(hist.Statuses),
colHistory: spark,
colSSL: m.fmtSSL(site),
colRetries: m.fmtRetries(site),
+1 -6
View File
@@ -116,12 +116,7 @@ func (m *Model) submitUserForm() tea.Cmd {
ctx := m.ctx
id := m.editID
username, key, role := d.Username, d.PublicKey, d.Role
if m.returnState == stateSettings {
m.state = stateSettings
} else {
m.state = stateDashboard
}
m.returnState = 0
m.state = stateDashboard
if id > 0 {
return writeCmd("Update user", func() error {
return st.UpdateUser(ctx, id, username, key, role)
+51 -59
View File
@@ -80,6 +80,14 @@ const (
chromeFooter = 2 // footer: "\n" prefix + text line
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
detailSparkWidth = 40
)
const (
tabMonitors = 0
tabMaint = 1
tabSettings = 2
)
const (
@@ -92,21 +100,6 @@ const (
panelMonitors = 0
panelLogs = 1
panelDetail = 2
panelMaint = 3
)
type bottomPanel int
const (
bottomNone bottomPanel = iota
bottomLogs
bottomMaint
)
const (
detailDefault = 0
detailSLA = 1
detailHistory = 2
)
const (
@@ -119,25 +112,24 @@ const (
type sessionState int
const (
stateDashboard sessionState = 0
stateLogs sessionState = 1
stateDetailFullscreen sessionState = 2
stateAlertDetail sessionState = 3
stateFormSite sessionState = 4
stateFormAlert sessionState = 5
stateFormUser sessionState = 6
stateConfirmDelete sessionState = 7
stateFormMaint sessionState = 8
stateSettings sessionState = 11
stateMaintDetail sessionState = 12
stateDashboard sessionState = iota
stateLogs
stateUsers
stateDetail
stateAlertDetail
stateFormSite
stateFormAlert
stateFormUser
stateConfirmDelete
stateFormMaint
stateHistory
stateSLA
)
type Model struct {
state sessionState
returnState sessionState
currentTab int
settingsSection int
settingsCursor int
settingsOffset int
cursor int
selectedID int
sortColumn int
@@ -148,7 +140,6 @@ type Model struct {
termHeight int
contentWidth int
focusedPanel int
detailMode int
logScrollOffset int
editID int
editToken string
@@ -165,25 +156,24 @@ type Model struct {
logTotal int
logShown int
historyViewport viewport.Model
historyChanges []models.StateChange
historySiteName string
historySiteID int
slaReport monitor.SLAReport
slaDailyBreakdown []monitor.DayReport
slaSiteName string
slaSiteID int
slaPeriodIdx int
detailScrollOffset int
slaViewport viewport.Model
slaReport monitor.SLAReport
slaDailyBreakdown []monitor.DayReport
slaSiteName string
slaSiteID int
slaPeriodIdx int
isAdmin bool
zones *zone.Manager
deleteID int
deleteName string
deleteKind string
maintDetailID int
deleteTab int
ctx context.Context
collapsed map[int]bool
@@ -207,17 +197,18 @@ type Model struct {
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
tabSeq int // seq of the newest issued tab-data load
maintSet map[int]bool
bottomPanel bottomPanel
logsOpen bool
detailOpen bool
maintCursor int
detailChanges []models.StateChange
detailChangesSiteID int
detailDailyDays []monitor.DayReport
detailViewport viewport.Model
filterMode bool
filterText string
sparkTooltipIdx int // clicked sparkline data index, -1 = none
// 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.
demoMode bool
@@ -244,23 +235,24 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
detailPref, _ := s.GetPreference(ctx, "detail_open")
return Model{
ctx: ctx,
state: stateDashboard,
logViewport: vpLogs,
maxTableRows: 5,
isAdmin: isAdmin,
store: s,
engine: eng,
zones: z,
pulseSpring: spring,
collapsed: collapsed,
theme: theme,
themeIndex: themeIdx,
st: newStyles(theme),
bottomPanel: bottomLogs,
detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version,
ctx: ctx,
state: stateDashboard,
logViewport: vpLogs,
maxTableRows: 5,
isAdmin: isAdmin,
store: s,
engine: eng,
zones: z,
pulseSpring: spring,
collapsed: collapsed,
theme: theme,
themeIndex: themeIdx,
st: newStyles(theme),
logsOpen: true,
detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version,
sparkTooltipIdx: -1,
}
}
+493 -473
View File
File diff suppressed because it is too large Load Diff
+21 -159
View File
@@ -118,10 +118,11 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}}
m.cursor = 0
m.detailOpen = true
m.state = stateDetail
m.termWidth = 120
m.termHeight = 40
// Entering detail dispatches the load Cmd.
cmd := m.loadDetailCmd(1)
if cmd == 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)
}
// Apply the msg through Update (caches into the model).
updated, _ := m.Update(dd)
m = updated.(Model)
if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 {
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++ {
_ = m.viewDetailInline(80, 30)
_ = m.viewDetailPanel()
}
if ms.stateChangeCalls != 1 {
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"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}}
m.detailOpen = true
m.state = stateDetail
m.termWidth, m.termHeight = 120, 40
updated, cmd := (&m).handleDashboardKey(keyMsg("h"))
updated, cmd := (&m).handleDetailKey(keyMsg("h"))
if ms.stateChangeCalls != 0 {
t.Fatal("history keypress hit the store synchronously in Update")
}
got := updated.(*Model)
if got.detailMode != detailHistory || got.historySiteID != 7 {
t.Fatalf("history mode not set: mode=%v siteID=%d", got.detailMode, got.historySiteID)
if got.state != stateHistory || got.historySiteID != 7 {
t.Fatalf("history view not opened: state=%v siteID=%d", got.state, got.historySiteID)
}
if cmd == nil {
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")
}
// A reply for a previously opened site must not clobber the current one.
m2.historySiteID = 9
stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil})
if m3 := stale.(Model); len(m3.historyChanges) != 1 {
@@ -237,15 +241,20 @@ func TestSLAData_DropsStaleReply(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.termWidth, m.termHeight = 120, 40
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})
if mm := updated.(*Model); mm.slaDailyBreakdown != nil {
t.Error("stale SLA reply (old period) was applied")
}
// Matching reply → report computed.
updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx})
if mm := updated.(*Model); mm.slaDailyBreakdown == nil {
t.Error("matching SLA reply was not applied")
@@ -257,7 +266,7 @@ func TestConfirmDelete_WritesOffUIGoroutine(t *testing.T) {
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 4, Name: "s"}}}
m.state = stateConfirmDelete
m.deleteKind = "site"
m.deleteTab = 0
m.deleteID = 4
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) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
m.detailOpen = false
m.state = stateDashboard
if (&m).detailRefreshCmd() != nil {
t.Error("refresh Cmd issued outside the detail view")
}
m.detailOpen = true
m.state = stateDetail
cmd := (&m).detailRefreshCmd()
if cmd == nil {
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")
}
}
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
View File
@@ -47,12 +47,12 @@ func (m Model) View() string {
switch m.state {
case stateConfirmDelete:
kind := "monitor"
switch m.deleteKind {
case "maint":
kind = "maintenance window"
case "alert":
switch m.deleteTab {
case 1:
kind = "alert"
case "user":
case 4:
kind = "maintenance window"
case 5:
kind = "user"
}
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 ""
case stateDetail:
return m.zones.Scan(m.viewDetailPanel())
case stateLogs:
return m.viewLogsFullscreen()
case stateDetailFullscreen:
return m.viewDetailFullscreen()
case stateHistory:
return m.viewHistoryPanel()
case stateSLA:
return m.viewSLAPanel()
case stateAlertDetail:
return m.viewAlertDetailPanel()
case stateSettings:
return m.viewSettingsOverlay()
case stateMaintDetail:
return m.viewMaintDetailPanel()
default:
return m.zones.Scan(m.viewDashboard())
}
@@ -145,67 +145,61 @@ func (m Model) computeStats() dashboardStats {
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 {
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)
footer := m.renderFooter(stats)
@@ -217,17 +211,74 @@ func (m Model) viewDashboard() string {
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 {
contentHeight = 1
}
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 {
dot := m.st.subtleStyle.Render(" · ")
type tabEntry struct {
name string
count int
warn int
}
func (m Model) renderTabBar(stats dashboardStats) string {
settingsCount := len(m.alerts) + len(m.nodes)
settingsWarn := stats.offlineNodes
for _, a := range m.alerts {
h := m.engine.GetAlertHealth(a.ID)
if !h.LastSendOK && !h.LastSendAt.IsZero() {
settingsWarn++
}
}
if m.isAdmin {
settingsCount += len(m.users)
}
tabs := []tabEntry{
{"Monitors", stats.totalMonitors, stats.downCount + stats.lateCount},
{"Maint", len(m.maintenanceWindows), stats.activeMaint},
{"Settings", settingsCount, settingsWarn},
}
countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted)
var renderedTabs []string
for i, t := range tabs {
label := t.name
if t.count > 0 {
badge := countStyle.Render(fmt.Sprintf(" %d", t.count))
if t.warn > 0 {
badge = m.st.dangerStyle.Render(fmt.Sprintf(" %d", t.warn))
}
label += badge
}
var rendered string
if i == m.currentTab {
rendered = m.st.activeTab.Render(label)
} else {
rendered = m.st.inactiveTab.Render(label)
}
renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered))
}
return lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...)
}
func (m Model) renderFooter(stats dashboardStats) string {
if m.filterMode {
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
return "\n" + m.st.titleStyle.Render("/") + " " + m.filterText + cursor + " " + m.st.subtleStyle.Render("[Enter]Apply [Esc]Clear")
}
upCount := stats.totalMonitors - stats.downCount - stats.lateCount
var upStr string
@@ -238,14 +289,9 @@ func (m Model) renderStatusLine(stats dashboardStats) string {
} else {
upStr = m.st.specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors))
}
parts := []string{m.pulseIndicator() + " " + upStr}
statusParts := []string{upStr}
if stats.lateCount > 0 {
parts = append(parts, m.st.warnStyle.Render(fmt.Sprintf("%d late", stats.lateCount)))
}
if stats.activeMaint > 0 {
parts = append(parts, m.st.maintStyle.Render(fmt.Sprintf("%d maint", stats.activeMaint)))
statusParts = append(statusParts, m.st.warnStyle.Render(fmt.Sprintf("%d LATE", stats.lateCount)))
}
if len(m.nodes) > 0 {
online := 0
@@ -254,67 +300,48 @@ func (m Model) renderStatusLine(stats dashboardStats) string {
online++
}
}
label := "probes"
probeLabel := "probes"
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)
padW := m.termWidth - chromePadH - lipgloss.Width(left) - lipgloss.Width(ver)
if padW < 2 {
padW = 2
line := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
if m.filterText != "" && m.currentTab == tabMonitors {
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
}
return left + strings.Repeat(" ", padW) + ver
}
func (m Model) hotkey(key, desc string) string {
k := lipgloss.NewStyle().Foreground(m.theme.Accent).Render(key)
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
divW := m.termWidth - chromePadH
if divW < 40 {
divW = 40
}
return m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n" + line
}
+334
View File
@@ -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
View File
@@ -2,7 +2,6 @@ package tui
import (
"fmt"
"sort"
"strings"
"time"
@@ -11,250 +10,128 @@ import (
"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) {
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]
hist, _ := m.engine.GetHistory(site.ID)
var title string
switch m.detailMode {
case detailSLA:
title = "SLA · " + site.Name
case detailHistory:
title = "History · " + site.Name
default:
title = site.Name
if width < detailTwoColMinWidth {
return m.viewDetailSingleCol(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))
return m.viewDetailTwoCol(site, hist, width)
}
func (m Model) buildDetailContent(site models.Site, hist monitor.SiteHistory, width int, fullscreen bool) string {
dot := m.st.subtleStyle.Render(" · ")
label := m.st.subtleStyle
innerW := width - 4
if innerW < 20 {
innerW = 20
func (m Model) viewDetailTwoCol(site models.Site, hist monitor.SiteHistory, width int) string {
leftW := width * 55 / 100
rightW := width - leftW - 3 // 3 for " │ " divider
left := m.detailLeftCol(site, hist, leftW)
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
// Status + latency + last check + state since
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
statusParts := []string{status}
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")
for i := range lineCount {
l := leftStyle.Render(leftLines[i])
r := rightStyle.Render(rightLines[i])
b.WriteString(l + " " + divChar + " " + r + "\n")
}
// Extended endpoint fields
m.writeEndpointFields(&b, site, label, innerW, fullscreen)
keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close")
b.WriteString(" " + keys + "\n")
// Uptime + retries + last success
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")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
// Maintenance window name
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) {
b.WriteString(" " + label.Render("Maint") + " " + m.st.maintStyle.Render(mw.Title) + "\n")
break
}
}
}
func (m Model) detailLeftCol(site models.Site, hist monitor.SiteHistory, width int) string {
var b strings.Builder
// 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 {
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 != "" {
b.WriteString(chart + "\n")
}
}
// 30d uptime timeline
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
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
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)
fmt.Fprintf(&b, " %s %dms %s %dms %s %dms\n",
label.Render("Min"), minL.Milliseconds(),
label.Render("Avg"), avg.Milliseconds(),
label.Render("Max"), maxL.Milliseconds())
}
return strings.TrimRight(b.String(), "\n")
}
func (m Model) detailRightCol(site models.Site, hist monitor.SiteHistory, width int) string {
dot := m.st.subtleStyle.Render(" · ")
label := m.st.subtleStyle
var b strings.Builder
// Line 1: status + latency + last check
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
parts := []string{status}
if site.Latency > 0 {
parts = append(parts, m.fmtLatency(site.Latency))
}
if !site.LastCheck.IsZero() {
parts = append(parts, m.fmtTimeAgo(site.LastCheck))
}
b.WriteString(strings.Join(parts, dot) + "\n")
// Line 2: type-specific details
typeParts := m.detailTypeLine(site)
if len(typeParts) > 0 {
b.WriteString(strings.Join(typeParts, dot) + "\n")
}
// Latency histogram
if site.Type != "push" && len(hist.Latencies) > 5 {
histContent := m.latencyHistogram(hist.Latencies, hist.Statuses, innerW)
if histContent != "" {
b.WriteString("\n")
b.WriteString(histContent)
// Line 3: uptime + retries
uptimeParts := []string{label.Render("Uptime") + " " + m.fmtUptime(hist.Statuses)}
if site.Type != "group" && site.MaxRetries > 0 {
uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site))
}
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")
// State changes
// State changes (one per line, compact)
var stateChanges []models.StateChange
if m.detailChangesSiteID == site.ID {
stateChanges = m.detailChanges
@@ -269,62 +146,19 @@ func (m Model) buildDetailContent(site models.Site, hist monitor.SiteHistory, wi
arrow := m.st.subtleStyle.Render("→")
from := m.fmtStatusWord(sc.FromStatus)
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 != "" {
reasonW := innerW - 25
reasonW := width - 30
if reasonW < 15 {
reasonW = 15
}
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())
}
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")
}
return strings.TrimRight(b.String(), "\n")
}
func (m Model) detailTypeLine(site models.Site) []string {
@@ -391,168 +225,87 @@ func (m Model) detailTypeLine(site models.Site) []string {
return parts
}
func (m Model) detailFooter(width int) string {
dot := m.st.subtleStyle.Render(" · ")
var parts []string
// viewDetailSingleCol is the narrow-terminal fallback (original stacked layout).
func (m Model) viewDetailSingleCol(site models.Site, hist monitor.SiteHistory, width int) string {
var b strings.Builder
dot := m.st.subtleStyle.Render(" · ")
switch m.detailMode {
case detailSLA:
for i, p := range slaPeriods {
if i == m.slaPeriodIdx {
parts = append(parts, m.st.titleStyle.Render(p.key)+" "+m.st.titleStyle.Render(p.label))
} else {
parts = append(parts, m.hotkey(p.key, p.label))
}
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
parts := []string{status}
if site.Latency > 0 {
parts = append(parts, m.fmtLatency(site.Latency))
}
parts = append(parts, fmt.Sprintf("Uptime %s", m.fmtUptime(hist.Statuses)))
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"))
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"))
b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
}
content := " " + strings.Join(parts, dot)
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(content)
var stateChanges []models.StateChange
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 {
switch status {
case "DOWN", "SSL EXP":
return m.st.dangerStyle.Render(status)
case "DOWN":
return m.st.dangerStyle.Render("DOWN")
case "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:
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())
}
+87
View File
@@ -1,10 +1,12 @@
package tui
import (
"fmt"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
)
type historyStats struct {
@@ -103,3 +105,88 @@ func (m Model) stateChangeSparkline(changes []models.StateChange, width int) str
}
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())
}
-199
View File
@@ -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
}
}
-87
View File
@@ -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")
}
+88
View File
@@ -5,6 +5,8 @@ import (
"math"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
var slaPeriods = []struct {
@@ -19,6 +21,92 @@ var slaPeriods = []struct {
{"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 {
filled := int(math.Round(pct / 100 * float64(width)))
if filled > width {