feat: Stage 3 — alert detail, acknowledge, comments, stats charts

Press enter on any alert to open a full-screen detail pane (viewport).
Keybindings in detail mode:
  a/A   acknowledge / unacknowledge (updates immediately)
  c     compose a comment (text input at bottom)
  [/]   cycle comment cursor up/down
  d     delete selected comment (y/N confirmation)
  s     assign placeholder — shows "not yet supported by server"
  S     open stats view: top alerts + by-hour/by-day ASCII bar charts
  esc   back to dashboard

API additions: GetAlert, AcknowledgeAlert, UnacknowledgeAlert,
GetComments, AddComment, DeleteComment, GetTopAlerts,
GetStatsByHour, GetStatsByDay.
This commit is contained in:
Niklas Ye
2026-05-21 13:48:01 +02:00
parent e29cff21ae
commit b5ee62f145
7 changed files with 825 additions and 36 deletions
+2
View File
@@ -1,3 +1,5 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
+95
View File
@@ -1,6 +1,7 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
@@ -92,6 +93,100 @@ func (c *Client) GetAlertStats() (*AlertStats, error) {
return &stats, c.do(req, &stats)
}
func (c *Client) newRequestWithBody(method, path string, body any) (*http.Request, error) {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, c.baseURL+path, bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
return req, nil
}
func (c *Client) GetAlert(id int64) (*Alert, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/alerts/%d", id))
if err != nil {
return nil, err
}
var alert Alert
return &alert, c.do(req, &alert)
}
func (c *Client) AcknowledgeAlert(id int64) (*Alert, error) {
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/alerts/%d/acknowledge", id))
if err != nil {
return nil, err
}
var alert Alert
return &alert, c.do(req, &alert)
}
func (c *Client) UnacknowledgeAlert(id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/alerts/%d/acknowledge", id))
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) GetComments(alertID int64) ([]Comment, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/alerts/%d/comments", alertID))
if err != nil {
return nil, err
}
var comments []Comment
return comments, c.do(req, &comments)
}
func (c *Client) AddComment(alertID int64, content string) (*Comment, error) {
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/alerts/%d/comments", alertID), map[string]string{"content": content})
if err != nil {
return nil, err
}
var comment Comment
return &comment, c.do(req, &comment)
}
func (c *Client) DeleteComment(alertID, commentID int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/alerts/%d/comments/%d", alertID, commentID))
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) GetTopAlerts(limit int) ([]TopAlert, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/stats/alerts/top?limit=%d", limit))
if err != nil {
return nil, err
}
var result []TopAlert
return result, c.do(req, &result)
}
func (c *Client) GetStatsByHour() ([]HourStat, error) {
req, err := c.newRequest(http.MethodGet, "/api/stats/alerts/by-hour")
if err != nil {
return nil, err
}
var result []HourStat
return result, c.do(req, &result)
}
func (c *Client) GetStatsByDay() ([]DayStat, error) {
req, err := c.newRequest(http.MethodGet, "/api/stats/alerts/by-day")
if err != nil {
return nil, err
}
var result []DayStat
return result, c.do(req, &result)
}
// 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)
+25
View File
@@ -23,3 +23,28 @@ type AlertStats struct {
Firing int `json:"firing"`
Resolved int `json:"resolved"`
}
type Comment struct {
ID int64 `json:"id"`
AlertID int64 `json:"alert_id"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
type TopAlert struct {
Name string `json:"name"`
Count int `json:"count"`
}
type HourStat struct {
Hour int `json:"hour"`
Count int `json:"count"`
}
type DayStat struct {
Day int `json:"day"`
DayName string `json:"day_name"`
Count int `json:"count"`
}
+189 -10
View File
@@ -6,6 +6,8 @@ import (
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api"
@@ -19,7 +21,17 @@ const (
sectionUsers
)
// tea.Msg types
type mode int
const (
modeDashboard mode = iota
modeDetail
modeComment
modeConfirmDelete
modeStats
)
// tea.Msg types — dashboard
type connectedMsg struct{}
type connectErrMsg struct{ err error }
@@ -29,28 +41,65 @@ type fetchDataErrMsg struct{ err error }
type tickMsg time.Time
type clearStatusMsg struct{}
// tea.Msg types — detail
type alertDetailFetchedMsg struct {
alert api.Alert
comments []api.Comment
}
type alertDetailErrMsg struct{ err error }
type actionErrMsg struct{ err error }
type detailStatsFetchedMsg struct {
top []api.TopAlert
byHour []api.HourStat
byDay []api.DayStat
}
type detailStatsErrMsg struct{ err error }
// Model holds all UI state.
type Model struct {
client *api.Client
serverURL string
refreshInterval time.Duration
activeSection section
mode mode
width int
height int
// Connection & dashboard
connected bool
loading bool
err error
statusMsg string
alerts []api.Alert
stats *api.AlertStats
filterStatus string // "firing", "resolved", or "" (all)
filterStatus string
alertTable table.Model
alertTable table.Model
help help.Model
keys keyMap
// Detail view
selectedAlert api.Alert
comments []api.Comment
commentCursor int
detailLoading bool
detailViewport viewport.Model
// Comment compose
commentInput textinput.Model
// Confirm delete
pendingDeleteID int64
// Stats view
topAlerts []api.TopAlert
hourStats []api.HourStat
dayStats []api.DayStat
statsLoading bool
statsViewport viewport.Model
help help.Model
keys keyMap
}
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model {
@@ -63,14 +112,21 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
Bold(true)
t.SetStyles(s)
ti := textinput.New()
ti.Placeholder = "type your comment…"
ti.CharLimit = 1000
return Model{
client: client,
serverURL: serverURL,
refreshInterval: refreshInterval,
activeSection: sectionAlerts,
mode: modeDashboard,
loading: true,
filterStatus: "firing",
commentCursor: -1,
alertTable: t,
commentInput: ti,
help: help.New(),
keys: keys,
}
@@ -80,17 +136,44 @@ func (m Model) Init() tea.Cmd {
return connectCmd(m.client)
}
// rebuildTable updates the alert table columns, rows, and height to match current state.
// rebuildTable updates alert table columns, rows, and height from 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
h := m.height - 8
if h < 1 {
h = 1
}
m.alertTable.SetHeight(h)
}
// refreshDetailContent rebuilds the viewport content from selectedAlert + comments.
func (m *Model) refreshDetailContent() {
if m.width == 0 {
return
}
content := buildDetailContent(m.selectedAlert, m.comments, m.commentCursor, m.width)
m.detailViewport.SetContent(content)
}
// refreshStatsContent rebuilds the stats viewport from loaded stats data.
func (m *Model) refreshStatsContent() {
content := buildStatsContent(m.topAlerts, m.hourStats, m.dayStats, m.width)
m.statsViewport.SetContent(content)
}
// detailViewportHeight returns the detail viewport height for the current mode.
func (m Model) detailViewportHeight() int {
h := m.height - 5
if m.mode == modeComment {
h -= 2 // separator + input line
}
if h < 1 {
h = 1
}
return h
}
func alertColumns(width int) []table.Column {
nameW := width/2 - 8
if nameW < 20 {
@@ -112,8 +195,7 @@ 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}
rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), a.AcknowledgedBy}
}
return rows
}
@@ -176,6 +258,103 @@ func fetchStatsCmd(client *api.Client) tea.Cmd {
}
}
func fetchAlertDetailCmd(client *api.Client, alertID int64) tea.Cmd {
return func() tea.Msg {
alert, err := client.GetAlert(alertID)
if err != nil {
return alertDetailErrMsg{err}
}
comments, err := client.GetComments(alertID)
if err != nil {
return alertDetailErrMsg{err}
}
return alertDetailFetchedMsg{alert: *alert, comments: comments}
}
}
func acknowledgeCmd(client *api.Client, alertID int64) tea.Cmd {
return func() tea.Msg {
alert, err := client.AcknowledgeAlert(alertID)
if err != nil {
return actionErrMsg{err}
}
comments, err := client.GetComments(alertID)
if err != nil {
return actionErrMsg{err}
}
return alertDetailFetchedMsg{alert: *alert, comments: comments}
}
}
func unacknowledgeCmd(client *api.Client, alertID int64) tea.Cmd {
return func() tea.Msg {
if err := client.UnacknowledgeAlert(alertID); err != nil {
return actionErrMsg{err}
}
alert, err := client.GetAlert(alertID)
if err != nil {
return actionErrMsg{err}
}
comments, err := client.GetComments(alertID)
if err != nil {
return actionErrMsg{err}
}
return alertDetailFetchedMsg{alert: *alert, comments: comments}
}
}
func addCommentCmd(client *api.Client, alertID int64, content string) tea.Cmd {
return func() tea.Msg {
if _, err := client.AddComment(alertID, content); err != nil {
return actionErrMsg{err}
}
alert, err := client.GetAlert(alertID)
if err != nil {
return actionErrMsg{err}
}
comments, err := client.GetComments(alertID)
if err != nil {
return actionErrMsg{err}
}
return alertDetailFetchedMsg{alert: *alert, comments: comments}
}
}
func deleteCommentCmd(client *api.Client, alertID, commentID int64) tea.Cmd {
return func() tea.Msg {
if err := client.DeleteComment(alertID, commentID); err != nil {
return actionErrMsg{err}
}
alert, err := client.GetAlert(alertID)
if err != nil {
return actionErrMsg{err}
}
comments, err := client.GetComments(alertID)
if err != nil {
return actionErrMsg{err}
}
return alertDetailFetchedMsg{alert: *alert, comments: comments}
}
}
func fetchDetailStatsCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
top, err := client.GetTopAlerts(10)
if err != nil {
return detailStatsErrMsg{err}
}
byHour, err := client.GetStatsByHour()
if err != nil {
return detailStatsErrMsg{err}
}
byDay, err := client.GetStatsByDay()
if err != nil {
return detailStatsErrMsg{err}
}
return detailStatsFetchedMsg{top: top, byHour: byHour, byDay: byDay}
}
}
func tickCmd(interval time.Duration) tea.Cmd {
return tea.Tick(interval, func(t time.Time) tea.Msg {
return tickMsg(t)
+7 -3
View File
@@ -35,7 +35,11 @@ var (
Foreground(colorFiring).
Bold(true)
styleFiring = lipgloss.NewStyle().Foreground(colorFiring).Bold(true)
styleResolved = lipgloss.NewStyle().Foreground(colorResolved)
styleMuted = lipgloss.NewStyle().Foreground(colorMuted)
styleFiring = lipgloss.NewStyle().Foreground(colorFiring).Bold(true)
styleResolved = lipgloss.NewStyle().Foreground(colorResolved)
styleMuted = lipgloss.NewStyle().Foreground(colorMuted)
styleAlertName = lipgloss.NewStyle().Bold(true)
styleBold = lipgloss.NewStyle().Bold(true)
styleSelected = lipgloss.NewStyle().Foreground(colorPrimary).Bold(true)
styleAccent = lipgloss.NewStyle().Foreground(colorAccent)
)
+224 -3
View File
@@ -1,6 +1,9 @@
package tui
import (
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
@@ -10,8 +13,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
m.rebuildTable()
m.detailViewport.Width = m.width
m.detailViewport.Height = m.detailViewportHeight()
m.statsViewport.Width = m.width
m.statsViewport.Height = m.height - 5
m.refreshDetailContent()
m.refreshStatsContent()
return m, nil
// Dashboard messages
case connectedMsg:
m.connected = true
m.err = nil
@@ -37,7 +47,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case fetchDataErrMsg:
m.statusMsg = "error: " + msg.err.Error()
m.statusMsg = "refresh error: " + msg.err.Error()
return m, clearStatusCmd()
case tickMsg:
@@ -47,11 +57,73 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
fetchStatsCmd(m.client),
)
// Detail messages
case alertDetailFetchedMsg:
m.selectedAlert = msg.alert
m.comments = msg.comments
m.detailLoading = false
if m.commentCursor >= len(m.comments) {
m.commentCursor = -1
}
m.refreshDetailContent()
return m, nil
case alertDetailErrMsg:
m.detailLoading = false
m.statusMsg = "error: " + msg.err.Error()
return m, clearStatusCmd()
case actionErrMsg:
m.statusMsg = "error: " + msg.err.Error()
return m, clearStatusCmd()
case detailStatsFetchedMsg:
m.topAlerts = msg.top
m.hourStats = msg.byHour
m.dayStats = msg.byDay
m.statsLoading = false
m.refreshStatsContent()
return m, nil
case detailStatsErrMsg:
m.statsLoading = false
m.statusMsg = "stats error: " + msg.err.Error()
m.mode = modeDetail
return m, clearStatusCmd()
case clearStatusMsg:
m.statusMsg = ""
return m, nil
case tea.KeyMsg:
return m.routeKey(msg)
}
return m, nil
}
// routeKey passes the key event to the appropriate component then our handler.
func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch m.mode {
case modeDetail, modeConfirmDelete:
var vpCmd tea.Cmd
m.detailViewport, vpCmd = m.detailViewport.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(vpCmd, ourCmd)
case modeComment:
var inputCmd tea.Cmd
m.commentInput, inputCmd = m.commentInput.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeStats:
var vpCmd tea.Cmd
m.statsViewport, vpCmd = m.statsViewport.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(vpCmd, ourCmd)
default: // modeDashboard
if m.activeSection == sectionAlerts && m.connected {
var tableCmd tea.Cmd
m.alertTable, tableCmd = m.alertTable.Update(msg)
@@ -60,11 +132,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m.handleKey(msg)
}
return m, nil
}
func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch m.mode {
case modeDetail:
return m.handleDetailKey(msg)
case modeComment:
return m.handleCommentKey(msg)
case modeConfirmDelete:
return m.handleConfirmKey(msg)
case modeStats:
return m.handleStatsKey(msg)
default:
return m.handleDashboardKey(msg)
}
}
func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
@@ -98,7 +183,143 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
}
m.loading = true
return m, fetchAlertsCmd(m.client, m.filterStatus)
case "enter":
if m.activeSection != sectionAlerts || len(m.alerts) == 0 {
return m, nil
}
cursor := m.alertTable.Cursor()
if cursor >= len(m.alerts) {
return m, nil
}
m.selectedAlert = m.alerts[cursor]
m.mode = modeDetail
m.commentCursor = -1
m.detailLoading = true
m.detailViewport = viewport.New(m.width, m.detailViewportHeight())
return m, fetchAlertDetailCmd(m.client, m.selectedAlert.ID)
}
return m, nil
}
func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc", "backspace":
m.mode = modeDashboard
m.statusMsg = ""
return m, nil
case "a":
if m.selectedAlert.AcknowledgedByID != nil {
m.statusMsg = "already acknowledged"
return m, clearStatusCmd()
}
return m, acknowledgeCmd(m.client, m.selectedAlert.ID)
case "A":
if m.selectedAlert.AcknowledgedByID == nil {
m.statusMsg = "not acknowledged"
return m, clearStatusCmd()
}
return m, unacknowledgeCmd(m.client, m.selectedAlert.ID)
case "c":
m.mode = modeComment
m.commentInput.Reset()
m.commentInput.Focus()
m.detailViewport.Height = m.detailViewportHeight()
return m, nil
case "d":
if m.commentCursor < 0 || m.commentCursor >= len(m.comments) {
m.statusMsg = "select a comment first with [ / ]"
return m, clearStatusCmd()
}
m.pendingDeleteID = m.comments[m.commentCursor].ID
m.mode = modeConfirmDelete
return m, nil
case "s":
m.statusMsg = "assign not yet supported by server"
return m, clearStatusCmd()
case "S":
m.mode = modeStats
m.statsLoading = true
m.statsViewport = viewport.New(m.width, m.height-5)
return m, fetchDetailStatsCmd(m.client)
case "[":
if len(m.comments) == 0 {
return m, nil
}
if m.commentCursor <= 0 {
m.commentCursor = len(m.comments) - 1
} else {
m.commentCursor--
}
m.refreshDetailContent()
return m, nil
case "]":
if len(m.comments) == 0 {
return m, nil
}
if m.commentCursor >= len(m.comments)-1 {
m.commentCursor = 0
} else {
m.commentCursor++
}
m.refreshDetailContent()
return m, nil
}
return m, nil
}
func (m Model) handleCommentKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.mode = modeDetail
m.commentInput.Blur()
m.detailViewport.Height = m.detailViewportHeight()
return m, nil
case "enter":
content := strings.TrimSpace(m.commentInput.Value())
if content == "" {
return m, nil
}
m.mode = modeDetail
m.commentInput.Blur()
m.detailViewport.Height = m.detailViewportHeight()
return m, addCommentCmd(m.client, m.selectedAlert.ID, content)
}
return m, nil
}
func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch strings.ToLower(msg.String()) {
case "y":
alertID := m.selectedAlert.ID
commentID := m.pendingDeleteID
m.mode = modeDetail
m.commentCursor = -1
m.pendingDeleteID = 0
return m, deleteCommentCmd(m.client, alertID, commentID)
default:
m.mode = modeDetail
m.pendingDeleteID = 0
return m, nil
}
}
func (m Model) handleStatsKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if msg.String() == "esc" {
m.mode = modeDetail
return m, nil
}
return m, nil
}
+283 -20
View File
@@ -2,9 +2,12 @@ package tui
import (
"fmt"
"sort"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api"
)
var sectionNames = []string{"Alerts", "Schedule", "Users"}
@@ -13,13 +16,12 @@ func (m Model) View() string {
if m.width == 0 {
return ""
}
header := m.renderHeader()
tabs := m.renderTabs()
body := m.renderBody()
footer := m.renderFooter()
return lipgloss.JoinVertical(lipgloss.Left, header, tabs, body, footer)
return lipgloss.JoinVertical(lipgloss.Left,
m.renderHeader(),
m.renderTabs(),
m.renderBody(),
m.renderFooter(),
)
}
func (m Model) renderHeader() string {
@@ -54,6 +56,62 @@ func (m Model) renderBody() string {
return "\n" + styleMuted.Render(" Connecting…")
}
switch m.mode {
case modeDetail:
return m.renderDetail()
case modeComment:
return m.renderCommentCompose()
case modeConfirmDelete:
return m.renderDetail()
case modeStats:
return m.renderStats()
default:
return m.renderDashboard()
}
}
func (m Model) renderFooter() string {
switch m.mode {
case modeDetail:
actions := styleFooter.Render(" a·ack A·unack c·comment [/]·select d·del s·assign S·stats esc·back")
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
}
return "\n" + actions
case modeComment:
return "\n" + styleFooter.Render(" enter·submit esc·cancel")
case modeConfirmDelete:
commentInfo := ""
if m.commentCursor >= 0 && m.commentCursor < len(m.comments) {
commentInfo = fmt.Sprintf(" by %s", m.comments[m.commentCursor].Username)
}
return "\n" + styleError.Render(fmt.Sprintf(" Delete comment%s? [y/N]", commentInfo))
case modeStats:
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" esc·back")
}
return "\n" + styleFooter.Render(" esc·back")
default:
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
}
}
// ── Dashboard ──────────────────────────────────────────────────────────────
func (m Model) renderDashboard() string {
switch m.activeSection {
case sectionAlerts:
return m.renderAlerts()
@@ -89,19 +147,16 @@ func (m Model) renderStatsBar() string {
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
@@ -109,15 +164,223 @@ func (m Model) renderStatsBar() string {
return left + strings.Repeat(" ", gap) + right
}
func (m Model) renderFooter() string {
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
// ── Detail ─────────────────────────────────────────────────────────────────
func (m Model) renderDetail() string {
if m.detailLoading {
return "\n" + styleMuted.Render(" Loading alert details…")
}
return "\n" + helpView
return m.detailViewport.View()
}
func (m Model) renderCommentCompose() string {
vp := m.detailViewport.View()
sep := styleMuted.Render(strings.Repeat("─", m.width))
prompt := styleHeader.Render("Comment: ") + m.commentInput.View()
return vp + "\n" + sep + "\n" + prompt
}
// ── Stats ──────────────────────────────────────────────────────────────────
func (m Model) renderStats() string {
if m.statsLoading {
return "\n" + styleMuted.Render(" Loading statistics…")
}
return m.statsViewport.View()
}
// ── Content builders ───────────────────────────────────────────────────────
func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width int) string {
now := time.Now()
var b strings.Builder
contentW := width - 4
// Name + status header
name := styleAlertName.Render(alert.Name)
var statusStr string
if alert.Status == "firing" {
statusStr = styleFiring.Render("● FIRING")
} else {
statusStr = styleResolved.Render("✓ RESOLVED")
}
nameGap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
if nameGap < 1 {
nameGap = 1
}
b.WriteString("\n " + name + strings.Repeat(" ", nameGap) + statusStr + "\n\n")
// Timeline
b.WriteString(fmt.Sprintf(" Started: %s (%s)\n",
alert.StartsAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, alert.StartsAt)))
if alert.EndsAt != nil {
b.WriteString(fmt.Sprintf(" Ended: %s\n", alert.EndsAt.UTC().Format("2006-01-02 15:04 UTC")))
}
if alert.GeneratorURL != "" {
url := alert.GeneratorURL
if len(url) > contentW-12 {
url = url[:contentW-15] + "…"
}
b.WriteString(fmt.Sprintf(" Source: %s\n", url))
}
b.WriteString("\n")
// Labels
if len(alert.Labels) > 0 {
b.WriteString(divider("Labels", width))
for _, k := range sortedKeys(alert.Labels) {
v := alert.Labels[k]
if len(v) > contentW-24 {
v = v[:contentW-27] + "…"
}
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, v))
}
b.WriteString("\n")
}
// Annotations
if len(alert.Annotations) > 0 {
b.WriteString(divider("Annotations", width))
for _, k := range sortedKeys(alert.Annotations) {
v := alert.Annotations[k]
if len(v) > contentW-24 {
v = v[:contentW-27] + "…"
}
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, v))
}
b.WriteString("\n")
}
// Acknowledgement
b.WriteString(divider("Acknowledgement", width))
if alert.AcknowledgedByID != nil {
ackAt := ""
if alert.AcknowledgedAt != nil {
ackAt = " at " + alert.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC")
}
b.WriteString(styleResolved.Render(fmt.Sprintf(" ✓ Acknowledged by %s%s\n", alert.AcknowledgedBy, ackAt)))
} else {
b.WriteString(styleMuted.Render(" Not acknowledged\n"))
}
b.WriteString("\n")
// Comments
b.WriteString(divider(fmt.Sprintf("Comments (%d)", len(comments)), width))
if len(comments) == 0 {
b.WriteString(styleMuted.Render(" No comments yet.\n"))
} else {
for i, c := range comments {
prefix := " "
authorLine := fmt.Sprintf("%s%s • %s", prefix, styleBold.Render(c.Username), humanAgo(now, c.CreatedAt))
if i == cursor {
authorLine = styleSelected.Render("> ") + styleBold.Render(c.Username) +
styleMuted.Render(fmt.Sprintf(" • %s", humanAgo(now, c.CreatedAt)))
}
b.WriteString(authorLine + "\n")
b.WriteString(" " + c.Content + "\n\n")
}
}
return b.String()
}
func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string {
barWidth := width/2 - 10
if barWidth < 8 {
barWidth = 8
}
if barWidth > 40 {
barWidth = 40
}
var b strings.Builder
b.WriteString("\n")
// Top alerts
b.WriteString(divider("Top Alerts", width))
if len(top) == 0 {
b.WriteString(styleMuted.Render(" No data.\n"))
} else {
maxCount := top[0].Count
for i, a := range top {
bar := styleResolved.Render(strings.Repeat("█", renderBarWidth(a.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %2d. %-30s %s %d\n", i+1, truncate(a.Name, 30), bar, a.Count))
}
}
b.WriteString("\n")
// By hour
b.WriteString(divider("Alerts by Hour (UTC)", width))
if len(byHour) > 0 {
maxCount := 0
for _, h := range byHour {
if h.Count > maxCount {
maxCount = h.Count
}
}
for _, h := range byHour {
bar := styleFiring.Render(strings.Repeat("█", renderBarWidth(h.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %2dh %-*s %d\n", h.Hour, barWidth, bar, h.Count))
}
} else {
b.WriteString(styleMuted.Render(" No data.\n"))
}
b.WriteString("\n")
// By day
b.WriteString(divider("Alerts by Day", width))
if len(byDay) > 0 {
maxCount := 0
for _, d := range byDay {
if d.Count > maxCount {
maxCount = d.Count
}
}
for _, d := range byDay {
bar := styleAccent.Render(strings.Repeat("█", renderBarWidth(d.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %-4s %-*s %d\n", d.DayName[:3], barWidth, bar, d.Count))
}
} else {
b.WriteString(styleMuted.Render(" No data.\n"))
}
return b.String()
}
// ── Helpers ────────────────────────────────────────────────────────────────
func divider(title string, width int) string {
prefix := "── " + title + " "
remaining := width - len(prefix) - 2
if remaining > 0 {
prefix += strings.Repeat("─", remaining)
}
return styleMuted.Render(prefix) + "\n"
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func renderBarWidth(count, maxCount, maxWidth int) int {
if maxCount == 0 {
return 0
}
w := count * maxWidth / maxCount
if w == 0 && count > 0 {
w = 1
}
return w
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-1] + "…"
}