402 lines
13 KiB
Go
402 lines
13 KiB
Go
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:
|
||
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 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))
|
||
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
|
||
}
|