Compare commits

..

31 Commits

Author SHA1 Message Date
lerko c1955a4c80 feat(tui): overhaul theme palette for legibility
CI / test (pull_request) Successful in 1m45s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Successful in 50s
Replace Nord and Gruvbox with Rosé Pine and Dracula. Add Kanagawa
and Everforest. Fix legibility issues across remaining themes:

- Tokyo Night: separate Muted from Fg, distinct SelectedBg
- Catppuccin Mocha: bolder semantic colors, tighter zebra, distinct SelectedBg
- Order themes by warm→cool color temperature for smooth cycling
2026-07-21 22:21:12 -04:00
lerko ce3abda5b9 fix(build): bump Go 1.26.4 → 1.26.5 for GO-2026-5856 (crypto/tls)
CI / test (pull_request) Successful in 1m51s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 51s
2026-07-21 20:56:40 -04:00
lerko ce3cfad7f8 fix(tui): sort groups by active sort column instead of fixed ID order
CI / test (pull_request) Successful in 2m9s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Failing after 56s
2026-07-21 20:41:21 -04:00
lerko d6e6011b35 feat(tui): add vim-style navigation (gg, G, ctrl-u, ctrl-d)
All scrollable panels now support gg (top), G (bottom), ctrl-u (half
page up), and ctrl-d (half page down). Works across dashboard panels
(monitors, detail, maint, logs), fullscreen logs, fullscreen detail,
and settings. Uses a pendingG flag for the two-key gg sequence.
2026-07-21 19:37:26 -04:00
lerko 1cfa0571c8 feat(tui): improve scrollbar visibility and extend to all panels
CI / test (pull_request) Successful in 1m46s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 56s
Use block characters (█/░) with accent coloring for scrollbar thumb/track
instead of box-drawing chars that blend with panel borders. Auto-derive
scrollbar from content overflow in titledPanelH. Add scrollbar to logs
and maintenance bottom panels.
2026-07-02 11:09:07 -04:00
lerko d6ba7d9af8 feat(tui): add scrollbar gutter to titledPanelH
Opt-in scrollbar track on the right border edge when totalItems > bodyH.
Thin track (│) with muted thumb (┃) showing viewport position.

Monitors panel passes len(sites) to enable it. Detail and fullscreen
panels pass 0 to opt out. Any panel can opt in via the totalItems param.
2026-07-01 21:51:07 -04:00
lerko 14cec4283d feat(tui): persist bottom panel preference across restarts
Save bottom_panel pref (logs/maint/none) to store on toggle.
Restore on startup via InitialModel, same pattern as detail_open.
2026-07-01 21:02:45 -04:00
lerko a32a443a4a perf(tui): precompute maintenance set, eliminate redundant GetAllSites
CI / test (pull_request) Successful in 1m45s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 46s
Replace O(windows × sites) isMonitorInMaintenance with O(1) map lookup.
buildMaintSet runs once per refreshLive/handleTabData, not per call site.

groupSparkline/groupUptime now use m.sites (already on model) instead of
calling engine.GetAllSites() which copies the full map under a mutex.
2026-07-01 19:37:27 -04:00
lerko b90033c7f0 fix(tui): use titledPanelH chrome for fullscreen detail view
CI / test (pull_request) Successful in 1m52s
CI / lint (pull_request) Successful in 1m22s
CI / vulncheck (pull_request) Successful in 56s
Match the sidebar's bordered panel style (╭─ Title ─╮) so the
fullscreen detail view on narrow terminals looks cohesive with the
rest of the UI. Breadcrumb now renders as the panel title.
2026-07-01 16:22:49 -04:00
lerko 989dd1fb39 feat(tui): fullscreen detail view + enriched sidebar content
On narrow terminals (<120 cols), Enter opens a fullscreen detail view
instead of the sidebar that can't be seen. Wide terminals keep existing
sidebar toggle behavior.

Enrich detail panel with: state since, last success, connection chain
diagnostic, probe results, latency histogram, extended endpoint fields
(interval/timeout/method/codes/regions/description), push token,
maintenance window name. Breadcrumb shown in fullscreen mode only.
2026-07-01 16:12:45 -04:00
lerko faf7d36c64 refactor(tui): replace # column with colored status dot
CI / test (pull_request) Successful in 2m7s
CI / lint (pull_request) Successful in 1m27s
CI / vulncheck (pull_request) Successful in 1m1s
Drop the row-number column and add a colored status dot as the first
column. Dot uses the same icon/color as the existing status indicators
(▲ green for up, ▼ red for down, ◼ purple for maint, etc). Matches
the maint strip visual pattern. Status text column retained for
explicit label.
2026-06-30 23:38:05 -04:00
lerko 16f0c2eb66 fix(tui): pad monitors panel to fill available height
CI / test (pull_request) Successful in 1m55s
CI / lint (pull_request) Successful in 1m22s
CI / vulncheck (pull_request) Successful in 1m1s
Monitors panel now uses titledPanelH with a target height based on
maxTableRows, filling the space down to the bottom strip. No more
gap between monitors panel and logs/maint strip when fewer monitors
than available rows.
2026-06-30 23:22:51 -04:00
lerko f0d97f5562 feat(tui): click anywhere in row to select monitor or maint item
CI / test (pull_request) Successful in 1m56s
CI / lint (pull_request) Successful in 1m22s
CI / vulncheck (pull_request) Successful in 56s
Use panel zone Pos() to compute clicked row from Y offset instead of
per-cell zone matching. Monitors and maint strip both support full-row
click selection.
2026-06-30 23:10:16 -04:00
lerko 0484153103 fix(tui): fix maint strip spacing and ANSI padding
Use rune-based padding instead of fmt %-*s which miscounts ANSI escape
sequences. Remove outer lipgloss width wrapper that added extra lines.
2026-06-30 23:00:56 -04:00
lerko e3d681311f refactor(tui): move maintenance from sidebar to bottom strip
Replace the cramped 22-char maint sidebar with a full-width bottom
strip in the same position as logs. Bottom panel is modal: l for logs,
m for maint, same key again to close. Maint strip shows one row per
window with icon, title, affected monitors, and status/time remaining.

Removes maintSidebarW constant and maintOffset field. Introduces
bottomPanel enum (bottomNone/bottomLogs/bottomMaint) replacing
logsOpen and maintOpen booleans. m key no longer gated behind
wideBreakpoint.
2026-06-30 22:47:50 -04:00
lerko f7303c946c fix(tui): show — for uptime when monitor is in maintenance
CI / test (pull_request) Successful in 1m57s
CI / lint (pull_request) Successful in 1m27s
CI / vulncheck (pull_request) Successful in 55s
Monitors in active maintenance now display — instead of a misleading
0.0% uptime in both the main table and detail sidebar.
2026-06-30 21:33:39 -04:00
lerko 6e936ecce3 style(tui): adjust monitor/detail split to 60/40
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
Gives monitors panel more room for columns while detail sidebar
content stacks vertically and doesn't need the extra width.
2026-06-30 21:12:00 -04:00
lerko 04cf12f52b style(tui): match detail panel footer to hotbar styling
Detail panel footer now uses accent keys, muted descriptions, and dot
separators — same visual style as the bottom hotbar.
2026-06-30 21:07:47 -04:00
lerko 33df597dda fix(tui): merge SLA footer into single line
Period keys and Esc Back now render on one line instead of two.
2026-06-30 20:59:04 -04:00
lerko 1d14f640f4 feat(tui): pin detail panel footer keys at bottom of sidebar
CI / test (pull_request) Successful in 1m53s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 1m1s
Footer keys (period selector, edit/history/SLA shortcuts, Esc) are now
pinned at the bottom of the detail panel, always visible regardless of
scroll position. Scrollable content fills the space between the header
and the pinned footer.
2026-06-30 20:46:43 -04:00
lerko 631f07c242 feat(tui): stable detail panel height with scroll support
CI / test (pull_request) Successful in 1m56s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 56s
Detail sidebar now maintains consistent height matching the monitors
panel across all modes (default, SLA, history). Content scrolls within
the fixed frame via mouse wheel or j/k when the detail panel is
focused. Scroll offset resets on mode/monitor/period changes. Logs
strip stays visible in all states.
2026-06-30 20:36:25 -04:00
lerko 0badc2ddf5 fix(tui): cap detail sidebar height to match monitors panel
CI / test (pull_request) Successful in 1m51s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
SLA daily breakdown no longer overflows past the monitors panel. Detail
sidebar height is capped to monitors panel height via titledPanelH.
SLA daily rows are limited to fit within available space. Logs strip
stays visible in all detail modes.
2026-06-30 20:18:39 -04:00
lerko be14436701 style(tui): accent keys, muted descriptions, dot separators in hotbar
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
Keys rendered in accent color, descriptions in muted. Dot separators
between items. No brackets. Faster to scan at a glance.
2026-06-30 19:56:16 -04:00
lerko efa8894b18 refactor(tui): unify detail toggle on Enter, remove i keybind
Enter now toggles detail sidebar open/closed with preference
persistence. Removes redundant i keybind that did the same thing.
Hotbar updated: [Enter]Detail when closed, [Enter]Close when open.
2026-06-30 19:51:44 -04:00
lerko ef54b36e0d refactor(tui): make Enter always open detail, Space for group collapse
Enter now consistently opens the detail sidebar for both monitors and
groups. Group collapse/expand is Space-only. Hotbar shows [Space]Expand
or [Space]Collapse contextually when cursor is on a group row.
2026-06-30 19:09:51 -04:00
lerko 835844314e fix(tui): show context-aware Enter hint in hotbar
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 50s
When cursor is on a group row, hotbar now shows [Enter]Expand or
[Enter]Collapse instead of [Enter]Detail, matching actual behavior.
2026-06-30 17:09:58 -04:00
lerko dc79e2baaa refactor(tui): remove full-page detail, SLA, and history views
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 56s
All monitor detail content now lives in the sidebar panel.
Delete the full-page viewDetailPanel, viewSLAPanel, viewHistoryPanel,
handleDetailKey, handleSLAKey, handleHistoryKey, openSLAView, and
related dead code (histogram, sparkline tooltip, detailViewport).

Remove stateDetail, stateSLA, stateHistory session states.
Update tests to use detailOpen/detailMode instead of removed states.
2026-06-30 09:28:12 -04:00
lerko 3e02833df4 feat(tui): enter opens detail sidebar, logs as bottom strip
Enter on a site now opens the detail sidebar instead of a
full-page takeover. Enter on a group expands/collapses children
(same as space). Logs panel moves to a toggleable bottom strip
via the l key with j/k scrolling when focused.
2026-06-30 09:12:13 -04:00
lerko 8581662237 feat(tui): SLA and history as sidebar detail modes
SLA and History views now render inside the detail sidebar panel
instead of taking over the full screen. Press s/h to switch modes,
Esc to return to default detail view. Cursor movement resets to
default mode.

- Add detailMode state (detailDefault/detailSLA/detailHistory)
- SLA sidebar: period selector, uptime bars, daily breakdown
- History sidebar: sparkline density, transition list, stats
- Panel title shows "SLA · name" or "History · name" in sub-modes
- Period keys [1]-[4] work in SLA mode from dashboard state
2026-06-30 09:09:04 -04:00
lerko 2779f9f532 feat(tui): tall-narrow detail sidebar for master-detail layout
Replace the old two-column/single-column inline detail with a
vertically stacked layout optimized for the right-side panel.

