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 }