Compare commits
26 Commits
dc79e2baaa
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
c1955a4c80
|
|||
|
ce3abda5b9
|
|||
|
ce3cfad7f8
|
|||
|
d6e6011b35
|
|||
|
1cfa0571c8
|
|||
|
d6ba7d9af8
|
|||
|
14cec4283d
|
|||
|
a32a443a4a
|
|||
|
b90033c7f0
|
|||
|
989dd1fb39
|
|||
|
faf7d36c64
|
|||
|
16f0c2eb66
|
|||
|
f0d97f5562
|
|||
|
0484153103
|
|||
|
e3d681311f
|
|||
|
f7303c946c
|
|||
|
6e936ecce3
|
|||
|
04cf12f52b
|
|||
|
33df597dda
|
|||
|
1d14f640f4
|
|||
|
631f07c242
|
|||
|
0badc2ddf5
|
|||
|
be14436701
|
|||
|
efa8894b18
|
|||
|
ef54b36e0d
|
|||
|
835844314e
|
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# --- Stage 1: Builder ---
|
# --- Stage 1: Builder ---
|
||||||
FROM golang:1.26.4-alpine3.23@sha256:f23e8b227fb4493eabe03bede4d5a32d04092da71962f1fb79b5f7d1e6c2a17f AS builder
|
FROM golang:1.26.5-alpine3.23@sha256:622e56dbc11a8cfe87cafa2331e9a201877271cbff918af53d3be315f3da88cc AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module gitea.lerkolabs.com/lerkolabs/uptop
|
module gitea.lerkolabs.com/lerkolabs/uptop
|
||||||
|
|
||||||
go 1.26.4
|
go 1.26.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||||
|
|||||||
+129
-2
@@ -50,6 +50,21 @@ func writeCmd(op string, fn func() error) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) saveBottomPanelPref() tea.Cmd {
|
||||||
|
v := "logs"
|
||||||
|
switch m.bottomPanel {
|
||||||
|
case bottomNone:
|
||||||
|
v = "none"
|
||||||
|
case bottomMaint:
|
||||||
|
v = "maint"
|
||||||
|
}
|
||||||
|
st := m.store
|
||||||
|
ctx := m.ctx
|
||||||
|
return writeCmd("Save bottom panel preference", func() error {
|
||||||
|
return st.SetPreference(ctx, "bottom_panel", v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol int, sortAsc bool) []models.Site {
|
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol int, sortAsc bool) []models.Site {
|
||||||
var groups, ungrouped []models.Site
|
var groups, ungrouped []models.Site
|
||||||
children := make(map[int][]models.Site)
|
children := make(map[int][]models.Site)
|
||||||
@@ -62,8 +77,6 @@ func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol
|
|||||||
ungrouped = append(ungrouped, s)
|
ungrouped = append(ungrouped, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID })
|
|
||||||
|
|
||||||
sortSlice := func(s []models.Site) {
|
sortSlice := func(s []models.Site) {
|
||||||
sort.Slice(s, func(i, j int) bool { return s[i].ID < s[j].ID })
|
sort.Slice(s, func(i, j int) bool { return s[i].ID < s[j].ID })
|
||||||
sort.SliceStable(s, func(i, j int) bool {
|
sort.SliceStable(s, func(i, j int) bool {
|
||||||
@@ -83,6 +96,7 @@ func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sortSlice(groups)
|
||||||
for pid := range children {
|
for pid := range children {
|
||||||
c := children[pid]
|
c := children[pid]
|
||||||
sortSlice(c)
|
sortSlice(c)
|
||||||
@@ -123,6 +137,7 @@ 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.selectedID != 0 {
|
||||||
@@ -153,6 +168,118 @@ func (m *Model) clampCursor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) detailCmdIfNeeded() tea.Cmd {
|
||||||
|
if m.focusedPanel == panelMonitors && m.detailOpen && m.cursor < len(m.sites) {
|
||||||
|
m.detailMode = detailDefault
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
return m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) jumpToTop() {
|
||||||
|
switch m.focusedPanel {
|
||||||
|
case panelDetail:
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
case panelMaint:
|
||||||
|
m.maintCursor = 0
|
||||||
|
case panelLogs:
|
||||||
|
m.logScrollOffset = 0
|
||||||
|
default:
|
||||||
|
m.cursor = 0
|
||||||
|
m.tableOffset = 0
|
||||||
|
m.syncSelectedID()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) jumpToBottom() {
|
||||||
|
switch m.focusedPanel {
|
||||||
|
case panelDetail:
|
||||||
|
m.detailScrollOffset = 9999
|
||||||
|
case panelMaint:
|
||||||
|
windows := m.activeMaintWindows()
|
||||||
|
if len(windows) > 0 {
|
||||||
|
m.maintCursor = len(windows) - 1
|
||||||
|
}
|
||||||
|
case panelLogs:
|
||||||
|
total := m.filteredLogCount()
|
||||||
|
if total > 0 {
|
||||||
|
m.logScrollOffset = total - 1
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
max := m.currentListLen() - 1
|
||||||
|
if max >= 0 {
|
||||||
|
m.cursor = max
|
||||||
|
if m.cursor >= m.tableOffset+m.maxTableRows {
|
||||||
|
m.tableOffset = m.cursor - m.maxTableRows + 1
|
||||||
|
}
|
||||||
|
m.syncSelectedID()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) halfPageUp() {
|
||||||
|
half := m.maxTableRows / 2
|
||||||
|
if half < 1 {
|
||||||
|
half = 1
|
||||||
|
}
|
||||||
|
switch m.focusedPanel {
|
||||||
|
case panelDetail:
|
||||||
|
m.detailScrollOffset -= half
|
||||||
|
if m.detailScrollOffset < 0 {
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
}
|
||||||
|
case panelMaint:
|
||||||
|
m.maintCursor -= half
|
||||||
|
if m.maintCursor < 0 {
|
||||||
|
m.maintCursor = 0
|
||||||
|
}
|
||||||
|
case panelLogs:
|
||||||
|
m.scrollLogs(-half)
|
||||||
|
default:
|
||||||
|
m.cursor -= half
|
||||||
|
if m.cursor < 0 {
|
||||||
|
m.cursor = 0
|
||||||
|
}
|
||||||
|
if m.cursor < m.tableOffset {
|
||||||
|
m.tableOffset = m.cursor
|
||||||
|
}
|
||||||
|
m.syncSelectedID()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) halfPageDown() {
|
||||||
|
half := m.maxTableRows / 2
|
||||||
|
if half < 1 {
|
||||||
|
half = 1
|
||||||
|
}
|
||||||
|
switch m.focusedPanel {
|
||||||
|
case panelDetail:
|
||||||
|
m.detailScrollOffset += half
|
||||||
|
case panelMaint:
|
||||||
|
windows := m.activeMaintWindows()
|
||||||
|
m.maintCursor += half
|
||||||
|
if len(windows) > 0 && m.maintCursor >= len(windows) {
|
||||||
|
m.maintCursor = len(windows) - 1
|
||||||
|
}
|
||||||
|
case panelLogs:
|
||||||
|
m.scrollLogs(half)
|
||||||
|
default:
|
||||||
|
max := m.currentListLen() - 1
|
||||||
|
m.cursor += half
|
||||||
|
if m.cursor > max {
|
||||||
|
m.cursor = max
|
||||||
|
}
|
||||||
|
if m.cursor < 0 {
|
||||||
|
m.cursor = 0
|
||||||
|
}
|
||||||
|
if m.cursor >= m.tableOffset+m.maxTableRows {
|
||||||
|
m.tableOffset = m.cursor - m.maxTableRows + 1
|
||||||
|
}
|
||||||
|
m.syncSelectedID()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// loadTabDataCmd returns a tea.Cmd that loads the DB-backed tab tables off the
|
// loadTabDataCmd returns a tea.Cmd that loads the DB-backed tab tables off the
|
||||||
// UI goroutine. Each call bumps tabSeq and stamps the reply with it, so
|
// UI goroutine. Each call bumps tabSeq and stamps the reply with it, so
|
||||||
// handleTabData can drop out-of-order results from slower earlier loads. The
|
// handleTabData can drop out-of-order results from slower earlier loads. The
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ 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("—")
|
||||||
@@ -155,6 +162,27 @@ 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")
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
type latencyBucket struct {
|
||||||
|
label string
|
||||||
|
min int64
|
||||||
|
max int64
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) latencyHistogram(latencies []time.Duration, statuses []bool, width int) string {
|
||||||
|
if len(latencies) == 0 || width < 30 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets := []latencyBucket{
|
||||||
|
{"0-50ms", 0, 50, 0},
|
||||||
|
{"50-100ms", 50, 100, 0},
|
||||||
|
{"100-200ms", 100, 200, 0},
|
||||||
|
{"200-500ms", 200, 500, 0},
|
||||||
|
{"500ms+", 500, 999999, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, l := range latencies {
|
||||||
|
if i < len(statuses) && !statuses[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ms := l.Milliseconds()
|
||||||
|
for j := range buckets {
|
||||||
|
if ms >= buckets[j].min && ms < buckets[j].max {
|
||||||
|
buckets[j].count++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxCount := 0
|
||||||
|
for _, b := range buckets {
|
||||||
|
if b.count > maxCount {
|
||||||
|
maxCount = b.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxCount == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
labelW := 10
|
||||||
|
countW := len(fmt.Sprintf("%d", maxCount))
|
||||||
|
barW := width - labelW - countW - 4
|
||||||
|
if barW < 5 {
|
||||||
|
barW = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, b := range buckets {
|
||||||
|
fill := 0
|
||||||
|
if maxCount > 0 {
|
||||||
|
fill = b.count * barW / maxCount
|
||||||
|
}
|
||||||
|
|
||||||
|
var barColor lipgloss.Style
|
||||||
|
switch {
|
||||||
|
case b.min < 100:
|
||||||
|
barColor = m.st.specialStyle
|
||||||
|
case b.min < 500:
|
||||||
|
barColor = m.st.warnStyle
|
||||||
|
default:
|
||||||
|
barColor = m.st.dangerStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
bar := barColor.Render(strings.Repeat("█", fill))
|
||||||
|
if fill < barW {
|
||||||
|
bar += m.st.subtleStyle.Render(strings.Repeat("░", barW-fill))
|
||||||
|
}
|
||||||
|
|
||||||
|
label := fmt.Sprintf("%*s", labelW, b.label)
|
||||||
|
count := fmt.Sprintf("%*d", countW, b.count)
|
||||||
|
sb.WriteString(" " + m.st.subtleStyle.Render(label) + " " + bar + " " + count + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -6,6 +6,131 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type scrollbar struct {
|
||||||
|
pos int
|
||||||
|
total int
|
||||||
|
visible int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset int, sb scrollbar, 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]
|
||||||
|
|
||||||
|
if sb.total == 0 && len(contentLines) > bodyH {
|
||||||
|
sb = scrollbar{pos: scrollOffset, total: len(contentLines), visible: bodyH}
|
||||||
|
}
|
||||||
|
sbVisible := sb.visible
|
||||||
|
if sbVisible <= 0 {
|
||||||
|
sbVisible = bodyH
|
||||||
|
}
|
||||||
|
showScrollbar := sb.total > 0 && sb.total > sbVisible
|
||||||
|
var thumbStart, thumbEnd int
|
||||||
|
if showScrollbar {
|
||||||
|
thumbSize := bodyH * sbVisible / sb.total
|
||||||
|
if thumbSize < 1 {
|
||||||
|
thumbSize = 1
|
||||||
|
}
|
||||||
|
scrollRange := sb.total - sbVisible
|
||||||
|
if scrollRange < 1 {
|
||||||
|
scrollRange = 1
|
||||||
|
}
|
||||||
|
trackSpace := bodyH - thumbSize
|
||||||
|
thumbStart = sb.pos * trackSpace / scrollRange
|
||||||
|
if thumbStart < 0 {
|
||||||
|
thumbStart = 0
|
||||||
|
}
|
||||||
|
thumbEnd = thumbStart + thumbSize
|
||||||
|
if thumbEnd > bodyH {
|
||||||
|
thumbEnd = bodyH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTrack := lipgloss.NewStyle().Foreground(m.theme.Border).Render("░")
|
||||||
|
scrollThumb := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("█")
|
||||||
|
|
||||||
|
borderLine := func(line string, idx int) string {
|
||||||
|
rightBorder := bc.Render("│")
|
||||||
|
if showScrollbar && idx >= thumbStart && idx < thumbEnd {
|
||||||
|
rightBorder = scrollThumb
|
||||||
|
} else if showScrollbar {
|
||||||
|
rightBorder = scrollTrack
|
||||||
|
}
|
||||||
|
return bc.Render("│") + line + strings.Repeat(" ", max(0, innerW-lipgloss.Width(line))) + rightBorder
|
||||||
|
}
|
||||||
|
emptyLine := func(idx int) string {
|
||||||
|
return borderLine(strings.Repeat(" ", innerW), idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, top)
|
||||||
|
for i, line := range visible {
|
||||||
|
lines = append(lines, borderLine(line, i))
|
||||||
|
}
|
||||||
|
for i := len(visible); len(lines) < height-1-len(footerLines); i++ {
|
||||||
|
lines = append(lines, emptyLine(i))
|
||||||
|
}
|
||||||
|
for _, line := range footerLines {
|
||||||
|
lines = append(lines, borderLine(line, -1))
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|||||||
@@ -156,9 +156,8 @@ 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 allSites {
|
for _, s := range m.sites {
|
||||||
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 {
|
||||||
@@ -209,9 +208,8 @@ 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 allSites {
|
for _, s := range m.sites {
|
||||||
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 {
|
||||||
|
|||||||
@@ -72,6 +72,20 @@ func (m Model) viewLogsStrip(width, maxLines int) string {
|
|||||||
return style.Render(strings.Join(visible, "\n"))
|
return style.Render(strings.Join(visible, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) filteredLogCount() int {
|
||||||
|
count := 0
|
||||||
|
for _, entry := range m.engine.GetLogs() {
|
||||||
|
if strings.TrimSpace(entry.Message) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) scrollLogs(delta int) {
|
func (m *Model) scrollLogs(delta int) {
|
||||||
logs := m.engine.GetLogs()
|
logs := m.engine.GetLogs()
|
||||||
total := 0
|
total := 0
|
||||||
|
|||||||
@@ -21,27 +21,36 @@ type maintFormData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) isMonitorInMaintenance(monitorID int) bool {
|
func (m Model) isMonitorInMaintenance(monitorID int) bool {
|
||||||
|
return m.maintSet[monitorID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) buildMaintSet() {
|
||||||
|
set := make(map[int]bool)
|
||||||
|
now := time.Now()
|
||||||
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 || mw.MonitorID == monitorID {
|
if mw.MonitorID == 0 {
|
||||||
return true
|
for _, s := range m.sites {
|
||||||
|
set[s.ID] = true
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
set[mw.MonitorID] = true
|
||||||
for _, s := range m.sites {
|
for _, s := range m.sites {
|
||||||
if s.ID == monitorID && s.ParentID > 0 && mw.MonitorID == s.ParentID {
|
if s.ParentID == mw.MonitorID {
|
||||||
return true
|
set[s.ID] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
m.maintSet = set
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) initMaintHuhForm() tea.Cmd {
|
func (m *Model) initMaintHuhForm() tea.Cmd {
|
||||||
|
|||||||
+56
-22
@@ -34,10 +34,9 @@ type siteFormData struct {
|
|||||||
type colKey int
|
type colKey int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
colNum colKey = iota
|
colDot colKey = iota
|
||||||
colName
|
colName
|
||||||
colType
|
colType
|
||||||
colStatus
|
|
||||||
colLatency
|
colLatency
|
||||||
colUptime
|
colUptime
|
||||||
colHistory
|
colHistory
|
||||||
@@ -55,17 +54,18 @@ type columnDef struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var siteColumns = []columnDef{
|
var siteColumns = []columnDef{
|
||||||
{colNum, "#", "#", 4, 4, 0},
|
{colDot, "", "", 3, 3, 0},
|
||||||
{colName, "NAME", "NAME", 0, 0, 0},
|
{colName, "NAME", "NAME", 0, 0, 0},
|
||||||
{colType, "TYPE", "TYPE", 10, 8, mediumBreakpoint},
|
{colType, "TYPE", "TYPE", 10, 8, 0},
|
||||||
{colStatus, "STATUS", "STATUS", 10, 10, 0},
|
|
||||||
{colLatency, "LATENCY", "LAT", 10, 7, 0},
|
{colLatency, "LATENCY", "LAT", 10, 7, 0},
|
||||||
{colUptime, "UPTIME", "UP%", 8, 8, mediumBreakpoint},
|
{colUptime, "UPTIME", "UP%", 8, 8, 0},
|
||||||
{colHistory, "HISTORY", "HISTORY", 0, 0, mediumBreakpoint},
|
{colHistory, "HISTORY", "HISTORY", 0, 0, 0},
|
||||||
{colSSL, "SSL", "SSL", 7, 5, wideBreakpoint},
|
{colSSL, "SSL", "SSL", 7, 5, 0},
|
||||||
{colRetries, "RETRIES", "RT", 9, 5, wideBreakpoint},
|
{colRetries, "RETRIES", "RT", 9, 5, 0},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,17 +76,51 @@ type tableLayout struct {
|
|||||||
func (m Model) computeLayout() tableLayout {
|
func (m Model) computeLayout() tableLayout {
|
||||||
wide := m.isWide()
|
wide := m.isWide()
|
||||||
|
|
||||||
var active []colKey
|
|
||||||
var headers []string
|
|
||||||
var widths []int
|
|
||||||
var fixed int
|
|
||||||
|
|
||||||
cw := m.contentWidth
|
cw := m.contentWidth
|
||||||
if cw == 0 {
|
if cw == 0 {
|
||||||
cw = m.termWidth
|
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
|
||||||
for _, c := range siteColumns {
|
for _, c := range siteColumns {
|
||||||
if c.minTerm > 0 && cw < c.minTerm {
|
if dropped[c.key] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
active = append(active, c.key)
|
active = append(active, c.key)
|
||||||
@@ -106,12 +140,12 @@ func (m Model) computeLayout() tableLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sortColMap := map[int]colKey{
|
sortColMap := map[int]colKey{
|
||||||
sortStatus: colStatus,
|
sortStatus: colDot,
|
||||||
sortName: colName,
|
sortName: colName,
|
||||||
sortLatency: colLatency,
|
sortLatency: colLatency,
|
||||||
}
|
}
|
||||||
sortableKeys := map[colKey]string{
|
sortableKeys := map[colKey]string{
|
||||||
colStatus: "sort-status",
|
colDot: "sort-status",
|
||||||
colName: "sort-name",
|
colName: "sort-name",
|
||||||
colLatency: "sort-latency",
|
colLatency: "sort-latency",
|
||||||
}
|
}
|
||||||
@@ -245,11 +279,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{
|
||||||
colNum: strconv.Itoa(i + 1),
|
colDot: m.fmtStatusDot(site.Status, site.Paused, inMaint),
|
||||||
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),
|
||||||
@@ -293,13 +327,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{
|
||||||
colNum: strconv.Itoa(i + 1),
|
colDot: m.fmtStatusDot(site.Status, site.Paused, inMaint),
|
||||||
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.fmtUptime(hist.Statuses),
|
colUptime: m.fmtUptimeMaint(hist.Statuses, site.ID),
|
||||||
colHistory: spark,
|
colHistory: spark,
|
||||||
colSSL: m.fmtSSL(site),
|
colSSL: m.fmtSSL(site),
|
||||||
colRetries: m.fmtRetries(site),
|
colRetries: m.fmtRetries(site),
|
||||||
|
|||||||
+91
-47
@@ -46,10 +46,12 @@ type Theme struct {
|
|||||||
|
|
||||||
var themes = []Theme{
|
var themes = []Theme{
|
||||||
themeFlexokiDark,
|
themeFlexokiDark,
|
||||||
|
themeEverforest,
|
||||||
|
themeKanagawa,
|
||||||
themeTokyoNight,
|
themeTokyoNight,
|
||||||
themeCatppuccinMocha,
|
themeCatppuccinMocha,
|
||||||
themeNord,
|
themeRosePine,
|
||||||
themeGruvbox,
|
themeDracula,
|
||||||
}
|
}
|
||||||
|
|
||||||
var themeFlexokiDark = Theme{
|
var themeFlexokiDark = Theme{
|
||||||
@@ -80,7 +82,7 @@ var themeTokyoNight = Theme{
|
|||||||
Panel: cc("#292e42", ""),
|
Panel: cc("#292e42", ""),
|
||||||
Border: cc("#3b4261", "8"),
|
Border: cc("#3b4261", "8"),
|
||||||
Fg: cc("#c0caf5", "15"),
|
Fg: cc("#c0caf5", "15"),
|
||||||
Muted: cc("#a9b1d6", "7"),
|
Muted: cc("#7982a9", "7"),
|
||||||
Subtle: cc("#565f89", "7"),
|
Subtle: cc("#565f89", "7"),
|
||||||
Success: cc("#9ece6a", "10"),
|
Success: cc("#9ece6a", "10"),
|
||||||
Warning: cc("#e0af68", "11"),
|
Warning: cc("#e0af68", "11"),
|
||||||
@@ -91,28 +93,28 @@ var themeTokyoNight = Theme{
|
|||||||
Purple: cc("#bb9af7", "13"),
|
Purple: cc("#bb9af7", "13"),
|
||||||
ZebraBg: cc("#1c1d28", ""),
|
ZebraBg: cc("#1c1d28", ""),
|
||||||
SelectedFg: cc("#c0caf5", "15"),
|
SelectedFg: cc("#c0caf5", "15"),
|
||||||
SelectedBg: cc("#292e42", "4"),
|
SelectedBg: cc("#363c53", "4"),
|
||||||
}
|
}
|
||||||
|
|
||||||
var themeGruvbox = Theme{
|
var themeDracula = Theme{
|
||||||
Name: "Gruvbox",
|
Name: "Dracula",
|
||||||
Bg: cc("#282828", ""),
|
Bg: cc("#282a36", ""),
|
||||||
Surface: cc("#3c3836", ""),
|
Surface: cc("#343746", ""),
|
||||||
Panel: cc("#504945", ""),
|
Panel: cc("#44475a", ""),
|
||||||
Border: cc("#665c54", "8"),
|
Border: cc("#6272a4", "8"),
|
||||||
Fg: cc("#ebdbb2", "15"),
|
Fg: cc("#f8f8f2", "15"),
|
||||||
Muted: cc("#bdae93", "7"),
|
Muted: cc("#a9b0cb", "7"),
|
||||||
Subtle: cc("#7c6f64", "7"),
|
Subtle: cc("#6272a4", "7"),
|
||||||
Success: cc("#b8bb26", "10"),
|
Success: cc("#50fa7b", "10"),
|
||||||
Warning: cc("#fabd2f", "11"),
|
Warning: cc("#f1fa8c", "11"),
|
||||||
Stale: cc("#fe8019", "3"),
|
Stale: cc("#ffb86c", "3"),
|
||||||
Danger: cc("#fb4934", "9"),
|
Danger: cc("#ff5555", "9"),
|
||||||
Info: cc("#83a598", "12"),
|
Info: cc("#8be9fd", "12"),
|
||||||
Accent: cc("#8ec07c", "14"),
|
Accent: cc("#bd93f9", "14"),
|
||||||
Purple: cc("#d3869b", "13"),
|
Purple: cc("#ff79c6", "13"),
|
||||||
ZebraBg: cc("#2a2a2a", ""),
|
ZebraBg: cc("#2c2e3a", ""),
|
||||||
SelectedFg: cc("#fbf1c7", "15"),
|
SelectedFg: cc("#f8f8f2", "15"),
|
||||||
SelectedBg: cc("#504945", "4"),
|
SelectedBg: cc("#52556b", "4"),
|
||||||
}
|
}
|
||||||
|
|
||||||
var themeCatppuccinMocha = Theme{
|
var themeCatppuccinMocha = Theme{
|
||||||
@@ -124,37 +126,79 @@ var themeCatppuccinMocha = Theme{
|
|||||||
Fg: cc("#cdd6f4", "15"),
|
Fg: cc("#cdd6f4", "15"),
|
||||||
Muted: cc("#a6adc8", "7"),
|
Muted: cc("#a6adc8", "7"),
|
||||||
Subtle: cc("#6c7086", "7"),
|
Subtle: cc("#6c7086", "7"),
|
||||||
Success: cc("#a6e3a1", "10"),
|
Success: cc("#7dc47a", "10"),
|
||||||
Warning: cc("#f9e2af", "11"),
|
Warning: cc("#f0c644", "11"),
|
||||||
Stale: cc("#fab387", "3"),
|
Stale: cc("#fab387", "3"),
|
||||||
Danger: cc("#f38ba8", "9"),
|
Danger: cc("#e6546e", "9"),
|
||||||
Info: cc("#89b4fa", "12"),
|
Info: cc("#89b4fa", "12"),
|
||||||
Accent: cc("#94e2d5", "14"),
|
Accent: cc("#94e2d5", "14"),
|
||||||
Purple: cc("#cba6f7", "13"),
|
Purple: cc("#cba6f7", "13"),
|
||||||
ZebraBg: cc("#232334", ""),
|
ZebraBg: cc("#212130", ""),
|
||||||
SelectedFg: cc("#cdd6f4", "15"),
|
SelectedFg: cc("#cdd6f4", "15"),
|
||||||
SelectedBg: cc("#45475a", "4"),
|
SelectedBg: cc("#585b70", "4"),
|
||||||
}
|
}
|
||||||
|
|
||||||
var themeNord = Theme{
|
var themeRosePine = Theme{
|
||||||
Name: "Nord",
|
Name: "Rosé Pine",
|
||||||
Bg: cc("#2e3440", ""),
|
Bg: cc("#191724", ""),
|
||||||
Surface: cc("#3b4252", ""),
|
Surface: cc("#1f1d2e", ""),
|
||||||
Panel: cc("#434c5e", ""),
|
Panel: cc("#26233a", ""),
|
||||||
Border: cc("#4c566a", "8"),
|
Border: cc("#524f67", "8"),
|
||||||
Fg: cc("#d8dee9", "15"),
|
Fg: cc("#e0def4", "15"),
|
||||||
Muted: cc("#d8dee9", "7"),
|
Muted: cc("#908caa", "7"),
|
||||||
Subtle: cc("#4c566a", "7"),
|
Subtle: cc("#6e6a86", "7"),
|
||||||
Success: cc("#a3be8c", "10"),
|
Success: cc("#9ccfd8", "10"),
|
||||||
Warning: cc("#ebcb8b", "11"),
|
Warning: cc("#f6c177", "11"),
|
||||||
Stale: cc("#d08770", "3"),
|
Stale: cc("#ebbcba", "3"),
|
||||||
Danger: cc("#bf616a", "9"),
|
Danger: cc("#eb6f92", "9"),
|
||||||
Info: cc("#81a1c1", "12"),
|
Info: cc("#3e8fb0", "12"),
|
||||||
Accent: cc("#88c0d0", "14"),
|
Accent: cc("#c4a7e7", "14"),
|
||||||
Purple: cc("#b48ead", "13"),
|
Purple: cc("#ebbcba", "13"),
|
||||||
ZebraBg: cc("#323845", ""),
|
ZebraBg: cc("#1c1a28", ""),
|
||||||
SelectedFg: cc("#eceff4", "15"),
|
SelectedFg: cc("#e0def4", "15"),
|
||||||
SelectedBg: cc("#434c5e", "4"),
|
SelectedBg: cc("#403d52", "4"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var themeKanagawa = Theme{
|
||||||
|
Name: "Kanagawa",
|
||||||
|
Bg: cc("#1F1F28", ""),
|
||||||
|
Surface: cc("#2A2A37", ""),
|
||||||
|
Panel: cc("#363646", ""),
|
||||||
|
Border: cc("#54546D", "8"),
|
||||||
|
Fg: cc("#DCD7BA", "15"),
|
||||||
|
Muted: cc("#C8C093", "7"),
|
||||||
|
Subtle: cc("#727169", "7"),
|
||||||
|
Success: cc("#98BB6C", "10"),
|
||||||
|
Warning: cc("#E6C384", "11"),
|
||||||
|
Stale: cc("#FFA066", "3"),
|
||||||
|
Danger: cc("#E46876", "9"),
|
||||||
|
Info: cc("#7E9CD8", "12"),
|
||||||
|
Accent: cc("#7FB4CA", "14"),
|
||||||
|
Purple: cc("#D27E99", "13"),
|
||||||
|
ZebraBg: cc("#22222c", ""),
|
||||||
|
SelectedFg: cc("#DCD7BA", "15"),
|
||||||
|
SelectedBg: cc("#2D4F67", "4"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var themeEverforest = Theme{
|
||||||
|
Name: "Everforest",
|
||||||
|
Bg: cc("#2F383E", ""),
|
||||||
|
Surface: cc("#343F44", ""),
|
||||||
|
Panel: cc("#3D484D", ""),
|
||||||
|
Border: cc("#56635F", "8"),
|
||||||
|
Fg: cc("#D3C6AA", "15"),
|
||||||
|
Muted: cc("#9DA9A0", "7"),
|
||||||
|
Subtle: cc("#7A8478", "7"),
|
||||||
|
Success: cc("#A7C080", "10"),
|
||||||
|
Warning: cc("#DBBC7F", "11"),
|
||||||
|
Stale: cc("#E69875", "3"),
|
||||||
|
Danger: cc("#E67E80", "9"),
|
||||||
|
Info: cc("#7FBBB3", "12"),
|
||||||
|
Accent: cc("#83C092", "14"),
|
||||||
|
Purple: cc("#D699B6", "13"),
|
||||||
|
ZebraBg: cc("#30393F", ""),
|
||||||
|
SelectedFg: cc("#D3C6AA", "15"),
|
||||||
|
SelectedBg: cc("#475258", "4"),
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t Theme) HuhTheme() *huh.Theme {
|
func (t Theme) HuhTheme() *huh.Theme {
|
||||||
|
|||||||
+40
-19
@@ -95,6 +95,14 @@ const (
|
|||||||
panelMaint = 3
|
panelMaint = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type bottomPanel int
|
||||||
|
|
||||||
|
const (
|
||||||
|
bottomNone bottomPanel = iota
|
||||||
|
bottomLogs
|
||||||
|
bottomMaint
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
detailDefault = 0
|
detailDefault = 0
|
||||||
detailSLA = 1
|
detailSLA = 1
|
||||||
@@ -111,16 +119,17 @@ const (
|
|||||||
type sessionState int
|
type sessionState int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
stateDashboard sessionState = 0
|
stateDashboard sessionState = 0
|
||||||
stateLogs sessionState = 1
|
stateLogs sessionState = 1
|
||||||
stateAlertDetail sessionState = 3
|
stateDetailFullscreen sessionState = 2
|
||||||
stateFormSite sessionState = 4
|
stateAlertDetail sessionState = 3
|
||||||
stateFormAlert sessionState = 5
|
stateFormSite sessionState = 4
|
||||||
stateFormUser sessionState = 6
|
stateFormAlert sessionState = 5
|
||||||
stateConfirmDelete sessionState = 7
|
stateFormUser sessionState = 6
|
||||||
stateFormMaint sessionState = 8
|
stateConfirmDelete sessionState = 7
|
||||||
stateSettings sessionState = 11
|
stateFormMaint sessionState = 8
|
||||||
stateMaintDetail sessionState = 12
|
stateSettings sessionState = 11
|
||||||
|
stateMaintDetail sessionState = 12
|
||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
@@ -160,11 +169,12 @@ type Model struct {
|
|||||||
historySiteName string
|
historySiteName string
|
||||||
historySiteID int
|
historySiteID int
|
||||||
|
|
||||||
slaReport monitor.SLAReport
|
slaReport monitor.SLAReport
|
||||||
slaDailyBreakdown []monitor.DayReport
|
slaDailyBreakdown []monitor.DayReport
|
||||||
slaSiteName string
|
slaSiteName string
|
||||||
slaSiteID int
|
slaSiteID int
|
||||||
slaPeriodIdx int
|
slaPeriodIdx int
|
||||||
|
detailScrollOffset int
|
||||||
|
|
||||||
isAdmin bool
|
isAdmin bool
|
||||||
zones *zone.Manager
|
zones *zone.Manager
|
||||||
@@ -197,11 +207,10 @@ 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
|
||||||
|
|
||||||
logsOpen bool
|
maintSet map[int]bool
|
||||||
|
bottomPanel bottomPanel
|
||||||
detailOpen bool
|
detailOpen bool
|
||||||
maintOpen bool
|
|
||||||
maintCursor int
|
maintCursor int
|
||||||
maintOffset int
|
|
||||||
detailChanges []models.StateChange
|
detailChanges []models.StateChange
|
||||||
detailChangesSiteID int
|
detailChangesSiteID int
|
||||||
detailDailyDays []monitor.DayReport
|
detailDailyDays []monitor.DayReport
|
||||||
@@ -209,6 +218,8 @@ type Model struct {
|
|||||||
filterMode bool
|
filterMode bool
|
||||||
filterText string
|
filterText string
|
||||||
|
|
||||||
|
pendingG bool
|
||||||
|
|
||||||
// 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
|
||||||
@@ -234,6 +245,16 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
|
|||||||
|
|
||||||
detailPref, _ := s.GetPreference(ctx, "detail_open")
|
detailPref, _ := s.GetPreference(ctx, "detail_open")
|
||||||
|
|
||||||
|
bp := bottomLogs
|
||||||
|
if bpPref, _ := s.GetPreference(ctx, "bottom_panel"); bpPref != "" {
|
||||||
|
switch bpPref {
|
||||||
|
case "none":
|
||||||
|
bp = bottomNone
|
||||||
|
case "maint":
|
||||||
|
bp = bottomMaint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
state: stateDashboard,
|
state: stateDashboard,
|
||||||
@@ -248,7 +269,7 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
|
|||||||
theme: theme,
|
theme: theme,
|
||||||
themeIndex: themeIdx,
|
themeIndex: themeIdx,
|
||||||
st: newStyles(theme),
|
st: newStyles(theme),
|
||||||
logsOpen: true,
|
bottomPanel: bp,
|
||||||
detailOpen: detailPref == "true",
|
detailOpen: detailPref == "true",
|
||||||
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
||||||
version: version,
|
version: version,
|
||||||
|
|||||||
+268
-72
@@ -19,7 +19,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case tabDataMsg:
|
case tabDataMsg:
|
||||||
return m.handleTabData(msg)
|
return m.handleTabData(msg)
|
||||||
case detailDataMsg:
|
case detailDataMsg:
|
||||||
if m.detailOpen && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID {
|
detailVisible := m.detailOpen || m.state == stateDetailFullscreen
|
||||||
|
if detailVisible && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.detailChanges = msg.changes
|
m.detailChanges = msg.changes
|
||||||
@@ -51,6 +52,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.state == stateLogs {
|
if m.state == stateLogs {
|
||||||
return m.handleLogsFullscreen(msg)
|
return m.handleLogsFullscreen(msg)
|
||||||
}
|
}
|
||||||
|
if m.state == stateDetailFullscreen {
|
||||||
|
return m.handleDetailFullscreen(msg)
|
||||||
|
}
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.MouseMsg:
|
case tea.MouseMsg:
|
||||||
@@ -157,7 +161,7 @@ func (m *Model) recalcLayout() {
|
|||||||
if m.filterMode || m.filterText != "" {
|
if m.filterMode || m.filterText != "" {
|
||||||
chrome++
|
chrome++
|
||||||
}
|
}
|
||||||
if m.logsOpen {
|
if m.bottomPanel != bottomNone {
|
||||||
chrome += logsStripHeight
|
chrome += logsStripHeight
|
||||||
}
|
}
|
||||||
m.maxTableRows = m.termHeight - chrome
|
m.maxTableRows = m.termHeight - chrome
|
||||||
@@ -203,7 +207,7 @@ func (m *Model) handleTick(t time.Time) (tea.Model, tea.Cmd) {
|
|||||||
// tab-data cadence, so a flap that happens while the panel is on screen shows
|
// tab-data cadence, so a flap that happens while the panel is on screen shows
|
||||||
// up without leaving and re-entering. Nil when no detail panel is open.
|
// up without leaving and re-entering. Nil when no detail panel is open.
|
||||||
func (m *Model) detailRefreshCmd() tea.Cmd {
|
func (m *Model) detailRefreshCmd() tea.Cmd {
|
||||||
if !m.detailOpen || m.cursor >= len(m.sites) {
|
if (!m.detailOpen && m.state != stateDetailFullscreen) || m.cursor >= len(m.sites) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return m.loadDetailCmd(m.sites[m.cursor].ID)
|
return m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
@@ -227,6 +231,7 @@ func (m *Model) handleTabData(msg tabDataMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.nodes = msg.nodes
|
m.nodes = msg.nodes
|
||||||
m.maintenanceWindows = msg.maint
|
m.maintenanceWindows = msg.maint
|
||||||
|
m.buildMaintSet()
|
||||||
m.clampCursor()
|
m.clampCursor()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -247,6 +252,17 @@ func (m *Model) testAlertCmd(id int, name string) tea.Cmd {
|
|||||||
func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
|
if msg.String() == "g" {
|
||||||
|
if m.pendingG {
|
||||||
|
m.pendingG = false
|
||||||
|
m.logViewport.GotoTop()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.pendingG = true
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.pendingG = false
|
||||||
|
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc", "q":
|
case "esc", "q":
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
@@ -256,6 +272,12 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case "f":
|
case "f":
|
||||||
m.logFilterImportant = !m.logFilterImportant
|
m.logFilterImportant = !m.logFilterImportant
|
||||||
m.refreshLogContent()
|
m.refreshLogContent()
|
||||||
|
case "G":
|
||||||
|
m.logViewport.GotoBottom()
|
||||||
|
case "ctrl+u":
|
||||||
|
m.logViewport.ScrollUp(m.logViewport.Height / 2)
|
||||||
|
case "ctrl+d":
|
||||||
|
m.logViewport.ScrollDown(m.logViewport.Height / 2)
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
m.logViewport.ScrollUp(1)
|
m.logViewport.ScrollUp(1)
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
@@ -278,6 +300,95 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleDetailFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if msg.String() == "g" {
|
||||||
|
if m.pendingG {
|
||||||
|
m.pendingG = false
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.pendingG = true
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.pendingG = false
|
||||||
|
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "q":
|
||||||
|
m.state = stateDashboard
|
||||||
|
m.focusedPanel = panelMonitors
|
||||||
|
case "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
case "e":
|
||||||
|
return m.handleEditItem()
|
||||||
|
case "h":
|
||||||
|
if m.cursor < len(m.sites) {
|
||||||
|
site := m.sites[m.cursor]
|
||||||
|
m.historySiteName = site.Name
|
||||||
|
m.historySiteID = site.ID
|
||||||
|
m.historyChanges = nil
|
||||||
|
m.detailMode = detailHistory
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
return m, m.loadHistoryCmd(site.ID)
|
||||||
|
}
|
||||||
|
case "s":
|
||||||
|
if m.cursor < len(m.sites) {
|
||||||
|
site := m.sites[m.cursor]
|
||||||
|
m.slaSiteName = site.Name
|
||||||
|
m.slaSiteID = site.ID
|
||||||
|
m.slaPeriodIdx = 2
|
||||||
|
m.detailMode = detailSLA
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
return m, m.loadSLACmd(site.ID, m.slaPeriodIdx)
|
||||||
|
}
|
||||||
|
case "1", "2", "3", "4":
|
||||||
|
if m.detailMode == detailSLA {
|
||||||
|
idx := int(msg.String()[0]-'0') - 1
|
||||||
|
if idx >= 0 && idx < len(slaPeriods) {
|
||||||
|
m.slaPeriodIdx = idx
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
return m, m.loadSLACmd(m.slaSiteID, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "G":
|
||||||
|
m.detailScrollOffset = 9999
|
||||||
|
case "ctrl+u":
|
||||||
|
m.detailScrollOffset -= 5
|
||||||
|
if m.detailScrollOffset < 0 {
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
}
|
||||||
|
case "ctrl+d":
|
||||||
|
m.detailScrollOffset += 5
|
||||||
|
case "up", "k":
|
||||||
|
m.detailScrollOffset--
|
||||||
|
if m.detailScrollOffset < 0 {
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
m.detailScrollOffset++
|
||||||
|
case "pgup":
|
||||||
|
m.detailScrollOffset -= 10
|
||||||
|
if m.detailScrollOffset < 0 {
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
}
|
||||||
|
case "pgdown":
|
||||||
|
m.detailScrollOffset += 10
|
||||||
|
}
|
||||||
|
case tea.MouseMsg:
|
||||||
|
switch msg.Button {
|
||||||
|
case tea.MouseButtonWheelUp:
|
||||||
|
m.detailScrollOffset -= 3
|
||||||
|
if m.detailScrollOffset < 0 {
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
}
|
||||||
|
case tea.MouseButtonWheelDown:
|
||||||
|
m.detailScrollOffset += 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||||
if m.state != stateDashboard {
|
if m.state != stateDashboard {
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -298,6 +409,18 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.focusedPanel == panelDetail && m.detailOpen {
|
||||||
|
if msg.Button == tea.MouseButtonWheelUp {
|
||||||
|
m.detailScrollOffset -= 3
|
||||||
|
} else {
|
||||||
|
m.detailScrollOffset += 3
|
||||||
|
}
|
||||||
|
if m.detailScrollOffset < 0 {
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
listLen := m.currentListLen()
|
listLen := m.currentListLen()
|
||||||
if msg.Button == tea.MouseButtonWheelUp {
|
if msg.Button == tea.MouseButtonWheelUp {
|
||||||
if m.cursor > 0 {
|
if m.cursor > 0 {
|
||||||
@@ -458,7 +581,52 @@ func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if msg.String() == "g" {
|
||||||
|
if m.pendingG {
|
||||||
|
m.pendingG = false
|
||||||
|
m.settingsCursor = 0
|
||||||
|
m.settingsOffset = 0
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.pendingG = true
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.pendingG = false
|
||||||
|
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
|
case "G":
|
||||||
|
max := m.settingsListLen() - 1
|
||||||
|
if max >= 0 {
|
||||||
|
m.settingsCursor = max
|
||||||
|
if m.settingsCursor >= m.settingsOffset+m.maxTableRows {
|
||||||
|
m.settingsOffset = m.settingsCursor - m.maxTableRows + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "ctrl+u":
|
||||||
|
half := m.maxTableRows / 2
|
||||||
|
if half < 1 {
|
||||||
|
half = 1
|
||||||
|
}
|
||||||
|
m.settingsCursor -= half
|
||||||
|
if m.settingsCursor < 0 {
|
||||||
|
m.settingsCursor = 0
|
||||||
|
}
|
||||||
|
if m.settingsCursor < m.settingsOffset {
|
||||||
|
m.settingsOffset = m.settingsCursor
|
||||||
|
}
|
||||||
|
case "ctrl+d":
|
||||||
|
half := m.maxTableRows / 2
|
||||||
|
if half < 1 {
|
||||||
|
half = 1
|
||||||
|
}
|
||||||
|
max := m.settingsListLen() - 1
|
||||||
|
m.settingsCursor += half
|
||||||
|
if m.settingsCursor > max {
|
||||||
|
m.settingsCursor = max
|
||||||
|
}
|
||||||
|
if m.settingsCursor >= m.settingsOffset+m.maxTableRows {
|
||||||
|
m.settingsOffset = m.settingsCursor - m.maxTableRows + 1
|
||||||
|
}
|
||||||
case "esc", "S":
|
case "esc", "S":
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
@@ -549,7 +717,27 @@ func (m *Model) handleSettingsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if msg.String() == "g" {
|
||||||
|
if m.pendingG {
|
||||||
|
m.pendingG = false
|
||||||
|
m.jumpToTop()
|
||||||
|
return m, m.detailCmdIfNeeded()
|
||||||
|
}
|
||||||
|
m.pendingG = true
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.pendingG = false
|
||||||
|
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
|
case "G":
|
||||||
|
m.jumpToBottom()
|
||||||
|
return m, m.detailCmdIfNeeded()
|
||||||
|
case "ctrl+u":
|
||||||
|
m.halfPageUp()
|
||||||
|
return m, m.detailCmdIfNeeded()
|
||||||
|
case "ctrl+d":
|
||||||
|
m.halfPageDown()
|
||||||
|
return m, m.detailCmdIfNeeded()
|
||||||
case "q":
|
case "q":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case "/":
|
case "/":
|
||||||
@@ -568,26 +756,33 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.sortAsc = !m.sortAsc
|
m.sortAsc = !m.sortAsc
|
||||||
m.refreshLive()
|
m.refreshLive()
|
||||||
case "m":
|
case "m":
|
||||||
if m.termWidth >= wideBreakpoint {
|
if m.bottomPanel == bottomMaint {
|
||||||
if m.focusedPanel == panelMaint {
|
m.bottomPanel = bottomNone
|
||||||
m.maintOpen = false
|
|
||||||
m.focusedPanel = panelMonitors
|
|
||||||
} else {
|
|
||||||
m.maintOpen = true
|
|
||||||
m.focusedPanel = panelMaint
|
|
||||||
}
|
|
||||||
m.recalcLayout()
|
|
||||||
}
|
|
||||||
case "l":
|
|
||||||
if m.logsOpen {
|
|
||||||
m.logsOpen = false
|
|
||||||
m.focusedPanel = panelMonitors
|
m.focusedPanel = panelMonitors
|
||||||
} else {
|
} else {
|
||||||
m.logsOpen = true
|
m.bottomPanel = bottomMaint
|
||||||
|
m.focusedPanel = panelMaint
|
||||||
|
}
|
||||||
|
m.recalcLayout()
|
||||||
|
return m, m.saveBottomPanelPref()
|
||||||
|
case "l":
|
||||||
|
if m.bottomPanel == bottomLogs {
|
||||||
|
m.bottomPanel = bottomNone
|
||||||
|
m.focusedPanel = panelMonitors
|
||||||
|
} else {
|
||||||
|
m.bottomPanel = bottomLogs
|
||||||
m.focusedPanel = panelLogs
|
m.focusedPanel = panelLogs
|
||||||
}
|
}
|
||||||
m.recalcLayout()
|
m.recalcLayout()
|
||||||
|
return m, m.saveBottomPanelPref()
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
|
if m.focusedPanel == panelDetail && m.detailOpen {
|
||||||
|
m.detailScrollOffset--
|
||||||
|
if m.detailScrollOffset < 0 {
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
if m.focusedPanel == panelMaint {
|
if m.focusedPanel == panelMaint {
|
||||||
m.scrollMaintCursor(-1)
|
m.scrollMaintCursor(-1)
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -604,10 +799,15 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.syncSelectedID()
|
m.syncSelectedID()
|
||||||
if m.detailOpen && m.cursor < len(m.sites) {
|
if m.detailOpen && m.cursor < len(m.sites) {
|
||||||
m.detailMode = detailDefault
|
m.detailMode = detailDefault
|
||||||
|
m.detailScrollOffset = 0
|
||||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
|
if m.focusedPanel == panelDetail && m.detailOpen {
|
||||||
|
m.detailScrollOffset++
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
if m.focusedPanel == panelMaint {
|
if m.focusedPanel == panelMaint {
|
||||||
m.scrollMaintCursor(1)
|
m.scrollMaintCursor(1)
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -625,6 +825,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.syncSelectedID()
|
m.syncSelectedID()
|
||||||
if m.detailOpen && m.cursor < len(m.sites) {
|
if m.detailOpen && m.cursor < len(m.sites) {
|
||||||
m.detailMode = detailDefault
|
m.detailMode = detailDefault
|
||||||
|
m.detailScrollOffset = 0
|
||||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -650,23 +851,35 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if len(m.sites) > 0 {
|
if len(m.sites) > 0 {
|
||||||
site := m.sites[m.cursor]
|
narrow := m.termWidth < wideBreakpoint
|
||||||
if site.Type == "group" {
|
if narrow {
|
||||||
m.collapsed[site.ID] = !m.collapsed[site.ID]
|
|
||||||
payload := collapsedJSON(m.collapsed)
|
|
||||||
st := m.store
|
|
||||||
ctx := m.ctx
|
|
||||||
m.refreshLive()
|
|
||||||
return m, writeCmd("Save collapsed groups", func() error {
|
|
||||||
return st.SetPreference(ctx, "collapsed_groups", payload)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if !m.detailOpen {
|
|
||||||
m.detailOpen = true
|
|
||||||
m.detailMode = detailDefault
|
m.detailMode = detailDefault
|
||||||
m.recalcLayout()
|
m.detailScrollOffset = 0
|
||||||
|
m.state = stateDetailFullscreen
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
}
|
}
|
||||||
return m, m.loadDetailCmd(site.ID)
|
m.detailOpen = !m.detailOpen
|
||||||
|
m.detailMode = detailDefault
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
m.recalcLayout()
|
||||||
|
st := m.store
|
||||||
|
ctx := m.ctx
|
||||||
|
open := m.detailOpen
|
||||||
|
var cmd tea.Cmd
|
||||||
|
if m.detailOpen {
|
||||||
|
cmd = m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
|
saveCmd := writeCmd("Save detail preference", func() error {
|
||||||
|
v := "false"
|
||||||
|
if open {
|
||||||
|
v = "true"
|
||||||
|
}
|
||||||
|
return st.SetPreference(ctx, "detail_open", v)
|
||||||
|
})
|
||||||
|
if cmd != nil {
|
||||||
|
return m, tea.Batch(cmd, saveCmd)
|
||||||
|
}
|
||||||
|
return m, saveCmd
|
||||||
}
|
}
|
||||||
case "e":
|
case "e":
|
||||||
return m.handleEditItem()
|
return m.handleEditItem()
|
||||||
@@ -693,37 +906,16 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return st.UpdateSitePaused(ctx, id, paused)
|
return st.UpdateSitePaused(ctx, id, paused)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
case "i":
|
|
||||||
if len(m.sites) > 0 {
|
|
||||||
m.detailOpen = !m.detailOpen
|
|
||||||
m.recalcLayout()
|
|
||||||
st := m.store
|
|
||||||
ctx := m.ctx
|
|
||||||
open := m.detailOpen
|
|
||||||
var cmd tea.Cmd
|
|
||||||
if m.detailOpen {
|
|
||||||
cmd = m.loadDetailCmd(m.sites[m.cursor].ID)
|
|
||||||
}
|
|
||||||
saveCmd := writeCmd("Save detail preference", func() error {
|
|
||||||
v := "false"
|
|
||||||
if open {
|
|
||||||
v = "true"
|
|
||||||
}
|
|
||||||
return st.SetPreference(ctx, "detail_open", v)
|
|
||||||
})
|
|
||||||
if cmd != nil {
|
|
||||||
return m, tea.Batch(cmd, saveCmd)
|
|
||||||
}
|
|
||||||
return m, saveCmd
|
|
||||||
}
|
|
||||||
case "esc":
|
case "esc":
|
||||||
if m.focusedPanel != panelMonitors {
|
if m.focusedPanel != panelMonitors {
|
||||||
m.focusedPanel = panelMonitors
|
m.focusedPanel = panelMonitors
|
||||||
} else if m.detailOpen && m.detailMode != detailDefault {
|
} else if m.detailOpen && m.detailMode != detailDefault {
|
||||||
m.detailMode = detailDefault
|
m.detailMode = detailDefault
|
||||||
|
m.detailScrollOffset = 0
|
||||||
} else if m.detailOpen {
|
} else if m.detailOpen {
|
||||||
m.detailOpen = false
|
m.detailOpen = false
|
||||||
m.detailMode = detailDefault
|
m.detailMode = detailDefault
|
||||||
|
m.detailScrollOffset = 0
|
||||||
m.recalcLayout()
|
m.recalcLayout()
|
||||||
st := m.store
|
st := m.store
|
||||||
ctx := m.ctx
|
ctx := m.ctx
|
||||||
@@ -738,6 +930,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.historySiteID = site.ID
|
m.historySiteID = site.ID
|
||||||
m.historyChanges = nil
|
m.historyChanges = nil
|
||||||
m.detailMode = detailHistory
|
m.detailMode = detailHistory
|
||||||
|
m.detailScrollOffset = 0
|
||||||
return m, m.loadHistoryCmd(site.ID)
|
return m, m.loadHistoryCmd(site.ID)
|
||||||
}
|
}
|
||||||
case "s":
|
case "s":
|
||||||
@@ -747,6 +940,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.slaSiteID = site.ID
|
m.slaSiteID = site.ID
|
||||||
m.slaPeriodIdx = 2
|
m.slaPeriodIdx = 2
|
||||||
m.detailMode = detailSLA
|
m.detailMode = detailSLA
|
||||||
|
m.detailScrollOffset = 0
|
||||||
return m, m.loadSLACmd(site.ID, m.slaPeriodIdx)
|
return m, m.loadSLACmd(site.ID, m.slaPeriodIdx)
|
||||||
}
|
}
|
||||||
case "1", "2", "3", "4":
|
case "1", "2", "3", "4":
|
||||||
@@ -754,6 +948,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
idx := int(msg.String()[0]-'0') - 1
|
idx := int(msg.String()[0]-'0') - 1
|
||||||
if idx >= 0 && idx < len(slaPeriods) {
|
if idx >= 0 && idx < len(slaPeriods) {
|
||||||
m.slaPeriodIdx = idx
|
m.slaPeriodIdx = idx
|
||||||
|
m.detailScrollOffset = 0
|
||||||
return m, m.loadSLACmd(m.slaSiteID, idx)
|
return m, m.loadSLACmd(m.slaSiteID, idx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -856,11 +1051,28 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.maintOpen && m.zones.Get("panel-maint").InBounds(msg) {
|
if m.bottomPanel == bottomMaint && m.zones.Get("panel-maint").InBounds(msg) {
|
||||||
m.focusedPanel = panelMaint
|
m.focusedPanel = panelMaint
|
||||||
|
_, relY := m.zones.Get("panel-maint").Pos(msg)
|
||||||
|
row := relY - 2
|
||||||
|
windows := m.activeMaintWindows()
|
||||||
|
if row >= 0 && row < len(windows) {
|
||||||
|
m.maintCursor = row
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
} else if m.zones.Get("panel-monitors").InBounds(msg) {
|
} else if m.zones.Get("panel-monitors").InBounds(msg) {
|
||||||
m.focusedPanel = panelMonitors
|
m.focusedPanel = panelMonitors
|
||||||
|
_, relY := m.zones.Get("panel-monitors").Pos(msg)
|
||||||
|
row := relY - 4 + m.tableOffset
|
||||||
|
if row >= 0 && row < len(m.sites) {
|
||||||
|
m.cursor = row
|
||||||
|
m.syncSelectedID()
|
||||||
|
if m.detailOpen {
|
||||||
|
m.detailScrollOffset = 0
|
||||||
|
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
} else if m.zones.Get("panel-logs").InBounds(msg) {
|
} else if m.zones.Get("panel-logs").InBounds(msg) {
|
||||||
m.focusedPanel = panelLogs
|
m.focusedPanel = panelLogs
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -869,22 +1081,6 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
end := m.tableOffset + m.maxTableRows
|
|
||||||
if end > len(m.sites) {
|
|
||||||
end = len(m.sites)
|
|
||||||
}
|
|
||||||
for i := m.tableOffset; i < end; i++ {
|
|
||||||
if m.zones.Get(fmt.Sprintf("site-%d", i)).InBounds(msg) {
|
|
||||||
m.cursor = i
|
|
||||||
m.syncSelectedID()
|
|
||||||
m.focusedPanel = panelMonitors
|
|
||||||
if m.detailOpen {
|
|
||||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+148
-1
@@ -142,7 +142,7 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
_ = m.viewDetailInline(80)
|
_ = m.viewDetailInline(80, 30)
|
||||||
}
|
}
|
||||||
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)
|
||||||
@@ -300,6 +300,70 @@ 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)
|
||||||
@@ -325,3 +389,86 @@ 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ func (m Model) View() string {
|
|||||||
return ""
|
return ""
|
||||||
case stateLogs:
|
case stateLogs:
|
||||||
return m.viewLogsFullscreen()
|
return m.viewLogsFullscreen()
|
||||||
|
case stateDetailFullscreen:
|
||||||
|
return m.viewDetailFullscreen()
|
||||||
case stateAlertDetail:
|
case stateAlertDetail:
|
||||||
return m.viewAlertDetailPanel()
|
return m.viewAlertDetailPanel()
|
||||||
case stateSettings:
|
case stateSettings:
|
||||||
@@ -143,41 +145,27 @@ func (m Model) computeStats() dashboardStats {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
const maintSidebarW = 22
|
|
||||||
|
|
||||||
func (m Model) viewMonitorsLayout() string {
|
func (m Model) viewMonitorsLayout() string {
|
||||||
availW := m.termWidth - chromePadH
|
availW := m.termWidth - chromePadH
|
||||||
wide := m.termWidth >= wideBreakpoint
|
wide := m.termWidth >= wideBreakpoint
|
||||||
|
|
||||||
showMaint := m.maintOpen && wide
|
|
||||||
showDetail := m.detailOpen && wide
|
showDetail := m.detailOpen && wide
|
||||||
|
|
||||||
var maintW, detailW, monW int
|
var detailW, monW int
|
||||||
if showMaint {
|
|
||||||
maintW = maintSidebarW
|
|
||||||
if maintW > availW/4 {
|
|
||||||
maintW = availW / 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
remaining := availW - maintW
|
|
||||||
if showDetail {
|
if showDetail {
|
||||||
monW = remaining * 45 / 100
|
monW = availW * 60 / 100
|
||||||
detailW = remaining - monW
|
detailW = availW - monW
|
||||||
} else {
|
} else {
|
||||||
monW = remaining
|
monW = availW
|
||||||
}
|
}
|
||||||
|
|
||||||
m.contentWidth = monW - 2
|
m.contentWidth = monW - 2
|
||||||
|
|
||||||
|
monTargetH := m.maxTableRows + 5
|
||||||
monitors := m.viewSitesTab()
|
monitors := m.viewSitesTab()
|
||||||
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, monW, m.focusedPanel == panelMonitors))
|
monPanel := m.zones.Mark("panel-monitors", m.titledPanelH("Monitors", monitors, "", monW, monTargetH, 0, scrollbar{pos: m.tableOffset, total: len(m.sites), visible: m.maxTableRows}, m.focusedPanel == panelMonitors))
|
||||||
|
|
||||||
var topParts []string
|
var topParts []string
|
||||||
if showMaint {
|
|
||||||
sidebar := m.viewMaintSidebar(maintW-2, m.maxTableRows)
|
|
||||||
maintPanel := m.zones.Mark("panel-maint", m.titledPanel("Maint", sidebar, maintW, m.focusedPanel == panelMaint))
|
|
||||||
topParts = append(topParts, maintPanel)
|
|
||||||
}
|
|
||||||
topParts = append(topParts, monPanel)
|
topParts = append(topParts, monPanel)
|
||||||
if showDetail {
|
if showDetail {
|
||||||
title := ""
|
title := ""
|
||||||
@@ -190,17 +178,28 @@ func (m Model) viewMonitorsLayout() string {
|
|||||||
case detailHistory:
|
case detailHistory:
|
||||||
title = "History · " + title
|
title = "History · " + title
|
||||||
}
|
}
|
||||||
detail := m.viewDetailInline(detailW - 2)
|
monHeight := lipgloss.Height(monPanel)
|
||||||
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(title, detail, detailW, m.focusedPanel == panelDetail))
|
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, scrollbar{}, m.focusedPanel == panelDetail))
|
||||||
topParts = append(topParts, detailPanel)
|
topParts = append(topParts, detailPanel)
|
||||||
}
|
}
|
||||||
|
|
||||||
top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...)
|
top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...)
|
||||||
|
|
||||||
if m.logsOpen {
|
switch m.bottomPanel {
|
||||||
logContent := m.viewLogsStrip(availW-2, logsStripHeight-2)
|
case bottomLogs:
|
||||||
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, availW, m.focusedPanel == panelLogs))
|
maxLines := logsStripHeight - 2
|
||||||
|
logContent := m.viewLogsStrip(availW-2, maxLines)
|
||||||
|
totalLogs := m.filteredLogCount()
|
||||||
|
logPanel := m.zones.Mark("panel-logs", m.titledPanelH("Logs", logContent, "", availW, logsStripHeight, 0, scrollbar{pos: m.logScrollOffset, total: totalLogs, visible: maxLines}, m.focusedPanel == panelLogs))
|
||||||
return top + "\n" + logPanel
|
return top + "\n" + logPanel
|
||||||
|
case bottomMaint:
|
||||||
|
maxLines := logsStripHeight - 2
|
||||||
|
maintContent := m.viewMaintStrip(availW-2, maxLines)
|
||||||
|
totalMaint := len(m.activeMaintWindows())
|
||||||
|
maintPanel := m.zones.Mark("panel-maint", m.titledPanelH("Maint", maintContent, "", availW, logsStripHeight, 0, scrollbar{pos: 0, total: totalMaint, visible: maxLines}, m.focusedPanel == panelMaint))
|
||||||
|
return top + "\n" + maintPanel
|
||||||
}
|
}
|
||||||
return top
|
return top
|
||||||
}
|
}
|
||||||
@@ -277,28 +276,46 @@ func (m Model) renderStatusLine(stats dashboardStats) string {
|
|||||||
return left + strings.Repeat(" ", padW) + 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 {
|
func (m Model) renderFooter(_ dashboardStats) string {
|
||||||
|
dot := m.st.subtleStyle.Render(" · ")
|
||||||
|
|
||||||
if m.filterMode {
|
if m.filterMode {
|
||||||
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
|
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")
|
keys := m.hotkey("Enter", "Apply") + dot + m.hotkey("Esc", "Clear")
|
||||||
|
return "\n" + m.st.titleStyle.Render("/") + " " + m.filterText + cursor + " " + keys
|
||||||
}
|
}
|
||||||
|
|
||||||
var keys string
|
var parts []string
|
||||||
if m.focusedPanel == panelMaint {
|
if m.focusedPanel == panelMaint {
|
||||||
keys = "[n]New [x]End [d]Del [Esc]Back [S]Settings [T]Theme [q]Quit"
|
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 {
|
} else if m.focusedPanel == panelLogs {
|
||||||
keys = "[↑/↓]Scroll [Enter]Expand [l/Esc]Back [S]Settings [T]Theme [q]Quit"
|
parts = []string{m.hotkey("↑/↓", "Scroll"), m.hotkey("Enter", "Expand"), m.hotkey("l/Esc", "Back")}
|
||||||
} else if m.detailOpen && m.detailMode == detailSLA {
|
} else if m.detailOpen && m.detailMode == detailSLA {
|
||||||
keys = "[1-4]Period [Esc]Back [l]Logs [S]Settings [T]Theme [q]Quit"
|
parts = []string{m.hotkey("1-4", "Period"), m.hotkey("Esc", "Back")}
|
||||||
} else if m.detailOpen && m.detailMode == detailHistory {
|
} else if m.detailOpen && m.detailMode == detailHistory {
|
||||||
keys = "[Esc]Back [l]Logs [S]Settings [T]Theme [q]Quit"
|
parts = []string{m.hotkey("Esc", "Back")}
|
||||||
} else if m.detailOpen {
|
} else if m.detailOpen {
|
||||||
keys = "[i]Close [h]History [s]SLA [e]Edit [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit"
|
parts = []string{m.hotkey("Enter", "Close"), m.hotkey("h", "History"), m.hotkey("s", "SLA"), m.hotkey("e", "Edit")}
|
||||||
} else {
|
} else {
|
||||||
keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [m]Maint [l]Logs [S]Settings [T]Theme [q]Quit"
|
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 := m.st.subtleStyle.Render(keys)
|
line := strings.Join(parts, dot)
|
||||||
if m.filterText != "" {
|
if m.filterText != "" {
|
||||||
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s ", m.filterText)) + line
|
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s ", m.filterText)) + line
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -10,32 +11,84 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) viewDetailInline(width int) string {
|
func (m Model) viewDetailInline(width, height int) string {
|
||||||
if m.cursor >= len(m.sites) {
|
if m.cursor >= len(m.sites) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
switch m.detailMode {
|
switch m.detailMode {
|
||||||
case detailSLA:
|
case detailSLA:
|
||||||
return m.viewSLASidebar(width)
|
return m.viewSLASidebar(width, height)
|
||||||
case detailHistory:
|
case detailHistory:
|
||||||
return m.viewHistorySidebar(width)
|
return m.viewHistorySidebar(width, height)
|
||||||
default:
|
default:
|
||||||
site := m.sites[m.cursor]
|
site := m.sites[m.cursor]
|
||||||
hist, _ := m.engine.GetHistory(site.ID)
|
hist, _ := m.engine.GetHistory(site.ID)
|
||||||
return m.viewDetailSidebar(site, hist, width)
|
return m.buildDetailContent(site, hist, width, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width int) string {
|
func (m Model) viewDetailFullscreen() string {
|
||||||
|
if m.cursor >= len(m.sites) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
availW := m.termWidth - chromePadH
|
||||||
|
site := m.sites[m.cursor]
|
||||||
|
|
||||||
|
var title string
|
||||||
|
switch m.detailMode {
|
||||||
|
case detailSLA:
|
||||||
|
title = "SLA · " + site.Name
|
||||||
|
case detailHistory:
|
||||||
|
title = "History · " + site.Name
|
||||||
|
default:
|
||||||
|
title = site.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
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, scrollbar{}, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) buildDetailContent(site models.Site, hist monitor.SiteHistory, width int, fullscreen bool) string {
|
||||||
dot := m.st.subtleStyle.Render(" · ")
|
dot := m.st.subtleStyle.Render(" · ")
|
||||||
label := m.st.subtleStyle
|
label := m.st.subtleStyle
|
||||||
var b strings.Builder
|
|
||||||
innerW := width - 4
|
innerW := width - 4
|
||||||
if innerW < 20 {
|
if innerW < 20 {
|
||||||
innerW = 20
|
innerW = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status + latency + last check
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Status + latency + last check + state since
|
||||||
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
||||||
statusParts := []string{status}
|
statusParts := []string{status}
|
||||||
if site.Latency > 0 {
|
if site.Latency > 0 {
|
||||||
@@ -44,6 +97,10 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid
|
|||||||
if !site.LastCheck.IsZero() {
|
if !site.LastCheck.IsZero() {
|
||||||
statusParts = append(statusParts, m.fmtTimeAgo(site.LastCheck))
|
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")
|
b.WriteString(" " + strings.Join(statusParts, dot) + "\n")
|
||||||
|
|
||||||
// Type-specific details
|
// Type-specific details
|
||||||
@@ -52,13 +109,33 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid
|
|||||||
b.WriteString(" " + strings.Join(typeParts, dot) + "\n")
|
b.WriteString(" " + strings.Join(typeParts, dot) + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uptime + retries
|
// Extended endpoint fields
|
||||||
uptimeParts := []string{label.Render("Uptime") + " " + m.fmtUptime(hist.Statuses)}
|
m.writeEndpointFields(&b, site, label, innerW, fullscreen)
|
||||||
|
|
||||||
|
// 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 {
|
if site.Type != "group" && site.MaxRetries > 0 {
|
||||||
uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site))
|
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")
|
b.WriteString(" " + strings.Join(uptimeParts, dot) + "\n")
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Error line
|
// Error line
|
||||||
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
||||||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||||
@@ -69,8 +146,62 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid
|
|||||||
b.WriteString(" " + label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
|
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")
|
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
|
// Latency chart
|
||||||
if len(hist.Latencies) > 0 {
|
if len(hist.Latencies) > 0 {
|
||||||
chart := m.latencyChart(hist.Latencies, hist.Statuses, innerW, 3)
|
chart := m.latencyChart(hist.Latencies, hist.Statuses, innerW, 3)
|
||||||
@@ -112,6 +243,15 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
// State changes
|
// State changes
|
||||||
@@ -143,11 +283,50 @@ func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, wid
|
|||||||
b.WriteString(" " + label.Render("No state changes") + "\n")
|
b.WriteString(" " + label.Render("No state changes") + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("\n " + m.detailKeys() + "\n")
|
|
||||||
|
|
||||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) detailTypeLine(site models.Site) []string {
|
func (m Model) detailTypeLine(site models.Site) []string {
|
||||||
label := m.st.subtleStyle
|
label := m.st.subtleStyle
|
||||||
var parts []string
|
var parts []string
|
||||||
@@ -212,8 +391,28 @@ func (m Model) detailTypeLine(site models.Site) []string {
|
|||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) detailKeys() string {
|
func (m Model) detailFooter(width int) string {
|
||||||
return m.st.subtleStyle.Render("[e] Edit [h] History [s] SLA [Esc] Back")
|
dot := m.st.subtleStyle.Render(" · ")
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
content := " " + strings.Join(parts, dot)
|
||||||
|
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) fmtStatusWord(status string) string {
|
func (m Model) fmtStatusWord(status string) string {
|
||||||
@@ -235,7 +434,7 @@ func (m Model) fmtStatusWord(status string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewSLASidebar(width int) string {
|
func (m Model) viewSLASidebar(width, _ int) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
label := m.st.subtleStyle
|
label := m.st.subtleStyle
|
||||||
innerW := width - 4
|
innerW := width - 4
|
||||||
@@ -292,24 +491,10 @@ func (m Model) viewSLASidebar(width int) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
var keys []string
|
|
||||||
for i, p := range slaPeriods {
|
|
||||||
k := fmt.Sprintf("[%s] %s", p.key, p.label)
|
|
||||||
if i == m.slaPeriodIdx {
|
|
||||||
keys = append(keys, m.st.titleStyle.Render(k))
|
|
||||||
} else {
|
|
||||||
keys = append(keys, label.Render(k))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString(" " + strings.Join(keys, " ") + "\n")
|
|
||||||
b.WriteString(" " + label.Render("[Esc] Back") + "\n")
|
|
||||||
|
|
||||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewHistorySidebar(width int) string {
|
func (m Model) viewHistorySidebar(width, _ int) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
label := m.st.subtleStyle
|
label := m.st.subtleStyle
|
||||||
innerW := width - 4
|
innerW := width - 4
|
||||||
@@ -368,7 +553,6 @@ func (m Model) viewHistorySidebar(width int) string {
|
|||||||
statParts = append(statParts, "avg "+fmtDuration(avg))
|
statParts = append(statParts, "avg "+fmtDuration(avg))
|
||||||
}
|
}
|
||||||
b.WriteString(" " + label.Render(strings.Join(statParts, " │ ")) + "\n")
|
b.WriteString(" " + label.Render(strings.Join(statParts, " │ ")) + "\n")
|
||||||
b.WriteString(" " + label.Render("[Esc] Back") + "\n")
|
|
||||||
|
|
||||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/lipgloss/table"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m Model) viewMaintDetailPanel() string {
|
func (m Model) viewMaintDetailPanel() string {
|
||||||
@@ -85,6 +86,15 @@ func (m Model) viewMaintDetailPanel() string {
|
|||||||
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
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 {
|
func (m Model) activeMaintWindows() []models.MaintenanceWindow {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var out []models.MaintenanceWindow
|
var out []models.MaintenanceWindow
|
||||||
@@ -97,34 +107,26 @@ func (m Model) activeMaintWindows() []models.MaintenanceWindow {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewMaintSidebar(width, maxLines int) string {
|
func (m Model) viewMaintStrip(width, maxLines int) string {
|
||||||
windows := m.activeMaintWindows()
|
windows := m.activeMaintWindows()
|
||||||
if len(windows) == 0 {
|
if len(windows) == 0 {
|
||||||
return m.st.subtleStyle.Render(" No active maintenance")
|
return m.st.subtleStyle.Render(" No active maintenance")
|
||||||
}
|
}
|
||||||
|
|
||||||
contentW := width - 2
|
|
||||||
if contentW < 10 {
|
|
||||||
contentW = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
start := m.maintOffset
|
|
||||||
if start > len(windows) {
|
|
||||||
start = len(windows)
|
|
||||||
}
|
|
||||||
linesUsed := 0
|
|
||||||
end := start
|
|
||||||
for end < len(windows) && linesUsed+2 <= maxLines {
|
|
||||||
linesUsed += 2
|
|
||||||
end++
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines []string
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for i := start; i < end; i++ {
|
end := maxLines
|
||||||
mw := windows[i]
|
if end > len(windows) {
|
||||||
selected := m.focusedPanel == panelMaint && i == m.maintCursor
|
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))
|
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
|
||||||
|
|
||||||
var icon string
|
var icon string
|
||||||
@@ -134,37 +136,51 @@ func (m Model) viewMaintSidebar(width, maxLines int) string {
|
|||||||
icon = m.st.warnStyle.Render("○")
|
icon = m.st.warnStyle.Render("○")
|
||||||
}
|
}
|
||||||
|
|
||||||
title := limitStr(mw.Title, contentW-3)
|
monName := "All Monitors"
|
||||||
titleLine := " " + icon + " " + title
|
if mw.MonitorID > 0 {
|
||||||
|
monName = m.monitorNameByID(mw.MonitorID)
|
||||||
|
}
|
||||||
|
|
||||||
var detail string
|
var status string
|
||||||
if isActive {
|
if isActive {
|
||||||
if mw.EndTime.IsZero() {
|
if mw.EndTime.IsZero() {
|
||||||
detail = m.st.subtleStyle.Render("active · indefinite")
|
status = "indefinite"
|
||||||
} else {
|
} else {
|
||||||
remaining := time.Until(mw.EndTime)
|
status = fmtDuration(time.Until(mw.EndTime)) + " left"
|
||||||
detail = m.st.subtleStyle.Render("active · " + fmtDuration(remaining))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
detail = m.st.subtleStyle.Render(mw.StartTime.Format("Jan 02") + " · " + fmtDuration(mw.EndTime.Sub(mw.StartTime)))
|
status = "starts " + mw.StartTime.Format("Jan 02 15:04")
|
||||||
}
|
|
||||||
detailLine := " " + limitStr(detail, contentW-3)
|
|
||||||
|
|
||||||
if selected {
|
|
||||||
sel := m.st.tableSelectedStyle
|
|
||||||
titleLine = sel.Render(lipgloss.NewStyle().Width(contentW).Render(titleLine))
|
|
||||||
detailLine = sel.Render(lipgloss.NewStyle().Width(contentW).Render(detailLine))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lines = append(lines, titleLine, detailLine)
|
rows = append(rows, []string{icon, mw.Title, monName, status})
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(windows) > end-start {
|
colWidths := []int{3, 0, 0, 0}
|
||||||
more := fmt.Sprintf(" %d more", len(windows)-(end-start))
|
remaining := width - colWidths[0] - 6
|
||||||
lines = append(lines, m.st.subtleStyle.Render(more))
|
colWidths[1] = remaining * 40 / 100
|
||||||
}
|
colWidths[2] = remaining * 30 / 100
|
||||||
|
colWidths[3] = remaining - colWidths[1] - colWidths[2]
|
||||||
|
|
||||||
return strings.Join(lines, "\n")
|
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) {
|
func (m *Model) scrollMaintCursor(delta int) {
|
||||||
@@ -180,14 +196,4 @@ func (m *Model) scrollMaintCursor(delta int) {
|
|||||||
if m.maintCursor >= total {
|
if m.maintCursor >= total {
|
||||||
m.maintCursor = total - 1
|
m.maintCursor = total - 1
|
||||||
}
|
}
|
||||||
if m.maintCursor < m.maintOffset {
|
|
||||||
m.maintOffset = m.maintCursor
|
|
||||||
}
|
|
||||||
visible := m.maxTableRows / 2
|
|
||||||
if visible < 1 {
|
|
||||||
visible = 1
|
|
||||||
}
|
|
||||||
if m.maintCursor >= m.maintOffset+visible {
|
|
||||||
m.maintOffset = m.maintCursor - visible + 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user