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:
+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