From 2779f9f5324cf098d86db9bf0b2958f84adcf3df Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Tue, 30 Jun 2026 09:03:30 -0400 Subject: [PATCH] feat(tui): tall-narrow detail sidebar for master-detail layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/tui/view_detail_inline.go | 250 +++++++++-------------------- 1 file changed, 79 insertions(+), 171 deletions(-) diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index d67ba6f..eb4ae7f 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -10,127 +10,104 @@ import ( "github.com/charmbracelet/lipgloss" ) -const detailTwoColMinWidth = 80 - func (m Model) viewDetailInline(width int) string { if m.cursor >= len(m.sites) { return "" } 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) + return m.viewDetailSidebar(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, "") - } - - divChar := m.st.subtleStyle.Render("│") - leftStyle := lipgloss.NewStyle().Width(leftW).MaxWidth(leftW) - rightStyle := lipgloss.NewStyle().Width(rightW).MaxWidth(rightW) - +func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width int) string { + dot := m.st.subtleStyle.Render(" · ") + label := m.st.subtleStyle var b strings.Builder - for i := range lineCount { - l := leftStyle.Render(leftLines[i]) - r := rightStyle.Render(rightLines[i]) - b.WriteString(l + " " + divChar + " " + r + "\n") + innerW := width - 4 + if innerW < 20 { + innerW = 20 } - b.WriteString(" " + m.detailKeys() + "\n") + // Status + latency + last check + 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)) + } + b.WriteString(" " + strings.Join(statusParts, dot) + "\n") - return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) -} + // Type-specific details + typeParts := m.detailTypeLine(site) + if len(typeParts) > 0 { + b.WriteString(" " + strings.Join(typeParts, dot) + "\n") + } -func (m Model) detailLeftCol(site models.Site, hist monitor.SiteHistory, width int) string { - var b strings.Builder + // 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") - if len(hist.Latencies) > 0 { - chartW := width - 2 - if chartW < 20 { - chartW = 20 + // 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 } - chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3) + b.WriteString(" " + label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\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()) } - 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 +122,23 @@ 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") + b.WriteString("\n " + m.detailKeys() + "\n") + + return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String()) } func (m Model) detailTypeLine(site models.Site) []string { @@ -224,79 +205,6 @@ 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(" · ") - - 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)) - } - scParts = append(scParts, entry) - } - b.WriteString(" " + strings.Join(scParts, dot) + "\n") - } - - if len(hist.Latencies) > 0 { - chartW := width - 4 - if chartW < 20 { - chartW = 20 - } - chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3) - if chart != "" { - b.WriteString(chart + "\n") - } - } - - if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID { - timelineW := width - 4 - if timelineW < 20 { - timelineW = 20 - } - b.WriteString(" " + m.st.subtleStyle.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, timelineW) + "\n") - } - - 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") }