Files
go-otel/cmd/issues-tui/view.go
T
developer a96e95212a
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 2m16s
issues-tui: wide, short terminal dashboard for current issues
Standalone cmd that runs the same pipeline as the webhook notifications
(alerts.LoadErrorEvents -> alerts.DetectAlerts -> cooldown suppressor) on
a schedule and renders what is firing as a row of severity-colored cards -
built for an ultra-wide, low display. A pattern shows PAGED when the scan
would have notified and ongoing while suppressed; disappearing spikes are
reported as recovered. -once prints one snapshot for cron/pipe use.
Colors follow the otel-ui design-system tokens (dark and light).
2026-09-24 09:00:51 +00:00

395 lines
13 KiB
Go
Raw 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
}
// 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 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, 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(snap snapshot, o snapshotOptions, w, avail int) string {
perRow, _, shown, cardW := gridLayout(len(snap.Issues), w, avail)
var rendered []string
for start := 0; start < shown; start += perRow {
end := min(start+perRow, shown)
var row []string
for _, v := range snap.Issues[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())
inner := w - 4 // border plus padding on both sides
// Line 1: HIGH · checkout · for 12m
age := ""
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) and the issue link.
state := style(colText3, false).Render("ongoing")
if v.paged {
state = style(fg, true).Render("PAGED")
}
var meta []string
if !a.Spike.WindowEnd.IsZero() {
meta = append(meta, style(colText3, false).Render(fmtTime(a.Spike.WindowEnd)))
}
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 !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))
if paged > 0 {
left = append(left, style(colCritical, true).Render(fmt.Sprintf("%d PAGED · %s", paged, counts)))
} else {
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 := len(snap.Issues) - 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, " "))
}
func shownCount(snap snapshot, o snapshotOptions) int {
if o.Height < minUsableHeight {
return len(snap.Issues)
}
_, _, shown, _ := gridLayout(len(snap.Issues), 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 {
return strings.ToUpper(o.Title) + ": all clear"
}
lines := make([]string, 0, len(snap.Issues)+1)
lines = append(lines, strings.ToUpper(o.Title)+":")
for _, v := range snap.Issues {
state := "ongoing"
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
}