feat!: incidents as the primary object
Release / build (amd64, darwin) (push) Failing after 9s
Release / build (arm64, darwin) (push) Failing after 10s
Release / build (amd64, linux) (push) Failing after 10s
Release / release (push) Has been skipped
Release / build (arm64, linux) (push) Failing after 11s

terdut-server v0.4.0 splits the alerts row into two objects, and the
endpoints this client drove for acknowledgement, comments and archiving
are gone. Pointing the same screens at the new paths would have missed
the point of the split: alerts are now Alertmanager's record, read-only
and carrying no human state, while the incident is the thing anyone
actually works on.

Incidents lead the section list and are what the client opens on. The
queue shows severity, status, assignee and age, and the detail view adds
what only exists server-side now: the group labels Alertmanager
correlated on, the member alerts, and an append-only timeline where
system events and notes are interleaved. That timeline is the whole
history the server keeps — alert rows are still mutated in place — so
rendering it in order matters more than styling it.

Actions all move onto the incident: a/A acknowledge, s assign, z/Z
snooze, c note, d delete note, x archive, R resolve.

Two of those need care rather than a keybinding:

  - R, not r, resolves, and it asks first. The server treats a manual
    resolve as terminal: a later occurrence opens a new incident instead
    of reopening this one, and an alert that never stops firing leaves
    the incident closed for good. A stray keypress is not recoverable,
    so the prompt says what it means.
  - x refuses on an open incident rather than archiving it, since
    archiving unresolved work only hides it. Snooze is offered as the
    "not now" answer, and the client treats a snoozed_until in the past
    as not snoozed, matching the server, which sweeps nothing.

Statistics lead with MTTA and MTTR, neither of which was computable
before. The server sends null until something has actually been
acknowledged or resolved, and that renders as — rather than 0: no data
is a different claim from instant.

Alerts keep a tab of their own as the raw feed — useful for asking what
Alertmanager is really sending — with an Incident column replacing Ack
By, and i in the detail view jumping to the incident where something can
be done about it. Archived now holds archived incidents; archiving an
alert is server-side housekeeping and no longer a user action.

