issues-tui: wide, short terminal dashboard for current issues
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 2m16s

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).
This commit is contained in:
developer committed 2026-09-24 09:00:51 +00:00
1 parent 36f00d49b9
commit a96e95212a
6 files changed
+1048 -78

No files matched your search

+394
View File
@@ -0,0 +1,394 @@
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
}