Content stack: status line, type details, uptime, error, latency
chart (3-row), 30d timeline, sparkline, min/avg/max stats, and
up to 5 state changes — all flowing top-to-bottom within the
~55% width detail panel.
2026-06-30 09:03:30 -04:00
lerko 4321e094a3 feat(tui): move detail panel from bottom overlay to right side
Restructure dashboard from [Monitors 70% | Logs 30%] to
[Monitors 45% | Detail 55%] master-detail layout. Detail panel
now sits beside the monitor list instead of below it, keeping
dashboard context visible during inspection.

- Detail panel renders in horizontal split (right of monitors)
- Logs sidebar removed; `l` key opens fullscreen logs directly
- recalcLayout no longer subtracts detail height from table rows
- Remove unused renderCompactLogLine/viewLogsSidebar (Phase 4 recreates)
2026-06-30 08:59:17 -04:00
19 changed files with 1517 additions and 1187 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
# --- 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
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
+1 -1
View File
@@ -1,6 +1,6 @@
module gitea.lerkolabs.com/lerkolabs/uptop
go 1.26.4
go 1.26.5
require (
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
+129 -2
View File
@@ -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 {
var groups, ungrouped []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)
}
}
sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID })
sortSlice := func(s []models.Site) {
sort.Slice(s, func(i, j int) bool { return s[i].ID < s[j].ID })
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 {
c := children[pid]
sortSlice(c)
@@ -123,6 +137,7 @@ func (m *Model) refreshLive() {
ordered = filterSites(ordered, m.filterText)
}
m.sites = ordered
m.buildMaintSet()
m.refreshLogContent()
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
// 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
+28
View File
@@ -104,6 +104,13 @@ func (m Model) fmtLatency(d time.Duration) string {
return m.st.dangerStyle.Render(s)
}
func (m Model) fmtUptimeMaint(statuses []bool, siteID int) string {
if m.isMonitorInMaintenance(siteID) {
return m.st.subtleStyle.Render("—")
}
return m.fmtUptime(statuses)
}
func (m Model) fmtUptime(statuses []bool) string {
if len(statuses) == 0 {
return m.st.subtleStyle.Render("—")
@@ -155,6 +162,27 @@ func (m Model) fmtRetries(site models.Site) string {
return s
}
func (m Model) fmtStatusDot(status models.Status, paused bool, inMaint bool) string {
if paused {
return m.st.warnStyle.Render("◇")
}
if inMaint {
return m.st.maintStyle.Render("◼")
}
switch status {
case models.StatusDown, models.StatusSSLExp:
return m.st.dangerStyle.Render("▼")
case models.StatusLate:
return m.st.warnStyle.Render("◆")
case models.StatusStale:
return m.st.staleStyle.Render("◆")
case models.StatusPending:
return m.st.subtleStyle.Render("○")
default:
return m.st.specialStyle.Render("▲")
}
}
func (m Model) fmtStatus(status models.Status, paused bool, inMaint bool) string {
if paused {
return m.st.warnStyle.Render("◇ PAUSED")
+125
View File
@@ -6,6 +6,131 @@ import (
"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 {
borderColor := m.theme.Border
titleColor := m.theme.Muted
+2 -4
View File
@@ -156,9 +156,8 @@ func resolveSparklineIndex(x, sparkWidth, dataLen int) int {
}
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor) string {
allSites := m.engine.GetAllSites()
var childStatuses [][]bool
for _, s := range allSites {
for _, s := range m.sites {
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
hist, _ := m.engine.GetHistory(s.ID)
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 {
allSites := m.engine.GetAllSites()
var allStatuses [][]bool
for _, s := range allSites {
for _, s := range m.sites {
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
hist, _ := m.engine.GetHistory(s.ID)
if len(hist.Statuses) > 0 {
+17 -3
View File
@@ -40,13 +40,13 @@ func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string {
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
}
func (m Model) viewLogsSidebar(width, maxLines int) string {
func (m Model) viewLogsStrip(width, maxLines int) string {
logs := m.engine.GetLogs()
if len(logs) == 0 {
return m.st.subtleStyle.Render(" No logs yet")
}
sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width)
style := lipgloss.NewStyle().Width(width).MaxWidth(width)
var all []string
for _, entry := range logs {
@@ -69,7 +69,21 @@ func (m Model) viewLogsSidebar(width, maxLines int) string {
}
visible := all[start:end]
return sidebarStyle.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) {
+15 -6
View File
@@ -21,27 +21,36 @@ type maintFormData struct {
}
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 {
if mw.Type != "maintenance" {
continue
}
now := time.Now()
if mw.StartTime.After(now) {
continue
}
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
continue
}
if mw.MonitorID == 0 || mw.MonitorID == monitorID {
return true
if mw.MonitorID == 0 {
for _, s := range m.sites {
set[s.ID] = true
}
break
}
set[mw.MonitorID] = true
for _, s := range m.sites {
if s.ID == monitorID && s.ParentID > 0 && mw.MonitorID == s.ParentID {
return true
if s.ParentID == mw.MonitorID {
set[s.ID] = true
}
}
}
return false
m.maintSet = set
}
func (m *Model) initMaintHuhForm() tea.Cmd {
+56 -22
View File
@@ -34,10 +34,9 @@ type siteFormData struct {
type colKey int
const (
colNum colKey = iota
colDot colKey = iota
colName
colType
colStatus
colLatency
colUptime
colHistory
@@ -55,17 +54,18 @@ type columnDef struct {
}
var siteColumns = []columnDef{
{colNum, "#", "#", 4, 4, 0},
{colDot, "", "", 3, 3, 0},
{colName, "NAME", "NAME", 0, 0, 0},
{colType, "TYPE", "TYPE", 10, 8, mediumBreakpoint},
{colStatus, "STATUS", "STATUS", 10, 10, 0},
{colType, "TYPE", "TYPE", 10, 8, 0},
{colLatency, "LATENCY", "LAT", 10, 7, 0},
{colUptime, "UPTIME", "UP%", 8, 8, mediumBreakpoint},
{colHistory, "HISTORY", "HISTORY", 0, 0, mediumBreakpoint},
{colSSL, "SSL", "SSL", 7, 5, wideBreakpoint},
{colRetries, "RETRIES", "RT", 9, 5, wideBreakpoint},
{colUptime, "UPTIME", "UP%", 8, 8, 0},
{colHistory, "HISTORY", "HISTORY", 0, 0, 0},
{colSSL, "SSL", "SSL", 7, 5, 0},
{colRetries, "RETRIES", "RT", 9, 5, 0},
}
var columnDropOrder = []colKey{colHistory, colRetries, colSSL, colUptime, colType}
type tableLayout struct {
nameW, sparkW int
headers []string
@@ -76,17 +76,51 @@ type tableLayout struct {
func (m Model) computeLayout() tableLayout {
wide := m.isWide()
var active []colKey
var headers []string
var widths []int
var fixed int
cw := m.contentWidth
if cw == 0 {
cw = m.termWidth
}
dropped := make(map[colKey]bool)
minNameW := 20
minSparkW := 12
for attempt := 0; attempt <= len(columnDropOrder); attempt++ {
var fixed int
var flexCount int
for _, c := range siteColumns {
if dropped[c.key] {
continue
}
w := c.narrowW
if wide {
w = c.wideW
}
if w > 0 {
fixed += w
} else {
flexCount++
}
}
numCols := len(siteColumns) - len(dropped)
borderOverhead := 2 + (numCols - 1)
avail := cw - chromePadH - 2 - borderOverhead - fixed
minFlex := minNameW
if flexCount > 1 {
minFlex += minSparkW
}
if avail >= minFlex || attempt >= len(columnDropOrder) {
break
}
dropped[columnDropOrder[attempt]] = true
}
var active []colKey
var headers []string
var widths []int
var fixed int
for _, c := range siteColumns {
if c.minTerm > 0 && cw < c.minTerm {
if dropped[c.key] {
continue
}
active = append(active, c.key)
@@ -106,12 +140,12 @@ func (m Model) computeLayout() tableLayout {
}
sortColMap := map[int]colKey{
sortStatus: colStatus,
sortStatus: colDot,
sortName: colName,
sortLatency: colLatency,
}
sortableKeys := map[colKey]string{
colStatus: "sort-status",
colDot: "sort-status",
colName: "sort-name",
colLatency: "sort-latency",
}
@@ -245,11 +279,11 @@ func (m Model) viewSitesTab() string {
if site.Type == "group" {
groupRows[i-start] = true
icon := typeIcon("group", m.collapsed[site.ID])
inMaint := m.isMonitorInMaintenance(site.ID)
cells := map[colKey]string{
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)),
colType: "group",
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
colLatency: m.st.subtleStyle.Render("—"),
colUptime: m.groupUptime(site.ID),
colHistory: m.groupSparkline(site.ID, sparkWidth, rowBg),
@@ -293,13 +327,13 @@ func (m Model) viewSitesTab() string {
spark = m.latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg)
}
inMaint := m.isMonitorInMaintenance(site.ID)
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),
colType: typeIcon(site.Type, false) + " " + site.Type,
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
colLatency: m.fmtLatency(site.Latency),
colUptime: m.fmtUptime(hist.Statuses),
colUptime: m.fmtUptimeMaint(hist.Statuses, site.ID),
colHistory: spark,
colSSL: m.fmtSSL(site),
colRetries: m.fmtRetries(site),
+91 -47
View File
@@ -46,10 +46,12 @@ type Theme struct {
var themes = []Theme{
themeFlexokiDark,
themeEverforest,
themeKanagawa,
themeTokyoNight,
themeCatppuccinMocha,
themeNord,
themeGruvbox,
themeRosePine,
themeDracula,
}
var themeFlexokiDark = Theme{
@@ -80,7 +82,7 @@ var themeTokyoNight = Theme{
Panel: cc("#292e42", ""),
Border: cc("#3b4261", "8"),
Fg: cc("#c0caf5", "15"),
Muted: cc("#a9b1d6", "7"),
Muted: cc("#7982a9", "7"),
Subtle: cc("#565f89", "7"),
Success: cc("#9ece6a", "10"),
Warning: cc("#e0af68", "11"),
@@ -91,28 +93,28 @@ var themeTokyoNight = Theme{
Purple: cc("#bb9af7", "13"),
ZebraBg: cc("#1c1d28", ""),
SelectedFg: cc("#c0caf5", "15"),
SelectedBg: cc("#292e42", "4"),
SelectedBg: cc("#363c53", "4"),
}
var themeGruvbox = Theme{
Name: "Gruvbox",
Bg: cc("#282828", ""),
Surface: cc("#3c3836", ""),
Panel: cc("#504945", ""),
Border: cc("#665c54", "8"),
Fg: cc("#ebdbb2", "15"),
Muted: cc("#bdae93", "7"),
Subtle: cc("#7c6f64", "7"),
Success: cc("#b8bb26", "10"),
Warning: cc("#fabd2f", "11"),
Stale: cc("#fe8019", "3"),
Danger: cc("#fb4934", "9"),
Info: cc("#83a598", "12"),
Accent: cc("#8ec07c", "14"),
Purple: cc("#d3869b", "13"),
ZebraBg: cc("#2a2a2a", ""),
SelectedFg: cc("#fbf1c7", "15"),
SelectedBg: cc("#504945", "4"),
var themeDracula = Theme{
Name: "Dracula",
Bg: cc("#282a36", ""),
Surface: cc("#343746", ""),
Panel: cc("#44475a", ""),
Border: cc("#6272a4", "8"),
Fg: cc("#f8f8f2", "15"),
Muted: cc("#a9b0cb", "7"),
Subtle: cc("#6272a4", "7"),
Success: cc("#50fa7b", "10"),
Warning: cc("#f1fa8c", "11"),
Stale: cc("#ffb86c", "3"),
Danger: cc("#ff5555", "9"),
Info: cc("#8be9fd", "12"),
Accent: cc("#bd93f9", "14"),
Purple: cc("#ff79c6", "13"),
ZebraBg: cc("#2c2e3a", ""),
SelectedFg: cc("#f8f8f2", "15"),
SelectedBg: cc("#52556b", "4"),
}
var themeCatppuccinMocha = Theme{
@@ -124,37 +126,79 @@ var themeCatppuccinMocha = Theme{
Fg: cc("#cdd6f4", "15"),
Muted: cc("#a6adc8", "7"),
Subtle: cc("#6c7086", "7"),
Success: cc("#a6e3a1", "10"),
Warning: cc("#f9e2af", "11"),
Success: cc("#7dc47a", "10"),
Warning: cc("#f0c644", "11"),
Stale: cc("#fab387", "3"),
Danger: cc("#f38ba8", "9"),
Danger: cc("#e6546e", "9"),
Info: cc("#89b4fa", "12"),
Accent: cc("#94e2d5", "14"),
Purple: cc("#cba6f7", "13"),
ZebraBg: cc("#232334", ""),
ZebraBg: cc("#212130", ""),
SelectedFg: cc("#cdd6f4", "15"),
SelectedBg: cc("#45475a", "4"),
SelectedBg: cc("#585b70", "4"),
}
var themeNord = Theme{
Name: "Nord",
Bg: cc("#2e3440", ""),
Surface: cc("#3b4252", ""),
Panel: cc("#434c5e", ""),
Border: cc("#4c566a", "8"),
Fg: cc("#d8dee9", "15"),
Muted: cc("#d8dee9", "7"),
Subtle: cc("#4c566a", "7"),
Success: cc("#a3be8c", "10"),
Warning: cc("#ebcb8b", "11"),
Stale: cc("#d08770", "3"),
Danger: cc("#bf616a", "9"),
Info: cc("#81a1c1", "12"),
Accent: cc("#88c0d0", "14"),
Purple: cc("#b48ead", "13"),
ZebraBg: cc("#323845", ""),
SelectedFg: cc("#eceff4", "15"),
SelectedBg: cc("#434c5e", "4"),
var themeRosePine = Theme{
Name: "Rosé Pine",
Bg: cc("#191724", ""),
Surface: cc("#1f1d2e", ""),
Panel: cc("#26233a", ""),
Border: cc("#524f67", "8"),
Fg: cc("#e0def4", "15"),
Muted: cc("#908caa", "7"),
Subtle: cc("#6e6a86", "7"),
Success: cc("#9ccfd8", "10"),
Warning: cc("#f6c177", "11"),
Stale: cc("#ebbcba", "3"),
Danger: cc("#eb6f92", "9"),
Info: cc("#3e8fb0", "12"),
Accent: cc("#c4a7e7", "14"),
Purple: cc("#ebbcba", "13"),
ZebraBg: cc("#1c1a28", ""),
SelectedFg: cc("#e0def4", "15"),
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 {
+62 -45
View File
@@ -80,8 +80,6 @@ const (
chromeFooter = 2 // footer: "\n" prefix + text line
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
detailSparkWidth = 40
)
const (
@@ -97,6 +95,20 @@ const (
panelMaint = 3
)
type bottomPanel int
const (
bottomNone bottomPanel = iota
bottomLogs
bottomMaint
)
const (
detailDefault = 0
detailSLA = 1
detailHistory = 2
)
const (
sortStatus = 0
sortName = 1
@@ -107,19 +119,17 @@ const (
type sessionState int
const (
stateDashboard sessionState = iota
stateLogs
stateDetail
stateAlertDetail
stateFormSite
stateFormAlert
stateFormUser
stateConfirmDelete
stateFormMaint
stateHistory
stateSLA
stateSettings
stateMaintDetail
stateDashboard sessionState = 0
stateLogs sessionState = 1
stateDetailFullscreen sessionState = 2
stateAlertDetail sessionState = 3
stateFormSite sessionState = 4
stateFormAlert sessionState = 5
stateFormUser sessionState = 6
stateConfirmDelete sessionState = 7
stateFormMaint sessionState = 8
stateSettings sessionState = 11
stateMaintDetail sessionState = 12
)
type Model struct {
@@ -138,6 +148,7 @@ type Model struct {
termHeight int
contentWidth int
focusedPanel int
detailMode int
logScrollOffset int
editID int
editToken string
@@ -154,17 +165,16 @@ type Model struct {
logTotal int
logShown int
historyViewport viewport.Model
historyChanges []models.StateChange
historySiteName string
historySiteID int
slaViewport viewport.Model
slaReport monitor.SLAReport
slaDailyBreakdown []monitor.DayReport
slaSiteName string
slaSiteID int
slaPeriodIdx int
slaReport monitor.SLAReport
slaDailyBreakdown []monitor.DayReport
slaSiteName string
slaSiteID int
slaPeriodIdx int
detailScrollOffset int
isAdmin bool
zones *zone.Manager
@@ -197,20 +207,18 @@ type Model struct {
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
tabSeq int // seq of the newest issued tab-data load
logsOpen bool
maintSet map[int]bool
bottomPanel bottomPanel
detailOpen bool
maintOpen bool
maintCursor int
maintOffset int
detailChanges []models.StateChange
detailChangesSiteID int
detailDailyDays []monitor.DayReport
detailViewport viewport.Model
filterMode bool
filterText string
sparkTooltipIdx int // clicked sparkline data index, -1 = none
pendingG bool
// 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.
@@ -237,25 +245,34 @@ func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor
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{
ctx: ctx,
state: stateDashboard,
logViewport: vpLogs,
maxTableRows: 5,
isAdmin: isAdmin,
store: s,
engine: eng,
zones: z,
pulseSpring: spring,
collapsed: collapsed,
theme: theme,
themeIndex: themeIdx,
st: newStyles(theme),
logsOpen: true,
detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version,
sparkTooltipIdx: -1,
ctx: ctx,
state: stateDashboard,
logViewport: vpLogs,
maxTableRows: 5,
isAdmin: isAdmin,
store: s,
engine: eng,
zones: z,
pulseSpring: spring,
collapsed: collapsed,
theme: theme,
themeIndex: themeIdx,
st: newStyles(theme),
bottomPanel: bp,
detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version,
}
}
+294 -247
View File
@@ -6,7 +6,6 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
)
@@ -20,9 +19,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tabDataMsg:
return m.handleTabData(msg)
case detailDataMsg:
// Drop replies for a site the user has already navigated away from,
// so a slow load can't clobber the panel currently on screen.
if m.state == stateDetail && 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
}
m.detailChanges = msg.changes
@@ -31,11 +29,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case historyDataMsg:
if msg.siteID != m.historySiteID {
return m, nil // stale reply for a previously opened history
return m, nil
}
m.historyChanges = msg.changes
m.historyViewport.SetContent(m.buildHistoryContent())
m.historyViewport.GotoTop()
return m, nil
case slaDataMsg:
return m.handleSLAData(msg)
@@ -56,6 +52,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.state == stateLogs {
return m.handleLogsFullscreen(msg)
}
if m.state == stateDetailFullscreen {
return m.handleDetailFullscreen(msg)
}
switch msg := msg.(type) {
case tea.MouseMsg:
@@ -155,15 +154,15 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
const detailInlineHeight = 12
const logsStripHeight = 6
func (m *Model) recalcLayout() {
chrome := chromeBase
if m.filterMode || m.filterText != "" {
chrome++
}
if m.detailOpen {
chrome += detailInlineHeight
if m.bottomPanel != bottomNone {
chrome += logsStripHeight
}
m.maxTableRows = m.termHeight - chrome
if m.maxTableRows < 1 {
@@ -177,10 +176,6 @@ func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.recalcLayout()
m.logViewport.Width = msg.Width - chromePadH
m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeFooter + 2)
m.historyViewport.Width = msg.Width - chromePadH
m.historyViewport.Height = msg.Height - 10
m.slaViewport.Width = msg.Width - chromePadH
m.slaViewport.Height = msg.Height - 16
if m.huhForm != nil {
formHeight := msg.Height - 7
if formHeight < 5 {
@@ -212,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
// up without leaving and re-entering. Nil when no detail panel is open.
func (m *Model) detailRefreshCmd() tea.Cmd {
if m.state != stateDetail || m.cursor >= len(m.sites) {
if (!m.detailOpen && m.state != stateDetailFullscreen) || m.cursor >= len(m.sites) {
return nil
}
return m.loadDetailCmd(m.sites[m.cursor].ID)
@@ -236,6 +231,7 @@ func (m *Model) handleTabData(msg tabDataMsg) (tea.Model, tea.Cmd) {
}
m.nodes = msg.nodes
m.maintenanceWindows = msg.maint
m.buildMaintSet()
m.clampCursor()
return m, nil
}
@@ -256,15 +252,32 @@ func (m *Model) testAlertCmd(id int, name string) tea.Cmd {
func (m *Model) handleLogsFullscreen(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.logViewport.GotoTop()
return m, nil
}
m.pendingG = true
return m, nil
}
m.pendingG = false
switch msg.String() {
case "esc", "q":
m.state = stateDashboard
m.focusedPanel = panelLogs
m.focusedPanel = panelMonitors
case "ctrl+c":
return m, tea.Quit
case "f":
m.logFilterImportant = !m.logFilterImportant
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":
m.logViewport.ScrollUp(1)
case "down", "j":
@@ -287,31 +300,96 @@ func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
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) {
if m.state == stateHistory {
switch msg.Button {
case tea.MouseButtonWheelUp:
m.historyViewport.ScrollUp(3)
case tea.MouseButtonWheelDown:
m.historyViewport.ScrollDown(3)
}
return m, nil
}
if m.state == stateSLA {
switch msg.Button {
case tea.MouseButtonWheelUp:
m.slaViewport.ScrollUp(3)
case tea.MouseButtonWheelDown:
m.slaViewport.ScrollDown(3)
}
return m, nil
}
if m.state == stateDetail {
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
return m.handleSparklineClick(msg)
}
return m, nil
}
if m.state != stateDashboard {
return m, nil
}
@@ -331,6 +409,18 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
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()
if msg.Button == tea.MouseButtonWheelUp {
if m.cursor > 0 {
@@ -367,12 +457,6 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
switch m.state {
case stateDetail:
return m.handleDetailKey(msg)
case stateHistory:
return m.handleHistoryKey(msg)
case stateSLA:
return m.handleSLAKey(msg)
case stateAlertDetail:
return m.handleAlertDetailKey(msg)
case stateSettings:
@@ -417,115 +501,6 @@ func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
m.detailViewport.ScrollUp(1)
return m, nil
case "down", "j":
m.detailViewport.ScrollDown(1)
return m, nil
case "pgup":
m.detailViewport.ScrollUp(m.detailViewport.Height / 2)
return m, nil
case "pgdown":
m.detailViewport.ScrollDown(m.detailViewport.Height / 2)
return m, nil
case "esc":
if m.sparkTooltipIdx >= 0 {
m.sparkTooltipIdx = -1
return m, nil
}
m.sparkTooltipIdx = -1
m.state = stateDashboard
case "i":
m.sparkTooltipIdx = -1
m.state = stateDashboard
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.historyViewport = viewport.New(
m.termWidth-chromePadH,
m.termHeight-10,
)
m.historyViewport.SetContent("\n Loading state history...")
m.state = stateHistory
return m, m.loadHistoryCmd(site.ID)
}
case "s":
if m.cursor < len(m.sites) {
return m, m.openSLAView(m.sites[m.cursor])
}
case "q":
m.state = stateDashboard
}
return m, nil
}
func (m *Model) handleSparklineClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.cursor >= len(m.sites) {
return m, nil
}
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
if zi := m.zones.Get("spark-latency"); zi != nil && !zi.IsZero() && zi.InBounds(msg) {
x, _ := zi.Pos(msg)
m.sparkTooltipIdx = resolveSparklineIndex(x, detailSparkWidth, len(hist.Latencies))
return m, nil
}
if zi := m.zones.Get("spark-heartbeat"); zi != nil && !zi.IsZero() && zi.InBounds(msg) {
x, _ := zi.Pos(msg)
m.sparkTooltipIdx = resolveSparklineIndex(x, detailSparkWidth, len(hist.Statuses))
return m, nil
}
m.sparkTooltipIdx = -1
return m, nil
}
func (m *Model) handleSLAKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "esc":
m.state = stateDetail
case "1", "2", "3", "4":
idx := int(msg.String()[0]-'0') - 1
if idx >= 0 && idx < len(slaPeriods) {
m.slaPeriodIdx = idx
return m, m.loadSLACmd(m.slaSiteID, idx)
}
case "up", "k":
m.slaViewport.ScrollUp(1)
case "down", "j":
m.slaViewport.ScrollDown(1)
case "pgup":
m.slaViewport.HalfPageUp()
case "pgdown":
m.slaViewport.HalfPageDown()
case "ctrl+c":
return m, tea.Quit
}
return m, nil
}
func (m *Model) openSLAView(site models.Site) tea.Cmd {
m.slaSiteName = site.Name
m.slaSiteID = site.ID
m.slaPeriodIdx = 2 // default 30d
m.slaViewport = viewport.New(
m.termWidth-chromePadH,
m.termHeight-16,
)
m.slaViewport.SetContent("\n Loading SLA report...")
m.state = stateSLA
return m.loadSLACmd(site.ID, m.slaPeriodIdx)
}
// handleSLAData folds an async SLA load into the model. The SLA math itself is
// pure CPU and cheap, so it runs here; only the state-change read happens in
// the Cmd. Replies for a different site or period than currently selected are
@@ -546,35 +521,6 @@ func (m *Model) handleSLAData(msg slaDataMsg) (tea.Model, tea.Cmd) {
m.slaReport = monitor.ComputeSLA(msg.changes, currentStatus, period.duration)
m.slaDailyBreakdown = monitor.ComputeDailyBreakdown(msg.changes, currentStatus, period.days, time.Now())
m.slaViewport = viewport.New(
m.termWidth-chromePadH,
m.termHeight-16,
)
m.slaViewport.SetContent(m.buildSLADailyContent())
m.slaViewport.GotoTop()
return m, nil
}
func (m *Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "esc":
m.state = stateDetail
case "up", "k":
m.historyViewport.ScrollUp(1)
case "down", "j":
m.historyViewport.ScrollDown(1)
case "pgup":
m.historyViewport.HalfPageUp()
case "pgdown":
m.historyViewport.HalfPageDown()
case "home", "g":
m.historyViewport.GotoTop()
case "end", "G":
m.historyViewport.GotoBottom()
case "ctrl+c":
return m, tea.Quit
}
return m, nil
}
@@ -635,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) {
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() {
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":
m.state = stateDashboard
case "ctrl+c":
@@ -726,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) {
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() {
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":
return m, tea.Quit
case "/":
@@ -745,26 +756,33 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.sortAsc = !m.sortAsc
m.refreshLive()
case "m":
if m.termWidth >= wideBreakpoint {
if m.focusedPanel == panelMaint {
m.maintOpen = false
m.focusedPanel = panelMonitors
} else {
m.maintOpen = true
m.focusedPanel = panelMaint
}
m.recalcLayout()
}
case "l":
if m.focusedPanel == panelLogs {
m.logsOpen = false
if m.bottomPanel == bottomMaint {
m.bottomPanel = bottomNone
m.focusedPanel = panelMonitors
} 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.recalcLayout()
return m, m.saveBottomPanelPref()
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 {
m.scrollMaintCursor(-1)
return m, nil
@@ -780,10 +798,16 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
m.syncSelectedID()
if m.detailOpen && m.cursor < len(m.sites) {
m.detailMode = detailDefault
m.detailScrollOffset = 0
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
}
case "down", "j":
if m.focusedPanel == panelDetail && m.detailOpen {
m.detailScrollOffset++
return m, nil
}
if m.focusedPanel == panelMaint {
m.scrollMaintCursor(1)
return m, nil
@@ -800,6 +824,8 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
m.syncSelectedID()
if m.detailOpen && m.cursor < len(m.sites) {
m.detailMode = detailDefault
m.detailScrollOffset = 0
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
}
@@ -825,8 +851,35 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
if len(m.sites) > 0 {
m.state = stateDetail
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
narrow := m.termWidth < wideBreakpoint
if narrow {
m.detailMode = detailDefault
m.detailScrollOffset = 0
m.state = stateDetailFullscreen
return m, m.loadDetailCmd(m.sites[m.cursor].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":
return m.handleEditItem()
@@ -853,34 +906,16 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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":
if m.focusedPanel != panelMonitors {
m.focusedPanel = panelMonitors
} else if m.detailOpen && m.detailMode != detailDefault {
m.detailMode = detailDefault
m.detailScrollOffset = 0
} else if m.detailOpen {
m.detailOpen = false
m.detailMode = detailDefault
m.detailScrollOffset = 0
m.recalcLayout()
st := m.store
ctx := m.ctx
@@ -894,17 +929,28 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.historySiteName = site.Name
m.historySiteID = site.ID
m.historyChanges = nil
m.historyViewport = viewport.New(
m.termWidth-chromePadH,
m.termHeight-10,
)
m.historyViewport.SetContent("\n Loading state history...")
m.state = stateHistory
m.detailMode = detailHistory
m.detailScrollOffset = 0
return m, m.loadHistoryCmd(site.ID)
}
case "s":
if m.detailOpen && m.cursor < len(m.sites) {
return m, m.openSLAView(m.sites[m.cursor])
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.detailOpen && 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 "x":
if m.focusedPanel == panelMaint {
@@ -1005,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
_, 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
} else if m.zones.Get("panel-monitors").InBounds(msg) {
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) {
m.focusedPanel = panelLogs
return m, nil
@@ -1018,22 +1081,6 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
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
}
+158 -20
View File
@@ -118,11 +118,10 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}}
m.cursor = 0
m.state = stateDetail
m.detailOpen = true
m.termWidth = 120
m.termHeight = 40
// Entering detail dispatches the load Cmd.
cmd := m.loadDetailCmd(1)
if cmd == nil {
t.Fatal("loadDetailCmd returned nil")
@@ -136,16 +135,14 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
t.Fatalf("expected exactly 1 store hit from the load Cmd, got %d", ms.stateChangeCalls)
}
// Apply the msg through Update (caches into the model).
updated, _ := m.Update(dd)
m = updated.(Model)
if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 {
t.Fatalf("detail changes not cached: id=%d n=%d", m.detailChangesSiteID, len(m.detailChanges))
}
// Render the detail panel several times — it must read the cache, not the DB.
for i := 0; i < 3; i++ {
_ = m.viewDetailPanel()
_ = m.viewDetailInline(80, 30)
}
if ms.stateChangeCalls != 1 {
t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls)
@@ -202,16 +199,16 @@ func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}}
m.state = stateDetail
m.detailOpen = true
m.termWidth, m.termHeight = 120, 40
updated, cmd := (&m).handleDetailKey(keyMsg("h"))
updated, cmd := (&m).handleDashboardKey(keyMsg("h"))
if ms.stateChangeCalls != 0 {
t.Fatal("history keypress hit the store synchronously in Update")
}
got := updated.(*Model)
if got.state != stateHistory || got.historySiteID != 7 {
t.Fatalf("history view not opened: state=%v siteID=%d", got.state, got.historySiteID)
if got.detailMode != detailHistory || got.historySiteID != 7 {
t.Fatalf("history mode not set: mode=%v siteID=%d", got.detailMode, got.historySiteID)
}
if cmd == nil {
t.Fatal("expected a history load Cmd")
@@ -229,7 +226,6 @@ func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) {
t.Fatal("history reply not folded into the model")
}
// A reply for a previously opened site must not clobber the current one.
m2.historySiteID = 9
stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil})
if m3 := stale.(Model); len(m3.historyChanges) != 1 {
@@ -241,20 +237,15 @@ func TestSLAData_DropsStaleReply(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.termWidth, m.termHeight = 120, 40
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 3}, SiteState: models.SiteState{Status: "UP"}}}
m.detailOpen = true
m.slaSiteID = 3
m.slaPeriodIdx = 2
if cmd := (&m).openSLAView(m.sites[0]); cmd == nil {
t.Fatal("openSLAView should return a load Cmd")
}
// Reply for a different period than currently selected → dropped.
// (slaDataMsg routes through a pointer-receiver handler, so Update
// returns *Model on this path.)
updated, _ := m.Update(slaDataMsg{siteID: 3, periodIdx: 0})
if mm := updated.(*Model); mm.slaDailyBreakdown != nil {
t.Error("stale SLA reply (old period) was applied")
}
// Matching reply → report computed.
updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx})
if mm := updated.(*Model); mm.slaDailyBreakdown == nil {
t.Error("matching SLA reply was not applied")
@@ -309,17 +300,81 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) {
}
}
func TestEnterNarrowTerminal_OpensFullscreen(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "UP"}}}
m.termWidth = 80 // narrow, below wideBreakpoint (120)
m.termHeight = 40
m.focusedPanel = panelMonitors
updated, cmd := (&m).handleDashboardKey(keyMsg("enter"))
got := updated.(*Model)
if got.state != stateDetailFullscreen {
t.Fatalf("expected stateDetailFullscreen, got %d", got.state)
}
if cmd == nil {
t.Fatal("expected a detail load Cmd")
}
}
func TestEnterWideTerminal_TogglesSidebar(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "UP"}}}
m.termWidth = 140 // wide, above wideBreakpoint (120)
m.termHeight = 40
m.focusedPanel = panelMonitors
updated, _ := (&m).handleDashboardKey(keyMsg("enter"))
got := updated.(*Model)
if got.state != stateDashboard {
t.Fatalf("expected stateDashboard, got %d", got.state)
}
if !got.detailOpen {
t.Fatal("expected detailOpen to be true")
}
}
func TestDetailFullscreen_EscReturnsToDashboard(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.state = stateDetailFullscreen
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}}}
updated, _ := (&m).handleDetailFullscreen(tea.KeyMsg{Type: tea.KeyEsc})
got := updated.(*Model)
if got.state != stateDashboard {
t.Fatalf("expected stateDashboard after Esc, got %d", got.state)
}
if got.focusedPanel != panelMonitors {
t.Fatalf("expected panelMonitors focus, got %d", got.focusedPanel)
}
}
func TestDetailRefreshCmd_FiresForFullscreen(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
m.state = stateDetailFullscreen
m.detailOpen = false
cmd := (&m).detailRefreshCmd()
if cmd == nil {
t.Fatal("detailRefreshCmd should fire for stateDetailFullscreen")
}
}
func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
m.state = stateDashboard
m.detailOpen = false
if (&m).detailRefreshCmd() != nil {
t.Error("refresh Cmd issued outside the detail view")
}
m.state = stateDetail
m.detailOpen = true
cmd := (&m).detailRefreshCmd()
if cmd == nil {
t.Fatal("open detail panel should refresh on the tab-data cadence")
@@ -334,3 +389,86 @@ func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
t.Error("refresh Cmd issued for an out-of-range cursor")
}
}
func TestBuildMaintSet_GlobalWindow(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
{SiteConfig: models.SiteConfig{ID: 2, Name: "b"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 0, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if !m.maintSet[1] || !m.maintSet[2] {
t.Error("global maint window should mark all monitors")
}
}
func TestBuildMaintSet_TargetedWindow(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
{SiteConfig: models.SiteConfig{ID: 2, Name: "b"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 1, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if !m.maintSet[1] {
t.Error("targeted window should mark monitor 1")
}
if m.maintSet[2] {
t.Error("targeted window should NOT mark monitor 2")
}
}
func TestBuildMaintSet_GroupPropagates(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 10, Name: "group", Type: "group"}},
{SiteConfig: models.SiteConfig{ID: 11, Name: "child1", ParentID: 10}},
{SiteConfig: models.SiteConfig{ID: 12, Name: "child2", ParentID: 10}},
{SiteConfig: models.SiteConfig{ID: 20, Name: "other"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 10, Type: "maintenance", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if !m.maintSet[10] || !m.maintSet[11] || !m.maintSet[12] {
t.Error("group maint window should mark group + children")
}
if m.maintSet[20] {
t.Error("unrelated monitor should NOT be marked")
}
}
func TestBuildMaintSet_ExpiredIgnored(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 1, Type: "maintenance",
StartTime: time.Now().Add(-2 * time.Hour),
EndTime: time.Now().Add(-1 * time.Hour)},
}
m.buildMaintSet()
if m.maintSet[1] {
t.Error("expired maint window should not mark monitor")
}
}
func TestBuildMaintSet_IncidentIgnored(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.sites = []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "a"}},
}
m.maintenanceWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 0, Type: "incident", StartTime: time.Now().Add(-time.Hour)},
}
m.buildMaintSet()
if m.maintSet[1] {
t.Error("incident windows should not mark monitors as in maintenance")
}
}
+68 -47
View File
@@ -90,14 +90,10 @@ func (m Model) View() string {
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
}
return ""
case stateDetail:
return m.zones.Scan(m.viewDetailPanel())
case stateLogs:
return m.viewLogsFullscreen()
case stateHistory:
return m.viewHistoryPanel()
case stateSLA:
return m.viewSLAPanel()
case stateDetailFullscreen:
return m.viewDetailFullscreen()
case stateAlertDetail:
return m.viewAlertDetailPanel()
case stateSettings:
@@ -149,58 +145,61 @@ func (m Model) computeStats() dashboardStats {
return s
}
const maintSidebarW = 22
func (m Model) viewMonitorsLayout() string {
availW := m.termWidth - chromePadH
wide := m.termWidth >= wideBreakpoint
showMaint := m.maintOpen && wide
showLogs := m.logsOpen && wide
showDetail := m.detailOpen && wide
var maintW, logsW, monW int
if showMaint {
maintW = maintSidebarW
if maintW > availW/4 {
maintW = availW / 4
}
}
remaining := availW - maintW
if showLogs {
monW = remaining * 70 / 100
logsW = remaining - monW
var detailW, monW int
if showDetail {
monW = availW * 60 / 100
detailW = availW - monW
} else {
monW = remaining
monW = availW
}
m.contentWidth = monW - 2
monTargetH := m.maxTableRows + 5
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
if showMaint {
sidebar := m.viewMaintSidebar(maintW-2, m.maxTableRows)
maintPanel := m.zones.Mark("panel-maint", m.titledPanel("Maint", sidebar, maintW, m.focusedPanel == panelMaint))
topParts = append(topParts, maintPanel)
}
topParts = append(topParts, monPanel)
if showLogs {
logContent := m.viewLogsSidebar(logsW-2, m.maxTableRows)
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, logsW, m.focusedPanel == panelLogs))
topParts = append(topParts, logPanel)
if showDetail {
title := ""
if m.cursor < len(m.sites) {
title = m.sites[m.cursor].Name
}
switch m.detailMode {
case detailSLA:
title = "SLA · " + title
case detailHistory:
title = "History · " + title
}
monHeight := lipgloss.Height(monPanel)
detail := m.viewDetailInline(detailW-2, monHeight)
footer := m.detailFooter(detailW - 2)
detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, footer, detailW, monHeight, m.detailScrollOffset, scrollbar{}, m.focusedPanel == panelDetail))
topParts = append(topParts, detailPanel)
}
top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...)
if m.detailOpen {
site := ""
if m.cursor < len(m.sites) {
site = m.sites[m.cursor].Name
}
detail := m.viewDetailInline(availW - 2)
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
return top + "\n" + detailPanel
switch m.bottomPanel {
case bottomLogs:
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
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
}
@@ -277,24 +276,46 @@ func (m Model) renderStatusLine(stats dashboardStats) string {
return left + strings.Repeat(" ", padW) + ver
}
func (m Model) hotkey(key, desc string) string {
k := lipgloss.NewStyle().Foreground(m.theme.Accent).Render(key)
d := m.st.subtleStyle.Render(desc)
return k + " " + d
}
func (m Model) renderFooter(_ dashboardStats) string {
dot := m.st.subtleStyle.Render(" · ")
if m.filterMode {
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
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 {
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 {
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 {
parts = []string{m.hotkey("1-4", "Period"), m.hotkey("Esc", "Back")}
} else if m.detailOpen && m.detailMode == detailHistory {
parts = []string{m.hotkey("Esc", "Back")}
} else if m.detailOpen {
keys = "[i]Close [Enter]Expand [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 {
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 != "" {
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s ", m.filterText)) + line
}
-342
View File
@@ -1,342 +0,0 @@
package tui
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
)
func (m Model) viewDetailPanel() string {
if m.cursor >= len(m.sites) {
return ""
}
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
var b strings.Builder
totalW := m.termWidth - chromePadH
var breadcrumb string
if site.ParentID > 0 {
for _, s := range m.sites {
if s.ID == site.ParentID {
breadcrumb = m.st.subtleStyle.Render(" Monitors > "+s.Name+" > ") + m.st.titleStyle.Render(site.Name)
break
}
}
}
if breadcrumb == "" {
breadcrumb = m.st.subtleStyle.Render(" Monitors > ") + m.st.titleStyle.Render(site.Name)
}
b.WriteString(breadcrumb + "\n")
b.WriteString(m.divider() + "\n")
// Two-column layout for key info
colW := (totalW - 4) / 2
if colW < 30 {
colW = 30
}
row := func(label, value string) string {
return fmt.Sprintf(" %-16s %s", m.st.subtleStyle.Render(label), value)
}
divW := totalW - 4
if divW < 20 {
divW = 20
}
sectionDiv := m.st.subtleStyle.Render(strings.Repeat("─", divW))
sectionHead := func(title string) string {
return m.st.titleStyle.Render(" "+title) + " " + m.st.subtleStyle.Render(strings.Repeat("─", divW-len(title)-3))
}
// Left column: endpoint details
var left []string
left = append(left, m.st.titleStyle.Render(" ENDPOINT"))
left = append(left, row("Type", site.Type))
if site.URL != "" {
left = append(left, row("URL", limitStr(site.URL, colW-19)))
}
if site.Hostname != "" {
left = append(left, row("Host", site.Hostname))
}
if site.Port > 0 {
left = append(left, row("Port", strconv.Itoa(site.Port)))
}
if site.Interval > 0 {
left = append(left, row("Interval", fmt.Sprintf("%ds", site.Interval)))
}
if site.MaxRetries > 0 {
left = append(left, row("Retries", m.fmtRetries(site)))
}
if site.Regions != "" {
left = append(left, row("Regions", site.Regions))
}
if site.Description != "" {
left = append(left, row("Description", limitStr(site.Description, colW-19)))
}
// Right column: status + timing + HTTP
var right []string
right = append(right, m.st.titleStyle.Render(" STATUS"))
right = append(right, row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))))
right = append(right, row("Latency", m.fmtLatency(site.Latency)))
right = append(right, row("Uptime", m.fmtUptime(hist.Statuses)))
if !site.StatusChangedAt.IsZero() {
dur := time.Since(site.StatusChangedAt)
right = append(right, row("State Since", fmtDuration(dur)+" ago"))
}
if !site.LastCheck.IsZero() {
right = append(right, row("Last Check", m.fmtTimeAgo(site.LastCheck)))
}
if !site.LastSuccessAt.IsZero() {
right = append(right, row("Last Success", m.fmtTimeAgo(site.LastSuccessAt)))
}
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
errW := colW - 19
if errW < 20 {
errW = 20
}
right = append(right, row("Error", m.st.dangerStyle.Render(limitStr(site.LastError, errW))))
}
if site.Type == "http" {
if site.StatusCode > 0 {
right = append(right, row("HTTP Code", strconv.Itoa(site.StatusCode)))
}
codes := site.AcceptedCodes
if codes == "" {
codes = "200-299"
}
right = append(right, row("Codes", codes))
right = append(right, row("SSL", m.fmtSSL(site)))
if site.Method != "" && site.Method != "GET" {
right = append(right, row("Method", site.Method))
}
}
// Pad shorter column
for len(left) < len(right) {
left = append(left, "")
}
for len(right) < len(left) {
right = append(right, "")
}
leftCol := lipgloss.NewStyle().Width(colW).Render(strings.Join(left, "\n"))
rightCol := lipgloss.NewStyle().Width(colW).Render(strings.Join(right, "\n"))
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) + "\n")
b.WriteString("\n" + sectionDiv + "\n")
// Connection chain (full width, only on errors)
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" {
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
if len(chain) > 0 {
b.WriteString("\n")
for _, step := range chain {
var icon string
switch step.Status {
case stepPassed:
icon = m.st.specialStyle.Render("✓")
case stepFailed:
icon = m.st.dangerStyle.Render("✗")
case stepSkipped:
icon = m.st.subtleStyle.Render("·")
}
line := fmt.Sprintf(" %s %-16s", icon, step.Name)
if step.Detail != "" {
switch step.Status {
case stepFailed:
line += " " + m.st.dangerStyle.Render(step.Detail)
case stepSkipped:
line += " " + m.st.subtleStyle.Render(step.Detail)
}
}
b.WriteString(line + "\n")
}
}
}
// Maintenance
if m.isMonitorInMaintenance(site.ID) {
for _, mw := range m.maintenanceWindows {
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Maintenance"), m.st.maintStyle.Render(mw.Title))
break
}
}
}
// Push token
if site.Type == "push" && site.Token != "" {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Token"), site.Token)
}
// Probe results
probeResults := m.engine.GetProbeResults(site.ID)
if len(probeResults) > 0 {
nodeIDs := make([]string, 0, len(probeResults))
for id := range probeResults {
nodeIDs = append(nodeIDs, id)
}
sort.Strings(nodeIDs)
b.WriteString("\n" + sectionHead("PROBE RESULTS") + "\n")
for _, nodeID := range nodeIDs {
result := probeResults[nodeID]
status := m.st.specialStyle.Render("UP")
if !result.IsUp {
status = m.st.dangerStyle.Render("DN")
}
latency := time.Duration(result.LatencyNs).Milliseconds()
ago := time.Since(result.CheckedAt).Truncate(time.Second)
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago)
if !result.IsUp && result.ErrorReason != "" {
line += " " + m.st.dangerStyle.Render(result.ErrorReason)
}
b.WriteString(line + "\n")
}
}
// Bottom two-column: graphs left, state changes right
graphW := (totalW - 4) * 70 / 100
changeW := totalW - 4 - graphW
if graphW < 30 {
graphW = 30
}
if changeW < 20 {
changeW = 20
}
bottomColW := graphW
sectionLabel := func(title string) string {
return m.st.titleStyle.Render(" " + title)
}
// Left: latency + histogram (skip for groups — they have no own checks)
var graphLines []string
if site.Type != "group" {
graphLines = append(graphLines, sectionLabel("LATENCY"))
if site.Type == "push" {
sparkW := bottomColW - 4
if sparkW > detailSparkWidth {
sparkW = detailSparkWidth
}
graphLines = append(graphLines, " "+m.heartbeatSparkline(hist.Statuses, sparkW, nil))
if len(hist.Statuses) > 0 {
up := 0
for _, s := range hist.Statuses {
if s {
up++
}
}
graphLines = append(graphLines, fmt.Sprintf(" %s %d/%d checks up",
m.st.subtleStyle.Render("Heartbeats"), up, len(hist.Statuses)))
}
} else {
sparkW := bottomColW - 4
if sparkW > detailSparkWidth {
sparkW = detailSparkWidth
}
graphLines = append(graphLines, " "+m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, nil))
var minL, maxL, total time.Duration
count := 0
for i, l := range hist.Latencies {
if i < len(hist.Statuses) && !hist.Statuses[i] {
continue
}
if count == 0 {
minL, maxL = l, l
} else if l < minL {
minL = l
} else if l > maxL {
maxL = l
}
total += l
count++
}
if count > 0 {
avg := total / time.Duration(count)
graphLines = append(graphLines, fmt.Sprintf(" %s %dms %s %dms %s %dms",
m.st.subtleStyle.Render("Min"), minL.Milliseconds(),
m.st.subtleStyle.Render("Avg"), avg.Milliseconds(),
m.st.subtleStyle.Render("Max"), maxL.Milliseconds()))
}
}
if site.Type != "push" && len(hist.Latencies) > 5 {
graphLines = append(graphLines, "")
graphLines = append(graphLines, sectionLabel("DISTRIBUTION"))
graphLines = append(graphLines, m.latencyHistogram(hist.Latencies, hist.Statuses, bottomColW))
}
}
// Right: state changes
var changeLines []string
var stateChanges []models.StateChange
if m.detailChangesSiteID == site.ID {
stateChanges = m.detailChanges
}
changeLines = append(changeLines, sectionLabel("STATE CHANGES"))
if len(stateChanges) > 0 {
for i, sc := range stateChanges {
from := m.fmtStatusWord(string(sc.FromStatus))
to := m.fmtStatusWord(string(sc.ToStatus))
ago := fmtDuration(time.Since(sc.ChangedAt))
line := fmt.Sprintf(" %s → %s %s ago", from, to, ago)
if sc.ToStatus == "UP" {
dur := computeOutageDuration(stateChanges, i)
if dur > 0 {
line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur))
}
}
if sc.ErrorReason != "" {
line += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, changeW-30))
}
changeLines = append(changeLines, line)
}
} else {
changeLines = append(changeLines, m.st.subtleStyle.Render(" No state changes"))
}
// Pad and join
for len(graphLines) < len(changeLines) {
graphLines = append(graphLines, "")
}
for len(changeLines) < len(graphLines) {
changeLines = append(changeLines, "")
}
if len(graphLines) > 0 {
graphCol := lipgloss.NewStyle().Width(graphW).Render(strings.Join(graphLines, "\n"))
changeCol := lipgloss.NewStyle().Width(changeW).Render(strings.Join(changeLines, "\n"))
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, graphCol, changeCol) + "\n")
} else {
b.WriteString(strings.Join(changeLines, "\n") + "\n")
}
b.WriteString("\n")
b.WriteString(m.divider() + "\n")
b.WriteString(m.st.subtleStyle.Render(" [e] Edit [h] History [s] SLA [click] Inspect [q/Esc] Back"))
// Wrap in a viewport for scrolling
content := b.String()
contentH := m.termHeight - 4
if contentH < 10 {
contentH = 10
}
lines := strings.Split(content, "\n")
if len(lines) > contentH {
m.detailViewport.SetContent(content)
m.detailViewport.Width = totalW
m.detailViewport.Height = contentH
return lipgloss.NewStyle().Padding(1, 2).Render(m.detailViewport.View())
}
return lipgloss.NewStyle().Padding(1, 2).Render(content)
}
+412 -175
View File
@@ -2,6 +2,7 @@ package tui
import (
"fmt"
"sort"
"strings"
"time"
@@ -10,127 +11,250 @@ import (
"github.com/charmbracelet/lipgloss"
)
const detailTwoColMinWidth = 80
func (m Model) viewDetailInline(width int) string {
func (m Model) viewDetailInline(width, height int) string {
if m.cursor >= len(m.sites) {
return ""
}
switch m.detailMode {
case detailSLA:
return m.viewSLASidebar(width, height)
case detailHistory:
return m.viewHistorySidebar(width, height)
default:
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
return m.buildDetailContent(site, hist, width, false)
}
}
func (m Model) viewDetailFullscreen() string {
if m.cursor >= len(m.sites) {
return ""
}
availW := m.termWidth - chromePadH
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
if width < detailTwoColMinWidth {
return m.viewDetailSingleCol(site, hist, width)
}
return m.viewDetailTwoCol(site, hist, width)
}
func (m Model) viewDetailTwoCol(site models.Site, hist monitor.SiteHistory, width int) string {
leftW := width * 55 / 100
rightW := width - leftW - 3 // 3 for " │ " divider
left := m.detailLeftCol(site, hist, leftW)
right := m.detailRightCol(site, hist, rightW)
leftLines := strings.Split(left, "\n")
rightLines := strings.Split(right, "\n")
lineCount := len(leftLines)
if len(rightLines) > lineCount {
lineCount = len(rightLines)
}
for len(leftLines) < lineCount {
leftLines = append(leftLines, "")
}
for len(rightLines) < lineCount {
rightLines = append(rightLines, "")
var title string
switch m.detailMode {
case detailSLA:
title = "SLA · " + site.Name
case detailHistory:
title = "History · " + site.Name
default:
title = site.Name
}
divChar := m.st.subtleStyle.Render("│")
leftStyle := lipgloss.NewStyle().Width(leftW).MaxWidth(leftW)
rightStyle := lipgloss.NewStyle().Width(rightW).MaxWidth(rightW)
var b strings.Builder
for i := range lineCount {
l := leftStyle.Render(leftLines[i])
r := rightStyle.Render(rightLines[i])
b.WriteString(l + " " + divChar + " " + r + "\n")
}
b.WriteString(" " + m.detailKeys() + "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
func (m Model) detailLeftCol(site models.Site, hist monitor.SiteHistory, width int) string {
var b strings.Builder
if len(hist.Latencies) > 0 {
chartW := width - 2
if chartW < 20 {
chartW = 20
if site.ParentID > 0 {
for _, s := range m.sites {
if s.ID == site.ParentID {
title = s.Name + " > " + title
break
}
}
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3)
}
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(" · ")
label := m.st.subtleStyle
innerW := width - 4
if innerW < 20 {
innerW = 20
}
var b strings.Builder
// Status + latency + last check + state since
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
statusParts := []string{status}
if site.Latency > 0 {
statusParts = append(statusParts, m.fmtLatency(site.Latency))
}
if !site.LastCheck.IsZero() {
statusParts = append(statusParts, m.fmtTimeAgo(site.LastCheck))
}
if !site.StatusChangedAt.IsZero() {
dur := time.Since(site.StatusChangedAt)
statusParts = append(statusParts, label.Render("for")+" "+fmtDuration(dur))
}
b.WriteString(" " + strings.Join(statusParts, dot) + "\n")
// Type-specific details
typeParts := m.detailTypeLine(site)
if len(typeParts) > 0 {
b.WriteString(" " + strings.Join(typeParts, dot) + "\n")
}
// Extended endpoint fields
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 {
uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site))
}
if site.Type != "push" && !site.LastSuccessAt.IsZero() {
uptimeParts = append(uptimeParts, label.Render("Last OK")+" "+m.fmtTimeAgo(site.LastSuccessAt))
}
b.WriteString(" " + strings.Join(uptimeParts, dot) + "\n")
// Maintenance window name
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
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
errW := innerW
if errW < 20 {
errW = 20
}
b.WriteString(" " + label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
}
// Connection chain
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" {
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
if len(chain) > 0 {
b.WriteString("\n")
for _, step := range chain {
var icon string
switch step.Status {
case stepPassed:
icon = m.st.specialStyle.Render("✓")
case stepFailed:
icon = m.st.dangerStyle.Render("✗")
case stepSkipped:
icon = m.st.subtleStyle.Render("·")
}
line := fmt.Sprintf(" %s %-16s", icon, step.Name)
if step.Detail != "" {
switch step.Status {
case stepFailed:
line += " " + m.st.dangerStyle.Render(step.Detail)
case stepSkipped:
line += " " + m.st.subtleStyle.Render(step.Detail)
}
}
b.WriteString(line + "\n")
}
}
}
b.WriteString("\n")
// Probe results
probeResults := m.engine.GetProbeResults(site.ID)
if len(probeResults) > 0 {
nodeIDs := make([]string, 0, len(probeResults))
for id := range probeResults {
nodeIDs = append(nodeIDs, id)
}
sort.Strings(nodeIDs)
for _, nodeID := range nodeIDs {
result := probeResults[nodeID]
probeStatus := m.st.specialStyle.Render("UP")
if !result.IsUp {
probeStatus = m.st.dangerStyle.Render("DN")
}
latency := time.Duration(result.LatencyNs).Milliseconds()
ago := time.Since(result.CheckedAt).Truncate(time.Second)
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, probeStatus, latency, ago)
if !result.IsUp && result.ErrorReason != "" {
line += " " + m.st.dangerStyle.Render(result.ErrorReason)
}
b.WriteString(line + "\n")
}
b.WriteString("\n")
}
// Latency chart
if len(hist.Latencies) > 0 {
chart := m.latencyChart(hist.Latencies, hist.Statuses, innerW, 3)
if chart != "" {
b.WriteString(chart + "\n")
}
}
// 30d uptime timeline
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
timelineW := width - 2
if timelineW < 20 {
timelineW = 20
b.WriteString(" " + label.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, innerW) + "\n")
}
// Sparkline + min/avg/max
if site.Type != "push" && len(hist.Latencies) > 0 {
b.WriteString(" " + m.latencySparkline(hist.Latencies, hist.Statuses, innerW, nil) + "\n")
var minL, maxL, total time.Duration
count := 0
for i, l := range hist.Latencies {
if i < len(hist.Statuses) && !hist.Statuses[i] {
continue
}
if count == 0 {
minL, maxL = l, l
} else if l < minL {
minL = l
} else if l > maxL {
maxL = l
}
total += l
count++
}
b.WriteString(" " + m.st.subtleStyle.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, timelineW) + "\n")
}
return strings.TrimRight(b.String(), "\n")
}
func (m Model) detailRightCol(site models.Site, hist monitor.SiteHistory, width int) string {
dot := m.st.subtleStyle.Render(" · ")
label := m.st.subtleStyle
var b strings.Builder
// Line 1: status + latency + last check
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
parts := []string{status}
if site.Latency > 0 {
parts = append(parts, m.fmtLatency(site.Latency))
}
if !site.LastCheck.IsZero() {
parts = append(parts, m.fmtTimeAgo(site.LastCheck))
}
b.WriteString(strings.Join(parts, dot) + "\n")
// Line 2: type-specific details
typeParts := m.detailTypeLine(site)
if len(typeParts) > 0 {
b.WriteString(strings.Join(typeParts, dot) + "\n")
}
// Line 3: uptime + retries
uptimeParts := []string{label.Render("Uptime") + " " + m.fmtUptime(hist.Statuses)}
if site.Type != "group" && site.MaxRetries > 0 {
uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site))
}
b.WriteString(strings.Join(uptimeParts, dot) + "\n")
// Error line (if down/broken)
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
errW := width - 8
if errW < 20 {
errW = 20
if count > 0 {
avg := total / time.Duration(count)
fmt.Fprintf(&b, " %s %dms %s %dms %s %dms\n",
label.Render("Min"), minL.Milliseconds(),
label.Render("Avg"), avg.Milliseconds(),
label.Render("Max"), maxL.Milliseconds())
}
}
// 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(label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
}
// Blank line before state changes
b.WriteString("\n")
// State changes (one per line, compact)
// State changes
var stateChanges []models.StateChange
if m.detailChangesSiteID == site.ID {
stateChanges = m.detailChanges
@@ -145,19 +269,62 @@ func (m Model) detailRightCol(site models.Site, hist monitor.SiteHistory, width
arrow := m.st.subtleStyle.Render("→")
from := m.fmtStatusWord(sc.FromStatus)
to := m.fmtStatusWord(sc.ToStatus)
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
entry := from + " " + arrow + " " + to + " " + label.Render(ago+" ago")
if sc.ErrorReason != "" {
reasonW := width - 30
reasonW := innerW - 25
if reasonW < 15 {
reasonW = 15
}
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
}
b.WriteString(entry + "\n")
b.WriteString(" " + entry + "\n")
}
} else {
b.WriteString(" " + label.Render("No state changes") + "\n")
}
return strings.TrimRight(b.String(), "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
func (m Model) writeEndpointFields(b *strings.Builder, site models.Site, label lipgloss.Style, innerW int, fullscreen bool) {
dot := m.st.subtleStyle.Render(" · ")
var fields []string
if site.Interval > 0 {
fields = append(fields, label.Render("Every")+" "+fmt.Sprintf("%ds", site.Interval))
}
if site.Timeout > 0 {
fields = append(fields, label.Render("Timeout")+" "+fmt.Sprintf("%ds", site.Timeout))
}
if site.Type == "http" && site.Method != "" && site.Method != "GET" {
fields = append(fields, label.Render("Method")+" "+site.Method)
}
if site.Type == "http" {
codes := site.AcceptedCodes
if codes == "" {
codes = "200-299"
}
fields = append(fields, label.Render("Codes")+" "+codes)
}
if site.Regions != "" {
fields = append(fields, label.Render("Regions")+" "+site.Regions)
}
if len(fields) > 0 {
b.WriteString(" " + strings.Join(fields, dot) + "\n")
}
if site.Description != "" {
maxDescW := innerW
if !fullscreen && maxDescW > 60 {
maxDescW = 60
}
b.WriteString(" " + label.Render(limitStr(site.Description, maxDescW)) + "\n")
}
if site.Type == "push" && site.Token != "" {
b.WriteString(" " + label.Render("Token") + " " + site.Token + "\n")
}
}
func (m Model) detailTypeLine(site models.Site) []string {
@@ -224,81 +391,28 @@ func (m Model) detailTypeLine(site models.Site) []string {
return parts
}
// viewDetailSingleCol is the narrow-terminal fallback (original stacked layout).
func (m Model) viewDetailSingleCol(site models.Site, hist monitor.SiteHistory, width int) string {
var b strings.Builder
dot := m.st.subtleStyle.Render(" · ")
func (m Model) detailFooter(width int) string {
dot := m.st.subtleStyle.Render(" · ")
var parts []string
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
parts := []string{status}
if site.Latency > 0 {
parts = append(parts, m.fmtLatency(site.Latency))
}
parts = append(parts, fmt.Sprintf("Uptime %s", m.fmtUptime(hist.Statuses)))
if !site.LastCheck.IsZero() {
parts = append(parts, m.fmtTimeAgo(site.LastCheck))
}
b.WriteString(" " + strings.Join(parts, dot) + "\n")
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
errW := width - 12
if errW < 20 {
errW = 20
}
b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
}
var stateChanges []models.StateChange
if m.detailChangesSiteID == site.ID {
stateChanges = m.detailChanges
}
if len(stateChanges) > 0 {
limit := 3
if len(stateChanges) < limit {
limit = len(stateChanges)
}
var scParts []string
for _, sc := range stateChanges[:limit] {
ago := fmtDuration(time.Since(sc.ChangedAt))
arrow := m.st.subtleStyle.Render("→")
from := m.fmtStatusWord(sc.FromStatus)
to := m.fmtStatusWord(sc.ToStatus)
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
if sc.ErrorReason != "" {
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30))
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))
}
scParts = append(scParts, entry)
}
b.WriteString(" " + strings.Join(scParts, dot) + "\n")
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"))
}
if len(hist.Latencies) > 0 {
chartW := width - 4
if chartW < 20 {
chartW = 20
}
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3)
if chart != "" {
b.WriteString(chart + "\n")
}
}
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
timelineW := width - 4
if timelineW < 20 {
timelineW = 20
}
b.WriteString(" " + m.st.subtleStyle.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, timelineW) + "\n")
}
b.WriteString(" " + m.detailKeys() + "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
func (m Model) detailKeys() string {
return m.st.subtleStyle.Render("[e] Edit [h] History [s] SLA [q/Esc] Back")
content := " " + strings.Join(parts, dot)
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(content)
}
func (m Model) fmtStatusWord(status string) string {
@@ -319,3 +433,126 @@ func (m Model) fmtStatusWord(status string) string {
return m.st.subtleStyle.Render(status)
}
}
func (m Model) viewSLASidebar(width, _ int) string {
var b strings.Builder
label := m.st.subtleStyle
innerW := width - 4
if innerW < 20 {
innerW = 20
}
period := slaPeriods[m.slaPeriodIdx]
b.WriteString(" " + label.Render("Period: Last "+period.label) + "\n\n")
r := m.slaReport
barWidth := innerW - 25
if barWidth < 10 {
barWidth = 10
}
bar := m.uptimeBar(r.UptimePct, barWidth)
uptimeColor := m.st.specialStyle
if r.UptimePct < uptimeExcellentPct {
uptimeColor = m.st.warnStyle
}
if r.UptimePct < uptimeGoodPct {
uptimeColor = m.st.dangerStyle
}
fmt.Fprintf(&b, " %-14s %s %s\n", label.Render("Uptime"), uptimeColor.Render(fmtPct(r.UptimePct)+"%"), bar)
fmt.Fprintf(&b, " %-14s %s\n", label.Render("Downtime"), fmtDuration(r.Downtime))
fmt.Fprintf(&b, " %-14s %d\n", label.Render("Outages"), r.OutageCount)
if r.OutageCount > 0 {
fmt.Fprintf(&b, " %-14s %s\n", label.Render("Longest"), fmtDuration(r.LongestOut))
fmt.Fprintf(&b, " %-14s %s\n", label.Render("MTTR"), fmtDuration(r.MTTR))
fmt.Fprintf(&b, " %-14s %s\n", label.Render("MTBF"), fmtDuration(r.MTBF))
}
b.WriteString("\n")
if len(m.slaDailyBreakdown) > 0 {
b.WriteString(" " + m.st.titleStyle.Render("DAILY BREAKDOWN") + "\n")
dayBarW := innerW - 20
if dayBarW < 10 {
dayBarW = 10
}
for _, day := range m.slaDailyBreakdown {
dateStr := day.Date.Format("Jan 02")
dayBar := m.uptimeBar(day.UptimePct, dayBarW)
pctStr := fmtPct(day.UptimePct) + "%"
color := m.st.specialStyle
if day.UptimePct < uptimeExcellentPct {
color = m.st.warnStyle
}
if day.UptimePct < uptimeGoodPct {
color = m.st.dangerStyle
}
fmt.Fprintf(&b, " %-8s %s %s\n", label.Render(dateStr), dayBar, color.Render(pctStr))
}
}
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
func (m Model) viewHistorySidebar(width, _ int) string {
var b strings.Builder
label := m.st.subtleStyle
innerW := width - 4
if innerW < 20 {
innerW = 20
}
sparkline := m.stateChangeSparkline(m.historyChanges, innerW)
if sparkline != "" {
b.WriteString(" " + sparkline + "\n\n")
}
if len(m.historyChanges) == 0 {
b.WriteString(" " + label.Render("No state changes recorded") + "\n")
} else {
reasonW := innerW - 45
if reasonW < 10 {
reasonW = 10
}
for i, sc := range m.historyChanges {
ts := sc.ChangedAt.Format("01/02 15:04")
arrow := label.Render(sc.FromStatus) + " → "
switch sc.ToStatus {
case string(models.StatusUp):
arrow += m.st.specialStyle.Render(sc.ToStatus)
case string(models.StatusLate):
arrow += m.st.warnStyle.Render(sc.ToStatus)
case string(models.StatusStale):
arrow += m.st.staleStyle.Render(sc.ToStatus)
default:
arrow += m.st.dangerStyle.Render(sc.ToStatus)
}
durStr := ""
if dur := computeOutageDuration(m.historyChanges, i); dur > 0 {
durStr = m.st.warnStyle.Render(fmtDuration(dur))
}
reason := ""
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
}
fmt.Fprintf(&b, " %-12s %s %s %s\n", ts, arrow, durStr, reason)
}
}
b.WriteString("\n")
stats := computeHistoryStats(m.historyChanges)
statParts := []string{fmt.Sprintf("%d events", stats.totalEvents)}
if stats.outageCount > 0 {
statParts = append(statParts, fmt.Sprintf("%d outages", stats.outageCount))
avg := stats.totalDowntime / time.Duration(stats.outageCount)
statParts = append(statParts, "avg "+fmtDuration(avg))
}
b.WriteString(" " + label.Render(strings.Join(statParts, " │ ")) + "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
-86
View File
@@ -1,12 +1,10 @@
package tui
import (
"fmt"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
)
type historyStats struct {
@@ -105,87 +103,3 @@ func (m Model) stateChangeSparkline(changes []models.StateChange, width int) str
}
return sb.String()
}
func (m Model) buildHistoryContent() string {
var b strings.Builder
reasonWidth := m.termWidth - chromePadH - 55
if reasonWidth < 10 {
reasonWidth = 10
}
if reasonWidth > 60 {
reasonWidth = 60
}
for i, sc := range m.historyChanges {
ts := sc.ChangedAt.Format("2006-01-02 15:04")
arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → "
switch sc.ToStatus {
case string(models.StatusUp):
arrow += m.st.specialStyle.Render(sc.ToStatus)
case string(models.StatusLate):
arrow += m.st.warnStyle.Render(sc.ToStatus)
case string(models.StatusStale):
arrow += m.st.staleStyle.Render(sc.ToStatus)
default:
arrow += m.st.dangerStyle.Render(sc.ToStatus)
}
durStr := ""
if dur := computeOutageDuration(m.historyChanges, i); dur > 0 {
durStr = m.st.warnStyle.Render("outage " + fmtDuration(dur))
}
reason := ""
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth))
}
fmt.Fprintf(&b, " %-18s %s %-12s %s\n", ts, arrow, durStr, reason)
}
return b.String()
}
func (m Model) viewHistoryPanel() string {
var b strings.Builder
header := " " + m.st.subtleStyle.Render("History >") + " " + m.st.titleStyle.Render(m.historySiteName)
b.WriteString(header + "\n")
divWidth := m.dividerWidth()
b.WriteString(m.divider() + "\n")
sparkline := m.stateChangeSparkline(m.historyChanges, divWidth)
if sparkline != "" {
b.WriteString(" " + sparkline + "\n")
b.WriteString(m.divider() + "\n")
}
fmt.Fprintf(&b, " %-18s %-17s %-12s %s\n",
m.st.subtleStyle.Render("TIME"),
m.st.subtleStyle.Render("TRANSITION"),
m.st.subtleStyle.Render("DURATION"),
m.st.subtleStyle.Render("REASON"))
if len(m.historyChanges) == 0 {
b.WriteString("\n " + m.st.subtleStyle.Render("No state changes recorded") + "\n")
} else {
b.WriteString(m.historyViewport.View())
}
b.WriteString("\n" + m.divider() + "\n")
stats := computeHistoryStats(m.historyChanges)
parts := []string{fmt.Sprintf("%d events", stats.totalEvents)}
if stats.outageCount > 0 {
parts = append(parts, fmt.Sprintf("%d outages", stats.outageCount))
avg := stats.totalDowntime / time.Duration(stats.outageCount)
parts = append(parts, "avg outage "+fmtDuration(avg))
}
b.WriteString(" " + m.st.subtleStyle.Render(strings.Join(parts, " │ ")) + "\n")
b.WriteString(" " + m.st.subtleStyle.Render("[↑/↓] Scroll [q/Esc] Back"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
+58 -52
View File
@@ -7,6 +7,7 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
func (m Model) viewMaintDetailPanel() string {
@@ -85,6 +86,15 @@ func (m Model) viewMaintDetailPanel() 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 {
now := time.Now()
var out []models.MaintenanceWindow
@@ -97,34 +107,26 @@ func (m Model) activeMaintWindows() []models.MaintenanceWindow {
return out
}
func (m Model) viewMaintSidebar(width, maxLines int) string {
func (m Model) viewMaintStrip(width, maxLines int) string {
windows := m.activeMaintWindows()
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()
for i := start; i < end; i++ {
mw := windows[i]
selected := m.focusedPanel == panelMaint && i == m.maintCursor
end := maxLines
if end > len(windows) {
end = len(windows)
}
selectedVisual := -1
if m.focusedPanel == panelMaint {
selectedVisual = m.maintCursor
}
var rows [][]string
for i := 0; i < end; i++ {
mw := windows[i]
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
var icon string
@@ -134,37 +136,51 @@ func (m Model) viewMaintSidebar(width, maxLines int) string {
icon = m.st.warnStyle.Render("○")
}
title := limitStr(mw.Title, contentW-3)
titleLine := " " + icon + " " + title
monName := "All Monitors"
if mw.MonitorID > 0 {
monName = m.monitorNameByID(mw.MonitorID)
}
var detail string
var status string
if isActive {
if mw.EndTime.IsZero() {
detail = m.st.subtleStyle.Render("active · indefinite")
status = "indefinite"
} else {
remaining := time.Until(mw.EndTime)
detail = m.st.subtleStyle.Render("active · " + fmtDuration(remaining))
status = fmtDuration(time.Until(mw.EndTime)) + " left"
}
} else {
detail = m.st.subtleStyle.Render(mw.StartTime.Format("Jan 02") + " · " + fmtDuration(mw.EndTime.Sub(mw.StartTime)))
}
detailLine := " " + limitStr(detail, contentW-3)
if selected {
sel := m.st.tableSelectedStyle
titleLine = sel.Render(lipgloss.NewStyle().Width(contentW).Render(titleLine))
detailLine = sel.Render(lipgloss.NewStyle().Width(contentW).Render(detailLine))
status = "starts " + mw.StartTime.Format("Jan 02 15:04")
}
lines = append(lines, titleLine, detailLine)
rows = append(rows, []string{icon, mw.Title, monName, status})
}
if len(windows) > end-start {
more := fmt.Sprintf(" %d more", len(windows)-(end-start))
lines = append(lines, m.st.subtleStyle.Render(more))
}
colWidths := []int{3, 0, 0, 0}
remaining := width - colWidths[0] - 6
colWidths[1] = remaining * 40 / 100
colWidths[2] = remaining * 30 / 100
colWidths[3] = remaining - colWidths[1] - colWidths[2]
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) {
@@ -180,14 +196,4 @@ func (m *Model) scrollMaintCursor(delta int) {
if m.maintCursor >= total {
m.maintCursor = total - 1
}
if m.maintCursor < m.maintOffset {
m.maintOffset = m.maintCursor
}
visible := m.maxTableRows / 2
if visible < 1 {
visible = 1
}
if m.maintCursor >= m.maintOffset+visible {
m.maintOffset = m.maintCursor - visible + 1
}
}
-87
View File
@@ -5,8 +5,6 @@ import (
"math"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
var slaPeriods = []struct {
@@ -21,91 +19,6 @@ var slaPeriods = []struct {
{"90d", "4", 90 * 24 * time.Hour, 90},
}
func (m Model) viewSLAPanel() string {
var b strings.Builder
header := " " + m.st.subtleStyle.Render("SLA >") + " " + m.st.titleStyle.Render(m.slaSiteName)
b.WriteString(header + "\n")
b.WriteString(m.divider() + "\n")
period := slaPeriods[m.slaPeriodIdx]
b.WriteString(" " + m.st.subtleStyle.Render("Period: Last "+period.label) + "\n\n")
r := m.slaReport
barWidth := m.dividerWidth() - 30
if barWidth < 10 {
barWidth = 10
}
bar := m.uptimeBar(r.UptimePct, barWidth)
uptimeColor := m.st.specialStyle
if r.UptimePct < uptimeExcellentPct {
uptimeColor = m.st.warnStyle
}
if r.UptimePct < uptimeGoodPct {
uptimeColor = m.st.dangerStyle
}
fmt.Fprintf(&b, " %-16s %s %s\n", m.st.subtleStyle.Render("Uptime"), uptimeColor.Render(fmt.Sprintf("%s%%", fmtPct(r.UptimePct))), bar)
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Downtime"), fmtDuration(r.Downtime))
fmt.Fprintf(&b, " %-16s %d\n", m.st.subtleStyle.Render("Outages"), r.OutageCount)
if r.OutageCount > 0 {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Longest"), fmtDuration(r.LongestOut))
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTTR"), fmtDuration(r.MTTR))
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTBF"), fmtDuration(r.MTBF))
}
b.WriteString("\n" + m.divider() + "\n")
if len(m.slaDailyBreakdown) > 0 {
b.WriteString(m.slaViewport.View())
}
b.WriteString("\n" + m.divider() + "\n")
var keys []string
for i, p := range slaPeriods {
label := fmt.Sprintf("[%s] %s", p.key, p.label)
if i == m.slaPeriodIdx {
keys = append(keys, m.st.titleStyle.Render(label))
} else {
keys = append(keys, m.st.subtleStyle.Render(label))
}
}
b.WriteString(" " + strings.Join(keys, " "))
b.WriteString(" " + m.st.subtleStyle.Render("[↑/↓] Scroll [q/Esc] Back"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
func (m Model) buildSLADailyContent() string {
var b strings.Builder
barWidth := m.dividerWidth() - 30
if barWidth < 10 {
barWidth = 10
}
b.WriteString(" " + m.st.titleStyle.Render("DAILY BREAKDOWN") + "\n")
for _, day := range m.slaDailyBreakdown {
dateStr := day.Date.Format("Jan 02")
bar := m.uptimeBar(day.UptimePct, barWidth)
pctStr := fmtPct(day.UptimePct) + "%"
color := m.st.specialStyle
if day.UptimePct < uptimeExcellentPct {
color = m.st.warnStyle
}
if day.UptimePct < uptimeGoodPct {
color = m.st.dangerStyle
}
fmt.Fprintf(&b, " %-8s %s %s\n", m.st.subtleStyle.Render(dateStr), bar, color.Render(pctStr))
}
return b.String()
}
func (m Model) uptimeBar(pct float64, width int) string {
filled := int(math.Round(pct / 100 * float64(width)))
if filled > width {