BREAKING CHANGE: requires terdut-server v0.4.0 or later. Against an
older server every incident request 404s. Use terdut-tui v0.3.x with
servers before v0.4.0.
This commit is contained in:
Niklas Ye
2026-07-30 21:54:54 +02:00
parent e04cfcf433
commit 1140d773f8
8 changed files with 1622 additions and 731 deletions
+407 -238
View File
@@ -10,7 +10,7 @@ import (
"github.com/yeniklas/terdut-tui/internal/api"
)
var sectionNames = []string{"Alerts", "Archived", "Schedule", "Users"}
var sectionNames = []string{"Incidents", "Alerts", "Archived", "Schedule", "Users"}
func (m Model) View() string {
if m.width == 0 {
@@ -27,11 +27,7 @@ func (m Model) View() string {
func (m Model) renderHeader() string {
title := styleHeader.Render("terdut-tui")
right := styleMuted.Render(m.serverURL)
gap := m.width - lipgloss.Width(title) - lipgloss.Width(right)
if gap < 0 {
gap = 0
}
return title + strings.Repeat(" ", gap) + right
return spread(title, right, m.width)
}
func (m Model) renderTabs() string {
@@ -57,13 +53,15 @@ func (m Model) renderBody() string {
}
switch m.mode {
case modeDetail:
case modeIncidentDetail, modeAlertDetail:
return m.renderDetail()
case modeComment:
return m.renderCommentCompose()
case modeConfirmDelete:
case modeNote:
return m.renderPrompt(styleHeader.Render("Note: ") + m.noteInput.View())
case modeSnooze:
return m.renderPrompt(styleHeader.Render("Snooze for: ") + m.snoozeInput.View())
case modeConfirm:
switch m.confirmTarget {
case confirmDeleteComment:
case confirmDeleteNote, confirmResolveIncident:
return m.renderDetail()
case confirmDeleteUser:
return m.renderUsers()
@@ -72,7 +70,7 @@ func (m Model) renderBody() string {
}
case modeStats:
return m.renderStats()
case modeScheduleUserPicker:
case modeUserPicker:
return m.renderUserPicker()
case modeUserCreate:
return m.renderUserCreate()
@@ -90,133 +88,103 @@ func (m Model) renderBody() string {
}
func (m Model) renderFooter() string {
switch m.mode {
case modeDetail:
var actions string
if m.activeSection == sectionArchived {
actions = styleFooter.Render(" x·unarchive c·comment [/]·select d·del S·stats esc·back")
} else {
actions = styleFooter.Render(" a·ack A·unack x·archive c·comment [/]·select d·del s·assign S·stats esc·back")
}
withStatus := func(actions string) string {
rendered := styleFooter.Render(actions)
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
return styleStatus.Render(" "+m.statusMsg) + "\n" + rendered
}
return "\n" + actions
return "\n" + rendered
}
case modeComment:
switch m.mode {
case modeIncidentDetail:
if !m.selectedIncident.IsOpen() {
return withStatus(" x·archive c·note [/]·select d·del S·stats esc·back")
}
return withStatus(" a·ack A·unack R·resolve s·assign z·snooze Z·unsnooze c·note [/]·select d·del S·stats esc·back")
case modeAlertDetail:
return withStatus(" i·open incident S·stats esc·back")
case modeNote:
return "\n" + styleFooter.Render(" enter·submit esc·cancel")
case modeConfirmDelete:
var deleteDesc string
switch m.confirmTarget {
case confirmDeleteComment:
if m.commentCursor >= 0 && m.commentCursor < len(m.comments) {
deleteDesc = fmt.Sprintf("comment by %s", m.comments[m.commentCursor].Username)
} else {
deleteDesc = "comment"
}
case confirmDeleteScheduleEntry:
if m.pendingDeleteEntry != nil {
deleteDesc = fmt.Sprintf("on-call for %s (%s)", m.pendingDeleteEntry.Date, m.pendingDeleteEntry.Username)
} else {
deleteDesc = "schedule entry"
}
case confirmDeleteUser:
deleteDesc = fmt.Sprintf("user %s (cascades all API keys)", m.selectedUser.Username)
}
return "\n" + styleError.Render(fmt.Sprintf(" Delete %s? [y/N]", deleteDesc))
case modeSnooze:
return "\n" + styleFooter.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)")
case modeConfirm:
return "\n" + styleError.Render(" "+m.confirmPrompt())
case modeStats:
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" esc·back")
}
return "\n" + styleFooter.Render(" esc·back")
return withStatus(" esc·back")
case modeScheduleUserPicker:
mode := "day"
if m.pickerAssignWeek {
mode = "week"
case modeUserPicker:
if m.pickerTarget == pickerIncidentAssignee {
return withStatus(" j/k·navigate enter·assign incident esc·cancel")
}
return "\n" + styleFooter.Render(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", mode))
scope := "day"
if m.pickerAssignWeek {
scope = "week"
}
return withStatus(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", scope))
case modeUserCreate:
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" tab·next field enter·create esc·cancel")
}
return "\n" + styleFooter.Render(" tab·next field enter·create esc·cancel")
return withStatus(" tab·next field enter·create esc·cancel")
case modeAPIKeyMenu:
return "\n" + styleFooter.Render(" n·new key r·revoke by ID esc·back")
return withStatus(" n·new key r·revoke by ID esc·back")
case modeAPIKeyCreate:
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" enter·create esc·back")
}
return "\n" + styleFooter.Render(" enter·create esc·back")
return withStatus(" enter·create esc·back")
case modeAPIKeyReveal:
footer := " c·copy to clipboard esc·done"
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(footer)
}
return "\n" + styleFooter.Render(footer)
return withStatus(" c·copy to clipboard esc·done")
case modeAPIKeyRevokeByID:
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" enter·revoke esc·back")
}
return "\n" + styleFooter.Render(" enter·revoke esc·back")
return withStatus(" enter·revoke esc·back")
default:
if m.activeSection == sectionArchived {
actions := styleFooter.Render(" x·unarchive enter·detail r·refresh tab·section q·quit")
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
}
return "\n" + actions
switch m.activeSection {
case sectionIncidents:
return withStatus(" enter·detail x·archive f·filter S·stats r·refresh tab·section q·quit")
case sectionAlerts:
return withStatus(" enter·detail f·filter S·stats r·refresh tab·section q·quit")
case sectionArchived:
return withStatus(" enter·detail x·unarchive r·refresh tab·section q·quit")
case sectionSchedule:
return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit")
case sectionUsers:
return withStatus(" n·new user d·delete k·API keys r·refresh tab·section q·quit")
}
if m.activeSection == sectionSchedule {
actions := styleFooter.Render(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit")
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
}
return "\n" + actions
}
if m.activeSection == sectionUsers {
actions := styleFooter.Render(" n·new user d·delete k·API keys r·refresh tab·section q·quit")
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
}
return "\n" + actions
}
var footerLeft string
if m.activeSection == sectionAlerts {
footerLeft = styleFooter.Render(" x·archive")
}
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
}
if footerLeft != "" {
gap := m.width - lipgloss.Width(footerLeft) - lipgloss.Width(helpView)
if gap < 0 {
gap = 0
}
return "\n" + footerLeft + strings.Repeat(" ", gap) + helpView
}
return "\n" + helpView
return "\n" + styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
}
}
func (m Model) confirmPrompt() string {
switch m.confirmTarget {
case confirmDeleteNote:
return "Delete this note? [y/N]"
case confirmResolveIncident:
// Manual resolution is terminal on the server, so say so before asking.
return "Resolve incident? This is final — a new occurrence opens a new incident. [y/N]"
case confirmDeleteScheduleEntry:
if m.pendingDeleteEntry != nil {
return fmt.Sprintf("Delete on-call for %s (%s)? [y/N]",
m.pendingDeleteEntry.Date, m.pendingDeleteEntry.Username)
}
return "Delete schedule entry? [y/N]"
case confirmDeleteUser:
return fmt.Sprintf("Delete user %s (cascades all API keys)? [y/N]", m.selectedUser.Username)
}
return "Are you sure? [y/N]"
}
// ── Dashboard ──────────────────────────────────────────────────────────────
func (m Model) renderDashboard() string {
switch m.activeSection {
case sectionIncidents:
return m.renderIncidents()
case sectionAlerts:
return m.renderAlerts()
case sectionArchived:
@@ -229,6 +197,90 @@ func (m Model) renderDashboard() string {
return ""
}
func (m Model) renderIncidents() string {
bar := m.renderIncidentStatsBar()
var content string
switch {
case m.loading && len(m.incidents) == 0:
content = styleMuted.Render(" Loading incidents…")
case len(m.incidents) == 0:
content = styleMuted.Render(
fmt.Sprintf(" No %s incidents.", filterLabel(m.incidentFilter)))
default:
content = m.incidentTable.View()
}
return lipgloss.JoinVertical(lipgloss.Left, bar, content)
}
func (m Model) renderAlerts() string {
bar := m.renderAlertStatsBar()
var content string
switch {
case m.loading && len(m.alerts) == 0:
content = styleMuted.Render(" Loading alerts…")
case len(m.alerts) == 0:
content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", filterLabel(m.alertFilter)))
default:
content = m.alertTable.View()
}
return lipgloss.JoinVertical(lipgloss.Left, bar, content)
}
func (m Model) renderArchived() string {
if m.archivedLoading {
return "\n" + styleMuted.Render(" Loading archived incidents…")
}
if len(m.archivedIncidents) == 0 {
return "\n" + styleMuted.Render(" No archived incidents.")
}
return "\n" + m.archivedTable.View()
}
func (m Model) renderIncidentStatsBar() string {
var triggered, acked, resolved int
mtta, mttr := "—", "—"
if m.incidentStats != nil {
triggered = m.incidentStats.Triggered
acked = m.incidentStats.Acknowledged
resolved = m.incidentStats.Resolved
mtta = humanSeconds(m.incidentStats.MTTASeconds)
mttr = humanSeconds(m.incidentStats.MTTRSeconds)
}
left := fmt.Sprintf(" %s %s %s %s",
styleTriggered.Render(fmt.Sprintf("Triggered: %d", triggered)),
styleAcknowledged.Render(fmt.Sprintf("Acked: %d", acked)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
styleMuted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)),
)
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.incidentFilter)))
return spread(left, right, m.width)
}
func (m Model) renderAlertStatsBar() string {
total, firing, resolved := 0, 0, 0
if m.alertStats != nil {
total = m.alertStats.Total
firing = m.alertStats.Firing
resolved = m.alertStats.Resolved
}
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(m.alertFilter)))
return spread(left, right, m.width)
}
// spread pushes left and right to the edges of a width.
func spread(left, right string, width int) string {
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 0 {
gap = 0
}
return left + strings.Repeat(" ", gap) + right
}
// ── Schedule ───────────────────────────────────────────────────────────────
func (m Model) renderSchedule() string {
@@ -236,7 +288,6 @@ func (m Model) renderSchedule() string {
return "\n" + styleMuted.Render(" Loading schedule…")
}
// On-call header
var onCallLine string
if m.currentOnCall != nil {
onCallLine = fmt.Sprintf(" On-call today: %s",
@@ -245,17 +296,12 @@ func (m Model) renderSchedule() string {
onCallLine = styleMuted.Render(" On-call today: nobody scheduled")
}
// Window label
from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 6)
windowLabel := styleMuted.Render(fmt.Sprintf(" %s — %s",
from.Format("Jan 02"), to.Format("Jan 02, 2006")))
gap := m.width - lipgloss.Width(onCallLine) - lipgloss.Width(windowLabel)
if gap < 0 {
gap = 0
}
header := "\n" + onCallLine + strings.Repeat(" ", gap) + windowLabel + "\n"
header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n"
return header + m.scheduleTable.View()
}
@@ -264,6 +310,12 @@ func (m Model) renderUserPicker() string {
return "\n" + styleMuted.Render(" Loading users…")
}
if m.pickerTarget == pickerIncidentAssignee {
header := fmt.Sprintf("\n Assign %s to:\n\n",
styleBold.Render(m.selectedIncident.Title))
return header + m.userPickerTable.View()
}
var scope string
cursor := m.scheduleTable.Cursor()
if cursor >= 0 && cursor < len(m.scheduleDays) {
@@ -278,85 +330,30 @@ func (m Model) renderUserPicker() string {
_, week := d.ISOWeek()
scope = fmt.Sprintf("week W%02d (%s–%s)",
week, monday.Format("Jan 02"), sunday.Format("Jan 02"))
} else if d.Format("2006-01-02") == time.Now().UTC().Format("2006-01-02") {
scope = "Today (" + d.Format("Mon") + ")"
} else {
if d.Format("2006-01-02") == time.Now().UTC().Format("2006-01-02") {
scope = "Today (" + d.Format("Mon") + ")"
} else {
scope = d.Format("Jan 02 (Mon)")
}
scope = d.Format("Jan 02 (Mon)")
}
}
header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n",
styleBold.Render(scope))
header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", styleBold.Render(scope))
return header + m.userPickerTable.View()
}
func (m Model) renderAlerts() string {
statsBar := m.renderStatsBar()
var content string
if m.loading && len(m.alerts) == 0 {
content = styleMuted.Render(" Loading alerts…")
} else if len(m.alerts) == 0 {
label := m.filterStatus
if label == "" {
label = "all"
}
content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", label))
} else {
content = m.alertTable.View()
}
return lipgloss.JoinVertical(lipgloss.Left, statsBar, content)
}
func (m Model) renderArchived() string {
if m.archivedLoading {
return styleMuted.Render(" Loading archived alerts…")
}
if len(m.archivedAlerts) == 0 {
return styleMuted.Render(" No archived alerts.")
}
return m.archivedTable.View()
}
func (m Model) renderStatsBar() string {
total, firing, resolved := 0, 0, 0
if m.stats != nil {
total = m.stats.Total
firing = m.stats.Firing
resolved = m.stats.Resolved
}
filterLabel := m.filterStatus
if filterLabel == "" {
filterLabel = "all"
}
left := fmt.Sprintf(" Total: %d %s %s",
total,
styleFiring.Render(fmt.Sprintf("Firing: %d", firing)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
)
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel))
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 0 {
gap = 0
}
return left + strings.Repeat(" ", gap) + right
}
// ── Detail ─────────────────────────────────────────────────────────────────
func (m Model) renderDetail() string {
if m.detailLoading {
return "\n" + styleMuted.Render(" Loading alert details…")
return "\n" + styleMuted.Render(" Loading…")
}
return m.detailViewport.View()
}
func (m Model) renderCommentCompose() string {
vp := m.detailViewport.View()
// renderPrompt puts an input line under the detail pane.
func (m Model) renderPrompt(prompt string) string {
sep := styleMuted.Render(strings.Repeat("─", m.width))
prompt := styleHeader.Render("Comment: ") + m.commentInput.View()
return vp + "\n" + sep + "\n" + prompt
return m.detailViewport.View() + "\n" + sep + "\n" + prompt
}
// ── Stats ──────────────────────────────────────────────────────────────────
@@ -370,12 +367,191 @@ func (m Model) renderStats() string {
// ── Content builders ───────────────────────────────────────────────────────
func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width int) string {
func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string {
now := time.Now()
var b strings.Builder
contentW := width - 4
// Title + status header
title := styleAlertName.Render(inc.Title)
status := incidentStatusStyle(inc.Status).Render(incidentStatusLabel(inc))
if inc.Severity != "" {
status += " " + severityStyle(inc.Severity).Render(strings.ToUpper(inc.Severity))
}
gap := contentW - lipgloss.Width(title) - lipgloss.Width(status)
if gap < 1 {
gap = 1
}
b.WriteString("\n " + title + strings.Repeat(" ", gap) + status + "\n\n")
// Timing and ownership
b.WriteString(fmt.Sprintf(" Triggered: %s (%s)\n",
inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt)))
if inc.AssignedTo != "" {
b.WriteString(fmt.Sprintf(" Assigned: %s\n", styleBold.Render(inc.AssignedTo)))
} else {
b.WriteString(styleMuted.Render(" Assigned: nobody\n"))
}
if inc.AcknowledgedByID != nil {
ackAt := ""
if inc.AcknowledgedAt != nil {
ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC")
}
b.WriteString(styleResolved.Render(
fmt.Sprintf(" Acked: %s%s\n", inc.AcknowledgedBy, ackAt)))
} else {
b.WriteString(styleMuted.Render(" Acked: not acknowledged\n"))
}
if inc.IsSnoozed() {
b.WriteString(styleSnoozed.Render(fmt.Sprintf(" Snoozed: until %s (%s)\n",
inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil))))
}
if inc.ResolvedAt != nil {
source := ""
if inc.ResolutionSource != nil {
source = " · " + *inc.ResolutionSource
}
b.WriteString(fmt.Sprintf(" Resolved: %s (%s)%s\n",
inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source))
}
if inc.ArchivedAt != nil {
b.WriteString(styleMuted.Render(" Archived: " +
inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC") + "\n"))
}
b.WriteString("\n")
// Group labels — the correlation Alertmanager applied.
if len(inc.GroupLabels) > 0 {
b.WriteString(divider("Grouped By", width))
for _, k := range sortedKeys(inc.GroupLabels) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(inc.GroupLabels[k], contentW-24)))
}
b.WriteString("\n")
}
// Member alerts
b.WriteString(divider(fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width))
if len(inc.Alerts) == 0 {
b.WriteString(styleMuted.Render(" No alerts.\n"))
} else {
for _, a := range inc.Alerts {
marker := styleFiring.Render("●")
if a.Status != "firing" {
marker = styleResolved.Render("✓")
}
instance := a.Labels["instance"]
if instance == "" {
instance = a.Fingerprint
}
b.WriteString(fmt.Sprintf(" %s %-28s %-26s %s\n",
marker, truncate(a.Name, 28), truncate(instance, 26),
styleMuted.Render("last seen "+humanAgo(now, a.ReceivedAt))))
}
}
b.WriteString("\n")
// Timeline — the only history the server keeps.
notes := noteEvents(timeline)
b.WriteString(divider(fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width))
if len(timeline) == 0 {
b.WriteString(styleMuted.Render(" Nothing recorded yet.\n"))
} else {
noteIndex := 0
for _, e := range timeline {
when := styleMuted.Render(humanAgo(now, e.CreatedAt))
if e.Type != api.EventNote {
b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when))
continue
}
marker := " "
author := styleBold.Render(e.Username)
if noteIndex == cursor {
marker = styleSelected.Render("> ")
author = styleSelected.Render(e.Username)
}
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when))
b.WriteString(" " + e.Detail + "\n")
noteIndex++
}
}
return b.String()
}
// incidentStatusLabel is the headline badge for an incident.
func incidentStatusLabel(inc api.Incident) string {
switch inc.Status {
case api.StatusTriggered:
if inc.IsSnoozed() {
return "● TRIGGERED (snoozed)"
}
return "● TRIGGERED"
case api.StatusAcknowledged:
if inc.IsSnoozed() {
return "◐ ACKNOWLEDGED (snoozed)"
}
return "◐ ACKNOWLEDGED"
case api.StatusResolved:
return "✓ RESOLVED"
default:
return strings.ToUpper(inc.Status)
}
}
// eventLabel renders one timeline entry as a sentence. Unrecognised types fall
// back to their raw name rather than vanishing — the server may add more.
func eventLabel(e api.IncidentEvent) string {
who := e.Username
switch e.Type {
case api.EventTriggered:
return " Incident opened"
case api.EventAlertAdded:
if e.AlertID != nil {
return fmt.Sprintf(" Alert #%d joined", *e.AlertID)
}
return " Alert joined"
case api.EventAlertResolved:
if e.AlertID != nil {
return fmt.Sprintf(" Alert #%d resolved", *e.AlertID)
}
return " Alert resolved"
case api.EventAcknowledged:
return " Acknowledged by " + who
case api.EventUnacknowledged:
return " Acknowledgement cleared by " + who
case api.EventAssigned:
// On an assigned event the user is the assignee, not the actor.
return " Assigned to " + who
case api.EventSnoozed:
if e.Detail != "" {
return " Snoozed until " + e.Detail
}
return " Snoozed"
case api.EventUnsnoozed:
return " Snooze cleared by " + who
case api.EventResolved:
if who != "" {
return " Resolved by " + who
}
return " Resolved (all alerts stopped firing)"
default:
label := " " + e.Type
if e.Detail != "" {
label += " · " + e.Detail
}
return label
}
}
func buildAlertDetailContent(alert api.Alert, 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" {
@@ -387,13 +563,12 @@ func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width i
}
statusStr = styleResolved.Render(label)
}
nameGap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
if nameGap < 1 {
nameGap = 1
gap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
if gap < 1 {
gap = 1
}
b.WriteString("\n " + name + strings.Repeat(" ", nameGap) + statusStr + "\n\n")
b.WriteString("\n " + name + strings.Repeat(" ", gap) + 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)))
b.WriteString(fmt.Sprintf(" Last Seen: %s (%s)\n",
@@ -402,74 +577,42 @@ func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width i
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(fmt.Sprintf(" Source: %s\n", truncate(alert.GeneratorURL, contentW-12)))
}
if alert.IncidentID != nil {
b.WriteString(fmt.Sprintf(" Incident: %s %s\n",
styleBold.Render(fmt.Sprintf("#%d", *alert.IncidentID)),
styleMuted.Render("press i to open it")))
} else {
b.WriteString(styleMuted.Render(" Incident: none\n"))
}
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(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Labels[k], contentW-24)))
}
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(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Annotations[k], contentW-24)))
}
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")
}
}
// Alerts carry no workflow state: it all lives on the incident.
b.WriteString(divider("", width))
b.WriteString(styleMuted.Render(
" Alerts are read-only — acknowledge, assign, note and resolve on the incident.\n"))
return b.String()
}
func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string {
func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string {
barWidth := width/2 - 10
if barWidth < 8 {
barWidth = 8
@@ -481,7 +624,29 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da
var b strings.Builder
b.WriteString("\n")
// Top alerts
// Response times first: they are what a rota is actually judged on.
b.WriteString(divider("Incident Response", width))
if incidents == nil {
b.WriteString(styleMuted.Render(" No data.\n"))
} else {
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total",
styleBold.Render(fmt.Sprintf("%d", incidents.Total))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Triggered",
styleTriggered.Render(fmt.Sprintf("%d", incidents.Triggered))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Acknowledged",
styleAcknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Resolved",
styleResolved.Render(fmt.Sprintf("%d", incidents.Resolved))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to acknowledge",
styleBold.Render(humanSeconds(incidents.MTTASeconds))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve",
styleBold.Render(humanSeconds(incidents.MTTRSeconds))))
if incidents.MTTASeconds == nil || incidents.MTTRSeconds == nil {
b.WriteString(styleMuted.Render(" (— means nothing has been acknowledged or resolved yet)\n"))
}
}
b.WriteString("\n")
b.WriteString(divider("Top Alerts", width))
if len(top) == 0 {
b.WriteString(styleMuted.Render(" No data.\n"))
@@ -494,7 +659,6 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da
}
b.WriteString("\n")
// By hour
b.WriteString(divider("Alerts by Hour (UTC)", width))
if len(byHour) > 0 {
maxCount := 0
@@ -512,7 +676,6 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da
}
b.WriteString("\n")
// By day
b.WriteString(divider("Alerts by Day", width))
if len(byDay) > 0 {
maxCount := 0
@@ -602,7 +765,10 @@ func (m Model) renderAPIKeyRevokeByID() string {
// ── Helpers ────────────────────────────────────────────────────────────────
func divider(title string, width int) string {
prefix := "── " + title + " "
prefix := "── "
if title != "" {
prefix += title + " "
}
remaining := width - len(prefix) - 2
if remaining > 0 {
prefix += strings.Repeat("─", remaining)
@@ -631,6 +797,9 @@ func renderBarWidth(count, maxCount, maxWidth int) int {
}
func truncate(s string, max int) string {
if max < 1 {
return ""
}
if len(s) <= max {
return s
}