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:
+189
-10
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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] + "…"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user