Files
go-otel/cmd/issues-tui/view.go
developer 6dc2785f71
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 2m36s
fix
2026-09-24 13:26:24 +00:00

438 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"git.tornberg.me/go-otel/internal/alerts"
)
// Layout budget. The display this dashboard targets is much wider than it is
// tall, so cards flow left-to-right in one row first; a second row only
// appears when the terminal is tall enough.
const (
cardContentLines = 4 // priority/what/stats/meta inside the border
cardHeight = cardContentLines + 2 // border rows included
cardGap = 1 // columns between cards
cardMinWidth = 34
cardMaxWidth = 64
minUsableWidth = 40
minUsableHeight = 3
footerHint = "q quit · r scan now"
)
// snapshotOptions carries everything the renderer needs beyond the scan.
type snapshotOptions struct {
Width, Height int
Title string
BaseURL string
Scanning bool
NextScanAt time.Time
Now time.Time
ScanError error
}
// Colors mirror the otel-ui design-system tokens (AGENTS.md) so the terminal
// dashboard and the web UI read as one system: neutral chrome, color only as
// a severity signal.
var (
colChrome = lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#0B0E12"}
colSurface = lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#14181E"}
colLine = lipgloss.AdaptiveColor{Light: "#E3E0D8", Dark: "#242B34"}
colText = lipgloss.AdaptiveColor{Light: "#161A1F", Dark: "#E8ECF2"}
colText2 = lipgloss.AdaptiveColor{Light: "#4F5964", Dark: "#98A3B2"}
colText3 = lipgloss.AdaptiveColor{Light: "#6B7480", Dark: "#8B96A6"}
colCritical = lipgloss.AdaptiveColor{Light: "#C4402C", Dark: "#FF6F5E"}
colWarn = lipgloss.AdaptiveColor{Light: "#8A5D0E", Dark: "#F0B24A"}
colOK = lipgloss.AdaptiveColor{Light: "#1F7A56", Dark: "#4FCF9A"}
colAccent = lipgloss.AdaptiveColor{Light: "#3B4FC4", Dark: "#7B8CFF"}
)
func priorityColor(p alerts.Priority) lipgloss.TerminalColor {
switch p {
case alerts.PriorityHigh:
return colCritical
case alerts.PriorityMedium:
return colWarn
}
return colText3
}
// renderSnapshot draws the whole dashboard: one header line, the card grid,
// one footer line. It is shared by the live TUI and -once mode.
func renderSnapshot(snap snapshot, o snapshotOptions) string {
w, h := o.Width, o.Height
if w <= 0 {
w = 80
}
if h <= 0 {
h = 8
}
if w < minUsableWidth || h < minUsableHeight {
return renderMinimal(snap, o)
}
header := renderHeader(snap, o)
footer := renderFooter(snap, o)
body := renderBody(snap, o, w, h-2)
return strings.Join([]string{header, body, footer}, "\n")
}
func renderBody(snap snapshot, o snapshotOptions, w, avail int) string {
switch {
case snap.Err != nil:
return statusPanel(w, min(avail, 5), colCritical, "SCAN FAILED", snap.Err.Error(),
"values are from "+fmtTime(snap.ScannedAt)+"; the display keeps the last good scan")
case o.ScanError != nil && len(snap.Issues) == 0:
return statusPanel(w, min(avail, 5), colCritical, "SCAN FAILED", o.ScanError.Error(),
"no successful scan yet; retrying automatically")
case len(snap.Issues) == 0 && len(snap.Resolved) > 0:
// Nothing fires any more: keep the last issue on the display,
// flipped to resolved, instead of an ALL CLEAR panel.
return renderGrid(snap.Resolved, o, w, avail)
case len(snap.Issues) == 0:
sub := fmt.Sprintf("no issues firing · window %s–%s UTC · scanned %s",
snap.WindowStart.Format("15:04"), snap.WindowEnd.Format("15:04"), commas(snap.Events))
if snap.ScannedAt.IsZero() {
sub = "waiting for the first scan…"
}
return statusPanel(w, min(avail, 5), colOK, "ALL CLEAR", sub, "")
default:
return renderGrid(snap.Issues, o, w, avail)
}
}
// renderGrid lays issues into rows of cards that fill the width; only as many
// rows as the height allows are drawn, the rest are counted in the footer.
func renderGrid(views []issueView, o snapshotOptions, w, avail int) string {
perRow, _, shown, cardW := gridLayout(len(views), w, avail)
var rendered []string
for start := 0; start < shown; start += perRow {
end := min(start+perRow, shown)
var row []string
for _, v := range views[start:end] {
row = append(row, issueCard(v, cardW, o))
}
rendered = append(rendered, lipgloss.JoinHorizontal(lipgloss.Top, row...))
}
grid := strings.Join(rendered, "\n")
if lipgloss.Width(grid) < w {
grid = lipgloss.PlaceHorizontal(w, lipgloss.Center, grid)
}
return grid
}
// gridLayout fits n cards into the width and at most `avail` height. It
// returns cards per row, rows drawn, how many cards that shows, and the card
// width; whatever does not fit is reported by the caller as hidden.
func gridLayout(n, width, avail int) (perRow, rows, shown, cardW int) {
if n <= 0 {
return 0, 0, 0, 0
}
maxRows := max(1, (avail+cardGap)/(cardHeight+cardGap))
perRow = max(1, (width+cardGap)/(cardMinWidth+cardGap))
perRow = min(perRow, n)
rows = min(maxRows, (n+perRow-1)/perRow)
shown = min(n, rows*perRow)
cardW = min(cardMaxWidth, (width-cardGap*(perRow-1))/perRow)
return perRow, rows, shown, cardW
}
// issueCard renders one issue as a bordered card of exactly cardHeight lines.
// Line 1 identifies it (priority, service, age), line 2 is what is failing,
// line 3 the numbers behind it, line 4 the display state and issue link.
func issueCard(v issueView, w int, o snapshotOptions) string {
a := v.alert
fg := priorityColor(a.Priority())
resolved := !v.resolvedAt.IsZero()
if resolved {
// A resolved card keeps its place but reads as recovered: the same
// green as the footer's recovered line and the ALL CLEAR panel.
fg = colOK
}
inner := w - 4 // border plus padding on both sides
// Line 1: HIGH · checkout · for 12m
age := ""
if !v.firstSeen.IsZero() && resolved {
age = " · for " + shortDur(v.resolvedAt.Sub(v.firstSeen).Round(time.Minute))
} else if !v.firstSeen.IsZero() && !o.Now.IsZero() {
age = " · for " + shortDur(o.Now.Sub(v.firstSeen).Round(time.Minute))
}
head := ansi.Truncate(
style(fg, true).Render(strings.ToUpper(string(a.Priority())))+
style(colText, true).Render(" · "+a.Spike.Service)+
style(colText3, false).Render(age), inner, "…")
// Line 2: what is failing - the top pattern's description, then any
// additional pattern count.
what := fmt.Sprintf("%d error(s), no pattern detail", a.Spike.ErrorCount)
if len(a.Patterns) > 0 {
what = a.Patterns[0].What
if extra := len(a.Patterns) - 1; extra > 0 {
what += fmt.Sprintf(" (+%d more pattern%s)", extra, plural(extra))
}
}
whatLine := style(colText, false).Render(ansi.Truncate(what, inner, "…"))
// Line 3: the detector's numbers: count vs baseline, excess, and the
// top pattern's tags.
segs := []string{style(colText2, false).Render(
fmt.Sprintf("%d err vs %.1f/w · excess +%.0f", a.Spike.ErrorCount, a.Spike.BaselineMean, a.Spike.Excess))}
var plain []string
if len(a.Patterns) > 0 {
top := a.Patterns[0]
if top.New {
segs = append(segs, style(colAccent, true).Render("NEW"))
}
if top.Clients > 0 {
plain = append(plain, fmt.Sprintf("%d client%s", top.Clients, plural(top.Clients)))
}
if len(top.Services) > 1 {
plain = append(plain, "with: "+strings.Join(top.Services, ","))
}
}
if len(plain) > 0 {
segs = append(segs, style(colText3, false).Render(strings.Join(plain, " · ")))
}
statsLine := ansi.Truncate(strings.Join(segs, " "), inner, "…")
// Line 4: display state (paged now vs already paged vs resolved) and the
// issue link.
state := style(colText3, false).Render("ongoing")
when := a.Spike.WindowEnd
switch {
case resolved:
state = style(colOK, true).Render("RESOLVED")
when = v.resolvedAt
case v.paged:
state = style(fg, true).Render("PAGED")
}
var meta []string
if !when.IsZero() {
meta = append(meta, style(colText3, false).Render(fmtTime(when)))
}
meta = append(meta, state)
if o.BaseURL != "" && len(a.Patterns) > 0 && a.Patterns[0].IssueID != "" {
meta = append(meta, style(colAccent, false).Render("⧉ "+shortID(a.Patterns[0].IssueID)))
}
metaLine := ansi.Truncate(strings.Join(meta, " "), inner, "…")
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(fg).
Background(colSurface).
Padding(0, 1).
Width(w - 2).
Height(cardContentLines).
Render(strings.Join([]string{head, whatLine, statsLine, metaLine}, "\n"))
}
// statusPanel is the single wide panel used for ALL CLEAR and scan failures.
func statusPanel(w, h int, border lipgloss.TerminalColor, title, line1, line2 string) string {
inner := max(w-6, 10)
lines := []string{
style(border, true).Render(ansi.Truncate(title, inner, "…")),
style(colText, false).Render(ansi.Truncate(line1, inner, "…")),
}
if line2 != "" {
lines = append(lines, style(colText3, false).Render(ansi.Truncate(line2, inner, "…")))
}
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center,
lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(border).
Background(colSurface).Padding(0, 2).Width(inner).
Render(strings.Join(lines, "\n")))
}
// renderHeader draws the one-line status bar: title, scan window, counts,
// clock and next-scan countdown, right-aligned.
func renderHeader(snap snapshot, o snapshotOptions) string {
w := o.Width - 2
title := style(colAccent, true).Render(strings.ToUpper(o.Title))
left := []string{title}
if o.ScanError != nil {
left = append(left, style(colCritical, true).Render("SCAN FAILED"))
}
if !snap.WindowEnd.IsZero() {
left = append(left, style(colText3, false).Render(
fmt.Sprintf("%s–%s UTC", snap.WindowStart.Format("15:04"), snap.WindowEnd.Format("15:04"))))
}
if !snap.ScannedAt.IsZero() {
left = append(left, style(colText3, false).Render(commas(snap.Events)+" events"))
var paged int
for _, v := range snap.Issues {
if v.paged {
paged++
}
}
counts := fmt.Sprintf("%d firing", len(snap.Issues))
switch {
case len(snap.Issues) == 0 && len(snap.Resolved) > 0:
left = append(left, style(colOK, true).Render(fmt.Sprintf("%d resolved", len(snap.Resolved))))
case paged > 0:
left = append(left, style(colCritical, true).Render(fmt.Sprintf("%d PAGED · %s", paged, counts)))
default:
left = append(left, style(colText2, false).Render(counts))
}
}
var right []string
if o.Scanning {
right = append(right, style(colAccent, false).Render("scanning…"))
} else if !o.NextScanAt.IsZero() && !o.Now.IsZero() {
d := max(o.NextScanAt.Sub(o.Now).Round(time.Second), 0)
right = append(right, style(colText3, false).Render("next "+shortDur(d)))
}
if !snap.ScannedAt.IsZero() {
right = append(right, style(colText3, false).Render("scanned "+fmtTime(snap.ScannedAt)))
}
if !o.Now.IsZero() {
right = append(right, style(colText, false).Render(o.Now.Format("15:04:05")))
}
return bar(w, strings.Join(left, " "), strings.Join(right, " "))
}
// renderFooter draws the one-line help/status bar: keys, hidden-card count
// and recovered issues.
func renderFooter(snap snapshot, o snapshotOptions) string {
var left []string
left = append(left, style(colText3, false).Render(footerHint))
if hidden := cardCount(snap) - shownCount(snap, o); hidden > 0 {
left = append(left, style(colWarn, false).Render(fmt.Sprintf("+%d more not shown (widen or raise the display)", hidden)))
}
var right []string
if len(snap.Recovered) > 0 {
names := make([]string, 0, len(snap.Recovered))
for _, r := range snap.Recovered {
names = append(names, r.Service)
}
right = append(right, style(colOK, false).Render("recovered: "+strings.Join(names, ", ")))
}
if o.BaseURL != "" {
right = append(right, style(colText3, false).Render(strings.TrimRight(o.BaseURL, "/")+"/issues"))
}
return bar(o.Width-2, strings.Join(left, " "), strings.Join(right, " "))
}
// cardCount is how many cards the body renders: the firing issues, or the
// kept resolved ones when nothing fires.
func cardCount(snap snapshot) int {
if n := len(snap.Issues); n > 0 {
return n
}
return len(snap.Resolved)
}
func shownCount(snap snapshot, o snapshotOptions) int {
if o.Height < minUsableHeight {
return cardCount(snap)
}
_, _, shown, _ := gridLayout(cardCount(snap), o.Width, o.Height-2)
return shown
}
// bar places left and right text on one full-width line, truncating when the
// terminal is too narrow for both.
func bar(w int, left, right string) string {
lw, rw := lipgloss.Width(left), lipgloss.Width(right)
if lw+rw+1 > w {
return ansi.Truncate(left+" "+right, w, "…")
}
return left + strings.Repeat(" ", max(w-lw-rw, 0)) + right
}
// renderMinimal is the fallback for tiny terminals and -once without a size:
// one line per issue, no boxes.
func renderMinimal(snap snapshot, o snapshotOptions) string {
if snap.Err != nil {
return "SCAN FAILED: " + snap.Err.Error()
}
if len(snap.Issues) == 0 && len(snap.Resolved) == 0 {
return strings.ToUpper(o.Title) + ": all clear"
}
views := snap.Issues
if len(views) == 0 {
views = snap.Resolved
}
lines := make([]string, 0, len(views)+1)
lines = append(lines, strings.ToUpper(o.Title)+":")
for _, v := range views {
state := "ongoing"
if !v.resolvedAt.IsZero() {
state = "RESOLVED"
} else if v.paged {
state = "PAGED"
}
what := ""
if len(v.alert.Patterns) > 0 {
what = v.alert.Patterns[0].What
}
lines = append(lines, fmt.Sprintf("%s %s %s: %s",
state, strings.ToUpper(string(v.alert.Priority())), v.alert.Spike.Service, what))
}
return strings.Join(lines, "\n")
}
// --- small helpers ---------------------------------------------------------
func style(c lipgloss.TerminalColor, bold bool) lipgloss.Style {
s := lipgloss.NewStyle().Foreground(c)
if bold {
s = s.Bold(true)
}
return s
}
func fmtTime(t time.Time) string {
if t.IsZero() {
return "never"
}
return t.Format("15:04:05")
}
func commas(n int) string {
s := fmt.Sprintf("%d", n)
if n < 1000 {
return s
}
var parts []string
for len(s) > 3 {
parts = append([]string{s[len(s)-3:]}, parts...)
s = s[:len(s)-3]
}
return s + "," + strings.Join(parts, ",")
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
// shortDur renders durations compactly: 90s -> "1m30s", 2h05m -> "2h5m".
func shortDur(d time.Duration) string {
s := d.Round(time.Second).String()
if strings.HasSuffix(s, "m0s") {
s = strings.TrimSuffix(s, "0s")
}
if strings.HasSuffix(s, "h0m") {
s = strings.TrimSuffix(s, "0m")
}
return s
}
// shortID abbreviates an issue fingerprint for the card's link chip.
func shortID(id string) string {
if n := utf8.RuneCountInString(id); n > 10 {
return id[:10]
}
return id
}