From b5ee62f145788e38f6f7d13645573d0ca88ca0a6 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Thu, 21 May 2026 13:48:01 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Stage=203=20=E2=80=94=20alert=20detail,?= =?UTF-8?q?=20acknowledge,=20comments,=20stats=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- go.sum | 2 + internal/api/client.go | 95 +++++++++++++ internal/api/types.go | 25 ++++ internal/tui/model.go | 199 +++++++++++++++++++++++++-- internal/tui/styles.go | 10 +- internal/tui/update.go | 227 +++++++++++++++++++++++++++++- internal/tui/view.go | 303 ++++++++++++++++++++++++++++++++++++++--- 7 files changed, 825 insertions(+), 36 deletions(-) diff --git a/go.sum b/go.sum index dfc953c..573ad57 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/api/client.go b/internal/api/client.go index 28bb631..c509fb0 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -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) diff --git a/internal/api/types.go b/internal/api/types.go index c00f8d3..ba801be 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -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"` +} diff --git a/internal/tui/model.go b/internal/tui/model.go index b409ceb..ff9bf76 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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) diff --git a/internal/tui/styles.go b/internal/tui/styles.go index ccf5983..3309613 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -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) ) diff --git a/internal/tui/update.go b/internal/tui/update.go index 3f1f8e2..1b82a53 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -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 +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 0057b46..7adbe39 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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] + "…" }