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).
This commit is contained in:
1 parent
36f00d49b9
commit
a96e95212a
6 files changed
+1048
-78
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user