feat: Stage 2 — alert dashboard with live table and stats

Adds a bubbles/table alert list showing Name, Status, Started (relative
time), and Ack By columns. Stats bar shows total/firing/resolved counts.
Filter cycles firing → resolved → all with [f]. Auto-refresh fires on
the configured interval via tea.Tick. Table scrolls with j/k.
This commit is contained in:
Niklas Ye
2026-05-21 13:37:05 +02:00
parent 55ee64530b
commit e29cff21ae
5 changed files with 304 additions and 59 deletions
+34
View File
@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -58,6 +60,38 @@ func (c *Client) do(req *http.Request, out any) error {
return nil return nil
} }
// ListAlerts fetches alerts from the server. status may be "firing", "resolved", or "" for all.
func (c *Client) ListAlerts(status string, limit int) ([]Alert, error) {
q := url.Values{}
if status != "" {
q.Set("status", status)
}
if limit > 0 {
q.Set("limit", strconv.Itoa(limit))
}
path := "/api/alerts"
if len(q) > 0 {
path += "?" + q.Encode()
}
req, err := c.newRequest(http.MethodGet, path)
if err != nil {
return nil, err
}
var alerts []Alert
return alerts, c.do(req, &alerts)
}
// GetAlertStats fetches aggregate alert counts.
func (c *Client) GetAlertStats() (*AlertStats, error) {
req, err := c.newRequest(http.MethodGet, "/api/stats/alerts")
if err != nil {
return nil, err
}
var stats AlertStats
return &stats, c.do(req, &stats)
}
// HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway). // HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway).
func (c *Client) HealthCheck() error { func (c *Client) HealthCheck() error {
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil) req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
+25
View File
@@ -0,0 +1,25 @@
package api
import "time"
type Alert struct {
ID int64 `json:"id"`
Fingerprint string `json:"fingerprint"`
Name string `json:"name"`
Status string `json:"status"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
StartsAt time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at"`
GeneratorURL string `json:"generator_url"`
ReceivedAt time.Time `json:"received_at"`
AcknowledgedByID *int64 `json:"acknowledged_by_id"`
AcknowledgedBy string `json:"acknowledged_by"`
AcknowledgedAt *time.Time `json:"acknowledged_at"`
}
type AlertStats struct {
Total int `json:"total"`
Firing int `json:"firing"`
Resolved int `json:"resolved"`
}
+112 -12
View File
@@ -1,10 +1,13 @@
package tui package tui
import ( import (
"fmt"
"time" "time"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api" "github.com/yeniklas/terdut-tui/internal/api"
) )
@@ -16,19 +19,13 @@ const (
sectionUsers sectionUsers
) )
type mode int
const (
modeDashboard mode = iota
modeDetail
modeSchedule
modeUsers
)
// tea.Msg types // tea.Msg types
type connectedMsg struct{ serverURL string } type connectedMsg struct{}
type connectErrMsg struct{ err error } type connectErrMsg struct{ err error }
type alertsFetchedMsg struct{ alerts []api.Alert }
type statsFetchedMsg struct{ stats api.AlertStats }
type fetchDataErrMsg struct{ err error }
type tickMsg time.Time type tickMsg time.Time
type clearStatusMsg struct{} type clearStatusMsg struct{}
@@ -39,25 +36,41 @@ type Model struct {
refreshInterval time.Duration refreshInterval time.Duration
activeSection section activeSection section
mode mode
width int width int
height int height int
connected bool connected bool
loading bool
err error err error
statusMsg string statusMsg string
alerts []api.Alert
stats *api.AlertStats
filterStatus string // "firing", "resolved", or "" (all)
alertTable table.Model
help help.Model help help.Model
keys keyMap keys keyMap
} }
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model { func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model {
t := table.New(table.WithFocused(true))
s := table.DefaultStyles()
s.Header = s.Header.Bold(true)
s.Selected = s.Selected.
Foreground(lipgloss.Color("0")).
Background(colorPrimary).
Bold(true)
t.SetStyles(s)
return Model{ return Model{
client: client, client: client,
serverURL: serverURL, serverURL: serverURL,
refreshInterval: refreshInterval, refreshInterval: refreshInterval,
activeSection: sectionAlerts, activeSection: sectionAlerts,
mode: modeDashboard, loading: true,
filterStatus: "firing",
alertTable: t,
help: help.New(), help: help.New(),
keys: keys, keys: keys,
} }
@@ -67,6 +80,73 @@ func (m Model) Init() tea.Cmd {
return connectCmd(m.client) return connectCmd(m.client)
} }
// rebuildTable updates the alert table columns, rows, and height to match current state.
func (m *Model) rebuildTable() {
m.alertTable.SetColumns(alertColumns(m.width))
m.alertTable.SetRows(alertRows(m.alerts))
h := m.height - 8 // header + tabs + sep + stats + table-header + footer + 2 margins
if h < 1 {
h = 1
}
m.alertTable.SetHeight(h)
}
func alertColumns(width int) []table.Column {
nameW := width/2 - 8
if nameW < 20 {
nameW = 20
}
ackW := width - nameW - 10 - 12 - 6
if ackW < 8 {
ackW = 8
}
return []table.Column{
{Title: "Name", Width: nameW},
{Title: "Status", Width: 10},
{Title: "Started", Width: 12},
{Title: "Ack By", Width: ackW},
}
}
func alertRows(alerts []api.Alert) []table.Row {
now := time.Now()
rows := make([]table.Row, len(alerts))
for i, a := range alerts {
ack := a.AcknowledgedBy
rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), ack}
}
return rows
}
func humanAgo(now, t time.Time) string {
d := now.Sub(t)
if d < 0 {
d = 0
}
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
h := int(d.Hours())
m := int(d.Minutes()) % 60
if m == 0 {
return fmt.Sprintf("%dh ago", h)
}
return fmt.Sprintf("%dh %dm ago", h, m)
default:
days := int(d.Hours()) / 24
h := int(d.Hours()) % 24
if h == 0 {
return fmt.Sprintf("%dd ago", days)
}
return fmt.Sprintf("%dd %dh ago", days, h)
}
}
// Command constructors
func connectCmd(client *api.Client) tea.Cmd { func connectCmd(client *api.Client) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := client.HealthCheck(); err != nil { if err := client.HealthCheck(); err != nil {
@@ -76,6 +156,26 @@ func connectCmd(client *api.Client) tea.Cmd {
} }
} }
func fetchAlertsCmd(client *api.Client, status string) tea.Cmd {
return func() tea.Msg {
alerts, err := client.ListAlerts(status, 500)
if err != nil {
return fetchDataErrMsg{err}
}
return alertsFetchedMsg{alerts}
}
}
func fetchStatsCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
stats, err := client.GetAlertStats()
if err != nil {
return fetchDataErrMsg{err}
}
return statsFetchedMsg{*stats}
}
}
func tickCmd(interval time.Duration) tea.Cmd { func tickCmd(interval time.Duration) tea.Cmd {
return tea.Tick(interval, func(t time.Time) tea.Msg { return tea.Tick(interval, func(t time.Time) tea.Msg {
return tickMsg(t) return tickMsg(t)
+58 -8
View File
@@ -9,27 +9,55 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.rebuildTable()
return m, nil return m, nil
case connectedMsg: case connectedMsg:
m.connected = true m.connected = true
m.err = nil m.err = nil
m.statusMsg = "Connected to " + m.serverURL return m, tea.Batch(
return m, tea.Batch(tickCmd(m.refreshInterval), clearStatusCmd()) tickCmd(m.refreshInterval),
fetchAlertsCmd(m.client, m.filterStatus),
fetchStatsCmd(m.client),
)
case connectErrMsg: case connectErrMsg:
m.connected = false m.connected = false
m.err = msg.err m.err = msg.err
return m, nil return m, nil
case alertsFetchedMsg:
m.alerts = msg.alerts
m.loading = false
m.rebuildTable()
return m, nil
case statsFetchedMsg:
m.stats = &msg.stats
return m, nil
case fetchDataErrMsg:
m.statusMsg = "error: " + msg.err.Error()
return m, clearStatusCmd()
case tickMsg: case tickMsg:
return m, tickCmd(m.refreshInterval) return m, tea.Batch(
tickCmd(m.refreshInterval),
fetchAlertsCmd(m.client, m.filterStatus),
fetchStatsCmd(m.client),
)
case clearStatusMsg: case clearStatusMsg:
m.statusMsg = "" m.statusMsg = ""
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
if m.activeSection == sectionAlerts && m.connected {
var tableCmd tea.Cmd
m.alertTable, tableCmd = m.alertTable.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
}
return m.handleKey(msg) return m.handleKey(msg)
} }
@@ -37,17 +65,39 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch { switch msg.String() {
case msg.String() == "q" || msg.String() == "ctrl+c": case "q", "ctrl+c":
return m, tea.Quit return m, tea.Quit
case msg.String() == "tab": case "tab":
m.activeSection = (m.activeSection + 1) % 3 m.activeSection = (m.activeSection + 1) % 3
return m, nil return m, nil
case msg.String() == "r": case "r":
if !m.connected {
return m, connectCmd(m.client)
}
m.statusMsg = "Refreshing…" m.statusMsg = "Refreshing…"
return m, tea.Batch(connectCmd(m.client), clearStatusCmd()) return m, tea.Batch(
fetchAlertsCmd(m.client, m.filterStatus),
fetchStatsCmd(m.client),
clearStatusCmd(),
)
case "f":
if m.activeSection != sectionAlerts || !m.connected {
return m, nil
}
switch m.filterStatus {
case "firing":
m.filterStatus = "resolved"
case "resolved":
m.filterStatus = ""
default:
m.filterStatus = "firing"
}
m.loading = true
return m, fetchAlertsCmd(m.client, m.filterStatus)
} }
return m, nil return m, nil
+64 -28
View File
@@ -24,11 +24,7 @@ func (m Model) View() string {
func (m Model) renderHeader() string { func (m Model) renderHeader() string {
title := styleHeader.Render("terdut-tui") title := styleHeader.Render("terdut-tui")
right := "" right := styleMuted.Render(m.serverURL)
if m.serverURL != "" {
right = styleMuted.Render(m.serverURL)
}
gap := m.width - lipgloss.Width(title) - lipgloss.Width(right) gap := m.width - lipgloss.Width(title) - lipgloss.Width(right)
if gap < 0 { if gap < 0 {
gap = 0 gap = 0
@@ -45,43 +41,83 @@ func (m Model) renderTabs() string {
tabs = append(tabs, styleTabInactive.Render(name)) tabs = append(tabs, styleTabInactive.Render(name))
} }
} }
line := strings.Repeat("─", m.width) sep := styleMuted.Render(strings.Repeat("─", m.width))
return strings.Join(tabs, "") + "\n" + styleMuted.Render(line) return strings.Join(tabs, "") + "\n" + sep
} }
func (m Model) renderBody() string { func (m Model) renderBody() string {
bodyHeight := m.height - 5 // header + tabs + separator + footer + help if m.err != nil {
if bodyHeight < 1 { return "\n" + styleError.Render(fmt.Sprintf(" Error: %v", m.err)) +
bodyHeight = 1 "\n" + styleMuted.Render(" Press r to retry.")
}
if !m.connected {
return "\n" + styleMuted.Render(" Connecting…")
} }
var content string
if m.err != nil {
content = styleError.Render(fmt.Sprintf("Error: %v", m.err))
} else if !m.connected {
content = styleMuted.Render("Connecting…")
} else if m.statusMsg != "" {
content = styleStatus.Render(m.statusMsg)
} else {
switch m.activeSection { switch m.activeSection {
case sectionAlerts: case sectionAlerts:
content = styleMuted.Render("Alert dashboard — coming in Stage 2") return m.renderAlerts()
case sectionSchedule: case sectionSchedule:
content = styleMuted.Render("On-call schedule — coming in Stage 4") return "\n" + styleMuted.Render(" On-call schedule — coming in Stage 4")
case sectionUsers: case sectionUsers:
content = styleMuted.Render("User management — coming in Stage 5") return "\n" + styleMuted.Render(" User management — coming in Stage 5")
} }
return ""
} }
lines := strings.Split(content, "\n") func (m Model) renderAlerts() string {
padding := (bodyHeight - len(lines)) / 2 statsBar := m.renderStatsBar()
if padding < 0 { var content string
padding = 0 if m.loading && len(m.alerts) == 0 {
content = styleMuted.Render(" Loading alerts…")
} else if len(m.alerts) == 0 {
label := m.filterStatus
if label == "" {
label = "all"
} }
return strings.Repeat("\n", padding) + content content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", label))
} else {
content = m.alertTable.View()
}
return lipgloss.JoinVertical(lipgloss.Left, statsBar, content)
}
func (m Model) renderStatsBar() string {
total, firing, resolved := 0, 0, 0
if m.stats != nil {
total = m.stats.Total
firing = m.stats.Firing
resolved = m.stats.Resolved
}
filterLabel := m.filterStatus
if filterLabel == "" {
filterLabel = "all"
}
left := fmt.Sprintf(" Total: %d %s %s",
total,
styleFiring.Render(fmt.Sprintf("Firing: %d", firing)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
)
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel))
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 0 {
gap = 0
}
return left + strings.Repeat(" ", gap) + right
} }
func (m Model) renderFooter() string { func (m Model) renderFooter() string {
helpView := m.help.ShortHelpView(m.keys.ShortHelp()) helpView := styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
return "\n" + styleFooter.Render(helpView) if m.statusMsg != "" {
status := styleStatus.Render(m.statusMsg)
gap := m.width - lipgloss.Width(status) - lipgloss.Width(helpView)
if gap < 0 {
gap = 0
}
return "\n" + status + strings.Repeat(" ", gap) + helpView
}
return "\n" + helpView
} }