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
+117 -17
View File
@@ -1,10 +1,13 @@
package tui
import (
"fmt"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api"
)
@@ -16,19 +19,13 @@ const (
sectionUsers
)
type mode int
const (
modeDashboard mode = iota
modeDetail
modeSchedule
modeUsers
)
// tea.Msg types
type connectedMsg struct{ serverURL string }
type connectedMsg struct{}
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 clearStatusMsg struct{}
@@ -39,25 +36,41 @@ type Model struct {
refreshInterval time.Duration
activeSection section
mode mode
width int
height int
connected bool
err error
statusMsg string
connected bool
loading bool
err error
statusMsg string
help help.Model
keys keyMap
alerts []api.Alert
stats *api.AlertStats
filterStatus string // "firing", "resolved", or "" (all)
alertTable table.Model
help help.Model
keys keyMap
}
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{
client: client,
serverURL: serverURL,
refreshInterval: refreshInterval,
activeSection: sectionAlerts,
mode: modeDashboard,
loading: true,
filterStatus: "firing",
alertTable: t,
help: help.New(),
keys: keys,
}
@@ -67,6 +80,73 @@ func (m Model) Init() tea.Cmd {
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 {
return func() tea.Msg {
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 {
return tea.Tick(interval, func(t time.Time) tea.Msg {
return tickMsg(t)