diff --git a/internal/api/client.go b/internal/api/client.go index 497152e..28bb631 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" + "strconv" "strings" "time" ) @@ -58,6 +60,38 @@ func (c *Client) do(req *http.Request, out any) error { 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). func (c *Client) HealthCheck() error { req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil) diff --git a/internal/api/types.go b/internal/api/types.go new file mode 100644 index 0000000..c00f8d3 --- /dev/null +++ b/internal/api/types.go @@ -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"` +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 785a5cf..b409ceb 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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) diff --git a/internal/tui/update.go b/internal/tui/update.go index c47889a..3f1f8e2 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -9,27 +9,55 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.rebuildTable() return m, nil case connectedMsg: m.connected = true m.err = nil - m.statusMsg = "Connected to " + m.serverURL - return m, tea.Batch(tickCmd(m.refreshInterval), clearStatusCmd()) + return m, tea.Batch( + tickCmd(m.refreshInterval), + fetchAlertsCmd(m.client, m.filterStatus), + fetchStatsCmd(m.client), + ) case connectErrMsg: m.connected = false m.err = msg.err 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: - return m, tickCmd(m.refreshInterval) + return m, tea.Batch( + tickCmd(m.refreshInterval), + fetchAlertsCmd(m.client, m.filterStatus), + fetchStatsCmd(m.client), + ) case clearStatusMsg: m.statusMsg = "" return m, nil 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) } @@ -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) { - switch { - case msg.String() == "q" || msg.String() == "ctrl+c": + switch msg.String() { + case "q", "ctrl+c": return m, tea.Quit - case msg.String() == "tab": + case "tab": m.activeSection = (m.activeSection + 1) % 3 return m, nil - case msg.String() == "r": + case "r": + if !m.connected { + return m, connectCmd(m.client) + } 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 diff --git a/internal/tui/view.go b/internal/tui/view.go index 94fbec9..0057b46 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -24,11 +24,7 @@ func (m Model) View() string { func (m Model) renderHeader() string { title := styleHeader.Render("terdut-tui") - right := "" - if m.serverURL != "" { - right = styleMuted.Render(m.serverURL) - } - + right := styleMuted.Render(m.serverURL) gap := m.width - lipgloss.Width(title) - lipgloss.Width(right) if gap < 0 { gap = 0 @@ -45,43 +41,83 @@ func (m Model) renderTabs() string { tabs = append(tabs, styleTabInactive.Render(name)) } } - line := strings.Repeat("─", m.width) - return strings.Join(tabs, "") + "\n" + styleMuted.Render(line) + sep := styleMuted.Render(strings.Repeat("─", m.width)) + return strings.Join(tabs, "") + "\n" + sep } func (m Model) renderBody() string { - bodyHeight := m.height - 5 // header + tabs + separator + footer + help - if bodyHeight < 1 { - bodyHeight = 1 - } - - 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 { - case sectionAlerts: - content = styleMuted.Render("Alert dashboard — coming in Stage 2") - case sectionSchedule: - content = styleMuted.Render("On-call schedule — coming in Stage 4") - case sectionUsers: - content = styleMuted.Render("User management — coming in Stage 5") - } + return "\n" + styleError.Render(fmt.Sprintf(" Error: %v", m.err)) + + "\n" + styleMuted.Render(" Press r to retry.") + } + if !m.connected { + return "\n" + styleMuted.Render(" Connecting…") } - lines := strings.Split(content, "\n") - padding := (bodyHeight - len(lines)) / 2 - if padding < 0 { - padding = 0 + switch m.activeSection { + case sectionAlerts: + return m.renderAlerts() + case sectionSchedule: + return "\n" + styleMuted.Render(" On-call schedule — coming in Stage 4") + case sectionUsers: + return "\n" + styleMuted.Render(" User management — coming in Stage 5") } - return strings.Repeat("\n", padding) + content + return "" +} + +func (m Model) renderAlerts() string { + statsBar := m.renderStatsBar() + var content string + 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" + } + 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 { - helpView := m.help.ShortHelpView(m.keys.ShortHelp()) - return "\n" + styleFooter.Render(helpView) + helpView := styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp())) + 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 }