057302cb39
The incident view gets a "Seen before" section from the server's new /similar endpoint; an older server without it just shows nothing. C adds a note as the resolution note, alongside c for a plain note. Needs the server release that adds /similar. Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU
1026 lines
33 KiB
Go
1026 lines
33 KiB
Go
package tui
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||
"github.com/charmbracelet/lipgloss"
|
||
)
|
||
|
||
// Order must match the section constants — renderTabs indexes this by ordinal.
|
||
var sectionNames = []string{"Incidents", "Alerts", "Stats", "Archived", "Schedule", "Users"}
|
||
|
||
func (m Model) View() string {
|
||
if m.width == 0 {
|
||
return ""
|
||
}
|
||
return lipgloss.JoinVertical(lipgloss.Left,
|
||
m.renderHeader(),
|
||
m.renderTabs(),
|
||
m.renderBody(),
|
||
m.renderFooter(),
|
||
)
|
||
}
|
||
|
||
func (m Model) renderHeader() string {
|
||
title := m.styles.Header.Render("terdut-tui")
|
||
if len(m.teams) > 0 {
|
||
title += m.styles.Muted.Render(" team: " + m.activeTeamLabel())
|
||
}
|
||
right := m.styles.Muted.Render(m.serverURL)
|
||
return spread(title, right, m.width)
|
||
}
|
||
|
||
// renderLogin is the sign-in form. It is the whole body: nothing else is shown
|
||
// until the server has accepted a session.
|
||
func (m Model) renderLogin() string {
|
||
var b strings.Builder
|
||
b.WriteString("\n " + m.styles.Bold.Render("Sign in to "+m.serverURL) + "\n\n")
|
||
if m.loginNote != "" {
|
||
b.WriteString(m.styles.Status.Render(" "+m.loginNote) + "\n\n")
|
||
}
|
||
b.WriteString(" " + m.styles.Header.Render("Username: ") + m.loginInputs[loginUsername].View() + "\n")
|
||
b.WriteString(" " + m.styles.Header.Render("Password: ") + m.loginInputs[loginPassword].View() + "\n\n")
|
||
switch {
|
||
case m.loggingIn:
|
||
b.WriteString(m.styles.Muted.Render(" Signing in…") + "\n")
|
||
case m.loginErr != "":
|
||
b.WriteString(m.styles.Error.Render(" "+m.loginErr) + "\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// activeTeamLabel names what the lists are narrowed to.
|
||
func (m Model) activeTeamLabel() string {
|
||
if t, ok := m.activeTeam(); ok {
|
||
return t.Name
|
||
}
|
||
return "all"
|
||
}
|
||
|
||
func (m Model) renderTabs() string {
|
||
var tabs []string
|
||
for i, name := range sectionNames {
|
||
if section(i) == m.activeSection {
|
||
tabs = append(tabs, m.styles.TabActive.Render(name))
|
||
} else {
|
||
tabs = append(tabs, m.styles.TabInactive.Render(name))
|
||
}
|
||
}
|
||
sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
|
||
return strings.Join(tabs, "") + "\n" + sep
|
||
}
|
||
|
||
func (m Model) renderBody() string {
|
||
if m.mode == modeLogin {
|
||
return m.renderLogin()
|
||
}
|
||
if m.err != nil {
|
||
return "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) +
|
||
"\n" + m.styles.Muted.Render(" Press r to retry.")
|
||
}
|
||
if !m.connected {
|
||
return "\n" + m.styles.Muted.Render(" Connecting…")
|
||
}
|
||
|
||
switch m.mode {
|
||
case modeIncidentDetail, modeAlertDetail:
|
||
return m.renderDetail()
|
||
case modeNote:
|
||
label := "Note: "
|
||
if m.notePinned {
|
||
label = "What fixed it: "
|
||
}
|
||
return m.renderPrompt(m.styles.Header.Render(label) + m.noteInput.View())
|
||
case modeSnooze:
|
||
return m.renderPrompt(m.styles.Header.Render("Snooze for: ") + m.snoozeInput.View())
|
||
case modeConfirm:
|
||
switch m.confirmTarget {
|
||
case confirmDeleteNote, confirmResolveIncident:
|
||
return m.renderDetail()
|
||
case confirmDeleteUser:
|
||
return m.renderUsers()
|
||
default:
|
||
return m.renderSchedule()
|
||
}
|
||
case modeUserPicker:
|
||
return m.renderUserPicker()
|
||
case modeUserCreate:
|
||
return m.renderUserCreate()
|
||
case modeUserNotifyEdit:
|
||
return m.renderUserNotifyEdit()
|
||
case modeAPIKeyMenu:
|
||
return m.renderAPIKeyMenu()
|
||
case modeAPIKeyCreate:
|
||
return m.renderAPIKeyCreate()
|
||
case modeAPIKeyReveal:
|
||
return m.renderAPIKeyReveal()
|
||
case modeAPIKeyRevokeByID:
|
||
return m.renderAPIKeyRevokeByID()
|
||
case modePasswordSet:
|
||
return m.renderPasswordSet()
|
||
default:
|
||
return m.renderDashboard()
|
||
}
|
||
}
|
||
|
||
func (m Model) renderFooter() string {
|
||
withStatus := func(actions string) string {
|
||
rendered := m.styles.Footer.Render(actions)
|
||
if m.statusMsg != "" {
|
||
return m.styles.Status.Render(" "+m.statusMsg) + "\n" + rendered
|
||
}
|
||
return "\n" + rendered
|
||
}
|
||
|
||
switch m.mode {
|
||
case modeIncidentDetail:
|
||
if !m.selectedIncident.IsOpen() {
|
||
return withStatus(" x·archive c·note C·fix note [/]·select d·del esc·back")
|
||
}
|
||
return withStatus(" a·ack A·unack R·resolve s·assign z·snooze Z·unsnooze c·note [/]·select d·del esc·back")
|
||
|
||
case modeAlertDetail:
|
||
return withStatus(" i·open incident esc·back")
|
||
|
||
case modeNote:
|
||
return "\n" + m.styles.Footer.Render(" enter·submit esc·cancel")
|
||
|
||
case modeSnooze:
|
||
return "\n" + m.styles.Footer.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)")
|
||
|
||
case modeConfirm:
|
||
return "\n" + m.styles.Error.Render(" "+m.confirmPrompt())
|
||
|
||
case modeUserPicker:
|
||
if m.pickerTarget == pickerIncidentAssignee {
|
||
return withStatus(" j/k·navigate enter·assign incident esc·cancel")
|
||
}
|
||
scope := "day"
|
||
if m.pickerAssignWeek {
|
||
scope = "week"
|
||
}
|
||
return withStatus(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", scope))
|
||
|
||
case modeUserCreate:
|
||
return withStatus(" tab·next field enter·create esc·cancel")
|
||
|
||
case modeUserNotifyEdit:
|
||
return withStatus(" enter·save esc·cancel (empty clears the topic)")
|
||
|
||
case modeAPIKeyMenu:
|
||
return withStatus(" n·new key r·revoke by ID esc·back")
|
||
|
||
case modeAPIKeyCreate:
|
||
return withStatus(" enter·create esc·back")
|
||
|
||
case modeAPIKeyReveal:
|
||
return withStatus(" c·copy to clipboard esc·done")
|
||
|
||
case modeAPIKeyRevokeByID:
|
||
return withStatus(" enter·revoke esc·back")
|
||
|
||
case modePasswordSet:
|
||
return withStatus(" tab·next field enter·set password esc·cancel")
|
||
|
||
case modeLogin:
|
||
return "\n" + m.styles.Footer.Render(" tab·next field enter·sign in esc·quit")
|
||
|
||
default:
|
||
switch m.activeSection {
|
||
case sectionIncidents:
|
||
return withStatus(" enter·detail x·archive f·filter " + m.teamHint() + "r·refresh tab·section L·sign out q·quit")
|
||
case sectionAlerts:
|
||
return withStatus(" enter·detail f·filter " + m.teamHint() + "r·refresh tab·section q·quit")
|
||
case sectionStats:
|
||
return withStatus(" ↑/↓·scroll r·refresh tab·section q·quit")
|
||
case sectionArchived:
|
||
return withStatus(" enter·detail x·unarchive " + m.teamHint() + "r·refresh tab·section q·quit")
|
||
case sectionSchedule:
|
||
return withStatus(" +·assign day W·assign week d·del ←/→·shift week " + m.teamHint() + "tab·section r·refresh q·quit")
|
||
case sectionUsers:
|
||
return withStatus(" n·new user t·topic d·delete k·API keys p·password r·refresh L·sign out q·quit")
|
||
}
|
||
return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
|
||
}
|
||
}
|
||
|
||
// teamHint is the footer's team-switch key, shown only when there is a choice.
|
||
func (m Model) teamHint() string {
|
||
if len(m.teams) > 1 {
|
||
return "T·team "
|
||
}
|
||
return ""
|
||
}
|
||
|
||
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)
|
||
case confirmReassignSchedule:
|
||
if p := m.pendingAssign; p != nil {
|
||
return fmt.Sprintf("%s assigned to %s. Reassign to %s? [y/N]",
|
||
dayCount(len(p.taken), len(p.dates)), joinNames(p.holders), p.username)
|
||
}
|
||
return "Reassign these days? [y/N]"
|
||
}
|
||
return "Are you sure? [y/N]"
|
||
}
|
||
|
||
// dayCount phrases how much of an assignment is being taken from somebody. A
|
||
// single day says so plainly; a partial week says which part, because "3 of 7"
|
||
// is the difference between taking a shift and taking somebody's whole week.
|
||
func dayCount(taken, total int) string {
|
||
switch {
|
||
case total == 1:
|
||
return "This day is"
|
||
case taken == total:
|
||
return fmt.Sprintf("All %d days are", total)
|
||
default:
|
||
return fmt.Sprintf("%d of %d days are", taken, total)
|
||
}
|
||
}
|
||
|
||
// joinNames renders a list of people as prose.
|
||
func joinNames(names []string) string {
|
||
switch len(names) {
|
||
case 0:
|
||
return "somebody else"
|
||
case 1:
|
||
return names[0]
|
||
case 2:
|
||
return names[0] + " and " + names[1]
|
||
default:
|
||
return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
|
||
}
|
||
}
|
||
|
||
// ── Dashboard ──────────────────────────────────────────────────────────────
|
||
|
||
func (m Model) renderDashboard() string {
|
||
switch m.activeSection {
|
||
case sectionIncidents:
|
||
return m.renderIncidents()
|
||
case sectionAlerts:
|
||
return m.renderAlerts()
|
||
case sectionStats:
|
||
return m.renderStats()
|
||
case sectionArchived:
|
||
return m.renderArchived()
|
||
case sectionSchedule:
|
||
return m.renderSchedule()
|
||
case sectionUsers:
|
||
return m.renderUsers()
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (m Model) renderIncidents() string {
|
||
bar := m.renderIncidentStatsBar()
|
||
var content string
|
||
switch {
|
||
case m.loading && len(m.incidents) == 0:
|
||
content = m.styles.Muted.Render(" Loading incidents…")
|
||
case len(m.incidents) == 0:
|
||
content = m.styles.Muted.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 = m.styles.Muted.Render(" Loading alerts…")
|
||
case len(m.alerts) == 0:
|
||
content = m.styles.Muted.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" + m.styles.Muted.Render(" Loading archived incidents…")
|
||
}
|
||
if len(m.archivedIncidents) == 0 {
|
||
return "\n" + m.styles.Muted.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",
|
||
m.styles.Triggered.Render(fmt.Sprintf("Triggered: %d", triggered)),
|
||
m.styles.Acknowledged.Render(fmt.Sprintf("Acked: %d", acked)),
|
||
m.styles.Resolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
|
||
m.styles.Muted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)),
|
||
)
|
||
right := m.styles.Muted.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,
|
||
m.styles.Firing.Render(fmt.Sprintf("Firing: %d", firing)),
|
||
m.styles.Resolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
|
||
)
|
||
right := m.styles.Muted.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 {
|
||
if m.scheduleLoading {
|
||
return "\n" + m.styles.Muted.Render(" Loading schedule…")
|
||
}
|
||
|
||
team, ok := m.scheduleTeam()
|
||
if !ok {
|
||
return "\n" + m.styles.Muted.Render(" You are not in any team, so there is no schedule to show.")
|
||
}
|
||
|
||
var onCallLine string
|
||
if len(m.currentOnCall) > 0 {
|
||
onCallLine = " On-call today: " + m.styles.AlertName.Render(m.onCallNames())
|
||
} else {
|
||
onCallLine = m.styles.Muted.Render(" On-call today: nobody scheduled")
|
||
}
|
||
|
||
from := m.scheduleWindow
|
||
to := m.scheduleWindow.AddDate(0, 0, 6)
|
||
windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s: %s — %s",
|
||
team.Name, from.Format("Jan 02"), to.Format("Jan 02, 2006")))
|
||
|
||
header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n"
|
||
return header + m.scheduleTable.View()
|
||
}
|
||
|
||
// onCallNames lists who is on call today. With several teams each name carries
|
||
// its team, since one person per team is on call and "alice, bob" alone would
|
||
// not say whose.
|
||
func (m Model) onCallNames() string {
|
||
parts := make([]string, len(m.currentOnCall))
|
||
for i, e := range m.currentOnCall {
|
||
parts[i] = e.Username
|
||
if len(m.teams) > 1 && e.TeamName != "" {
|
||
parts[i] += " (" + e.TeamName + ")"
|
||
}
|
||
}
|
||
return strings.Join(parts, ", ")
|
||
}
|
||
|
||
func (m Model) renderUserPicker() string {
|
||
if m.usersLoading {
|
||
return "\n" + m.styles.Muted.Render(" Loading users…")
|
||
}
|
||
|
||
if m.pickerTarget == pickerIncidentAssignee {
|
||
header := fmt.Sprintf("\n Assign %s to:\n\n",
|
||
m.styles.Bold.Render(m.selectedIncident.Title))
|
||
return header + m.userPickerTable.View()
|
||
}
|
||
|
||
var scope string
|
||
cursor := m.scheduleTable.Cursor()
|
||
if cursor >= 0 && cursor < len(m.scheduleDays) {
|
||
d := m.scheduleDays[cursor].date
|
||
if m.pickerAssignWeek {
|
||
weekday := int(d.Weekday())
|
||
if weekday == 0 {
|
||
weekday = 7
|
||
}
|
||
monday := d.AddDate(0, 0, -(weekday - 1))
|
||
sunday := monday.AddDate(0, 0, 6)
|
||
_, 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 {
|
||
scope = d.Format("Jan 02 (Mon)")
|
||
}
|
||
}
|
||
|
||
header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", m.styles.Bold.Render(scope))
|
||
return header + m.userPickerTable.View()
|
||
}
|
||
|
||
// ── Detail ─────────────────────────────────────────────────────────────────
|
||
|
||
func (m Model) renderDetail() string {
|
||
if m.detailLoading {
|
||
return "\n" + m.styles.Muted.Render(" Loading…")
|
||
}
|
||
return m.detailViewport.View()
|
||
}
|
||
|
||
// renderPrompt puts an input line under the detail pane.
|
||
func (m Model) renderPrompt(prompt string) string {
|
||
sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
|
||
return m.detailViewport.View() + "\n" + sep + "\n" + prompt
|
||
}
|
||
|
||
// ── Stats ──────────────────────────────────────────────────────────────────
|
||
|
||
func (m Model) renderStats() string {
|
||
// Only announce loading before the first result: a background refresh must not
|
||
// blank the page out from under whoever is reading it.
|
||
if m.statsLoading && !m.statsLoaded {
|
||
return "\n" + m.styles.Muted.Render(" Loading statistics…")
|
||
}
|
||
return m.statsViewport.View()
|
||
}
|
||
|
||
// line renders s in a style and terminates it.
|
||
//
|
||
// The newline has to stay outside Render: lipgloss pads every line of a styled
|
||
// block out to its widest line, so a trailing newline inside the block produces
|
||
// a second line made entirely of padding, and whatever is written next starts
|
||
// after that padding instead of at the left margin.
|
||
func line(style lipgloss.Style, s string) string {
|
||
return style.Render(s) + "\n"
|
||
}
|
||
|
||
// ── Content builders ───────────────────────────────────────────────────────
|
||
|
||
func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.IncidentEvent, similar []api.SimilarIncident, cursor, width int) string {
|
||
now := time.Now()
|
||
var b strings.Builder
|
||
contentW := width - 4
|
||
|
||
// Title + status header
|
||
title := s.AlertName.Render(inc.Title)
|
||
status := s.IncidentStatus(inc.Status).Render(incidentStatusLabel(inc))
|
||
if inc.Severity != "" {
|
||
status += " " + s.Severity(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
|
||
if inc.TeamName != "" {
|
||
b.WriteString(fmt.Sprintf(" Team: %s\n", inc.TeamName))
|
||
}
|
||
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", s.Bold.Render(inc.AssignedTo)))
|
||
} else {
|
||
b.WriteString(line(s.Muted, " Assigned: nobody"))
|
||
}
|
||
|
||
if inc.AcknowledgedByID != nil {
|
||
ackAt := ""
|
||
if inc.AcknowledgedAt != nil {
|
||
ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC")
|
||
}
|
||
b.WriteString(line(s.Resolved,
|
||
fmt.Sprintf(" Acked: %s%s", inc.AcknowledgedBy, ackAt)))
|
||
} else {
|
||
b.WriteString(line(s.Muted, " Acked: not acknowledged"))
|
||
}
|
||
|
||
if inc.EscalationLevel > 0 {
|
||
b.WriteString(line(s.Snoozed, fmt.Sprintf(" Escalation: level %d", inc.EscalationLevel)))
|
||
}
|
||
|
||
if inc.IsSnoozed() {
|
||
b.WriteString(line(s.Snoozed, fmt.Sprintf(" Snoozed: until %s (%s)",
|
||
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(line(s.Muted, " Archived: "+
|
||
inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC")))
|
||
}
|
||
b.WriteString("\n")
|
||
|
||
// Group labels — the correlation Alertmanager applied.
|
||
if len(inc.GroupLabels) > 0 {
|
||
b.WriteString(divider(s, "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(s, fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width))
|
||
if len(inc.Alerts) == 0 {
|
||
b.WriteString(line(s.Muted, " No alerts."))
|
||
} else {
|
||
for _, a := range inc.Alerts {
|
||
marker := s.Firing.Render("●")
|
||
if a.Status != "firing" {
|
||
marker = s.Resolved.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),
|
||
s.Muted.Render("last seen "+humanAgo(now, a.ReceivedAt))))
|
||
}
|
||
}
|
||
b.WriteString("\n")
|
||
|
||
// Seen before: earlier incidents of the same kind that someone left notes on.
|
||
if len(similar) > 0 {
|
||
b.WriteString(divider(s, "Seen before", width))
|
||
for _, sim := range similar {
|
||
b.WriteString(fmt.Sprintf(" #%-6d %-44s %s\n", sim.ID, truncate(sim.Title, 44),
|
||
s.Muted.Render("resolved "+humanAgo(now, sim.ResolvedAt))))
|
||
for _, n := range sim.ResolutionNotes {
|
||
b.WriteString(" " + s.Resolved.Render("fixed: ") + n.Detail + "\n")
|
||
}
|
||
if len(sim.ResolutionNotes) == 0 {
|
||
b.WriteString(line(s.Muted, fmt.Sprintf(" %d note(s), no resolution note", sim.NoteCount)))
|
||
}
|
||
}
|
||
b.WriteString("\n")
|
||
}
|
||
|
||
// Timeline — the only history the server keeps.
|
||
notes := noteEvents(timeline)
|
||
b.WriteString(divider(s, fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width))
|
||
if len(timeline) == 0 {
|
||
b.WriteString(line(s.Muted, " Nothing recorded yet."))
|
||
} else {
|
||
noteIndex := 0
|
||
for _, e := range timeline {
|
||
when := s.Muted.Render(humanAgo(now, e.CreatedAt))
|
||
if e.Type != api.EventNote && e.Type != api.EventResolutionNote {
|
||
b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when))
|
||
continue
|
||
}
|
||
marker := " "
|
||
author := s.Bold.Render(e.Username)
|
||
if noteIndex == cursor {
|
||
marker = s.Selected.Render("> ")
|
||
author = s.Selected.Render(e.Username)
|
||
}
|
||
verb := " wrote"
|
||
if e.Type == api.EventResolutionNote {
|
||
verb = " noted the fix"
|
||
}
|
||
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+verb, 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)"
|
||
case api.EventNotified:
|
||
// An empty username here is not "the server acted": it means the page
|
||
// went to the shared fallback topic, so it belongs to nobody.
|
||
return fmt.Sprintf(" Notified %s%s", notifiedTarget(who), notifyKind(e.Detail))
|
||
case api.EventNotifyFailed:
|
||
// The detail is "<kind>: <reason>", and the reason is the point — it is
|
||
// the only thing that says why nobody's phone rang.
|
||
return truncate(fmt.Sprintf(" Notification to %s failed · %s",
|
||
notifiedTarget(who), e.Detail), 52)
|
||
case api.EventDeadmanSilent:
|
||
return " Dead man's switch went silent"
|
||
default:
|
||
label := " " + e.Type
|
||
if e.Detail != "" {
|
||
label += " · " + e.Detail
|
||
}
|
||
return label
|
||
}
|
||
}
|
||
|
||
// notifiedTarget names who a page reached. The server attaches no user when it
|
||
// published to the shared fallback topic, and saying so is the difference
|
||
// between "somebody was paged" and "the on-call rota was empty".
|
||
func notifiedTarget(username string) string {
|
||
if username == "" {
|
||
return "the fallback topic"
|
||
}
|
||
return username
|
||
}
|
||
|
||
// notifyKind renders the notification kind the server puts in Detail. It is an
|
||
// open set, so anything unrecognised is shown rather than dropped.
|
||
func notifyKind(detail string) string {
|
||
if detail == "" {
|
||
return ""
|
||
}
|
||
return " (" + detail + ")"
|
||
}
|
||
|
||
func buildAlertDetailContent(s Styles, alert api.Alert, width int) string {
|
||
now := time.Now()
|
||
var b strings.Builder
|
||
contentW := width - 4
|
||
|
||
name := s.AlertName.Render(alert.Name)
|
||
var statusStr string
|
||
if alert.Status == "firing" {
|
||
statusStr = s.Firing.Render("● FIRING")
|
||
} else {
|
||
label := "✓ RESOLVED"
|
||
if alert.ResolutionSource != nil {
|
||
label += " · " + *alert.ResolutionSource
|
||
}
|
||
statusStr = s.Resolved.Render(label)
|
||
}
|
||
gap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
|
||
if gap < 1 {
|
||
gap = 1
|
||
}
|
||
b.WriteString("\n " + name + strings.Repeat(" ", gap) + statusStr + "\n\n")
|
||
|
||
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",
|
||
alert.ReceivedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, alert.ReceivedAt)))
|
||
if alert.EndsAt != nil {
|
||
b.WriteString(fmt.Sprintf(" Ended: %s\n", alert.EndsAt.UTC().Format("2006-01-02 15:04 UTC")))
|
||
}
|
||
if alert.GeneratorURL != "" {
|
||
b.WriteString(fmt.Sprintf(" Source: %s\n", truncate(alert.GeneratorURL, contentW-12)))
|
||
}
|
||
if alert.IncidentID != nil {
|
||
b.WriteString(fmt.Sprintf(" Incident: %s %s\n",
|
||
s.Bold.Render(fmt.Sprintf("#%d", *alert.IncidentID)),
|
||
s.Muted.Render("press i to open it")))
|
||
} else {
|
||
b.WriteString(line(s.Muted, " Incident: none"))
|
||
}
|
||
b.WriteString("\n")
|
||
|
||
if len(alert.Labels) > 0 {
|
||
b.WriteString(divider(s, "Labels", width))
|
||
for _, k := range sortedKeys(alert.Labels) {
|
||
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Labels[k], contentW-24)))
|
||
}
|
||
b.WriteString("\n")
|
||
}
|
||
|
||
if len(alert.Annotations) > 0 {
|
||
b.WriteString(divider(s, "Annotations", width))
|
||
for _, k := range sortedKeys(alert.Annotations) {
|
||
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Annotations[k], contentW-24)))
|
||
}
|
||
b.WriteString("\n")
|
||
}
|
||
|
||
// Alerts carry no workflow state: it all lives on the incident.
|
||
b.WriteString(divider(s, "", width))
|
||
b.WriteString(line(s.Muted,
|
||
" Alerts are read-only — acknowledge, assign, note and resolve on the incident."))
|
||
|
||
return b.String()
|
||
}
|
||
|
||
func buildStatsContent(s Styles, incidents *api.IncidentStats, 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")
|
||
|
||
// Response times first: they are what a rota is actually judged on.
|
||
b.WriteString(divider(s, "Incident Response", width))
|
||
if incidents == nil {
|
||
b.WriteString(line(s.Muted, " No data."))
|
||
} else {
|
||
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total",
|
||
s.Bold.Render(fmt.Sprintf("%d", incidents.Total))))
|
||
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Triggered",
|
||
s.Triggered.Render(fmt.Sprintf("%d", incidents.Triggered))))
|
||
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Acknowledged",
|
||
s.Acknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged))))
|
||
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Resolved",
|
||
s.Resolved.Render(fmt.Sprintf("%d", incidents.Resolved))))
|
||
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to acknowledge",
|
||
s.Bold.Render(humanSeconds(incidents.MTTASeconds))))
|
||
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve",
|
||
s.Bold.Render(humanSeconds(incidents.MTTRSeconds))))
|
||
if incidents.MTTASeconds == nil || incidents.MTTRSeconds == nil {
|
||
b.WriteString(line(s.Muted, " (— means nothing has been acknowledged or resolved yet)"))
|
||
}
|
||
}
|
||
b.WriteString("\n")
|
||
|
||
b.WriteString(divider(s, "Top Alerts", width))
|
||
if len(top) == 0 {
|
||
b.WriteString(line(s.Muted, " No data."))
|
||
} else {
|
||
maxCount := top[0].Count
|
||
for i, a := range top {
|
||
bar := s.Resolved.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")
|
||
|
||
b.WriteString(divider(s, "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 := s.Firing.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(line(s.Muted, " No data."))
|
||
}
|
||
b.WriteString("\n")
|
||
|
||
b.WriteString(divider(s, "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 := s.Accent.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(line(s.Muted, " No data."))
|
||
}
|
||
|
||
return b.String()
|
||
}
|
||
|
||
// ── Users ──────────────────────────────────────────────────────────────────
|
||
|
||
func (m Model) renderUsers() string {
|
||
if m.usersLoading {
|
||
return "\n" + m.styles.Muted.Render(" Loading users…")
|
||
}
|
||
if len(m.users) == 0 {
|
||
return "\n" + m.styles.Muted.Render(" No users found. Press n to create one.")
|
||
}
|
||
return "\n" + m.userManageTable.View()
|
||
}
|
||
|
||
func (m Model) renderUserCreate() string {
|
||
header := "\n " + m.styles.Bold.Render("Create new user") + "\n\n"
|
||
usernameLabel := " Username: "
|
||
emailLabel := " Email: "
|
||
if m.userFormFocus == 0 {
|
||
usernameLabel = m.styles.Selected.Render(" Username: ")
|
||
} else {
|
||
emailLabel = m.styles.Selected.Render(" Email: ")
|
||
}
|
||
return header +
|
||
usernameLabel + m.userFormInputs[0].View() + "\n" +
|
||
emailLabel + m.userFormInputs[1].View() + "\n"
|
||
}
|
||
|
||
func (m Model) renderUserNotifyEdit() string {
|
||
header := fmt.Sprintf("\n Push notifications for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
|
||
hint := line(m.styles.Muted,
|
||
" The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+
|
||
" their incidents then page the server's shared fallback topic, which\n"+
|
||
" carries no Acknowledge button.")
|
||
label := m.styles.Selected.Render(" Topic: ")
|
||
return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n"
|
||
}
|
||
|
||
func (m Model) renderPasswordSet() string {
|
||
header := fmt.Sprintf("\n Web UI password for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
|
||
if m.pwLoading {
|
||
return header + line(m.styles.Muted, " Checking who this key belongs to…")
|
||
}
|
||
labels := [pwFieldCount]string{
|
||
pwCurrent: " Current password: ",
|
||
pwNew: " New password: ",
|
||
pwRepeat: " Repeat: ",
|
||
}
|
||
var form string
|
||
for _, f := range m.pwFields() {
|
||
label := labels[f]
|
||
if f == m.pwFocus {
|
||
label = m.styles.Selected.Render(label)
|
||
}
|
||
form += label + m.pwInputs[f].View() + "\n"
|
||
}
|
||
hint := fmt.Sprintf(" At least %d characters. Setting it signs %s out of every other\n web UI session. API keys are not affected.", minPasswordLen, m.selectedUser.Username)
|
||
return header + form + "\n" + line(m.styles.Muted, hint)
|
||
}
|
||
|
||
func (m Model) renderAPIKeyMenu() string {
|
||
header := fmt.Sprintf("\n API keys for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
|
||
warning := line(m.styles.Muted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.")
|
||
options := "\n" +
|
||
m.styles.Accent.Render(" n") + " · create a new API key\n" +
|
||
m.styles.Accent.Render(" r") + " · revoke a key by ID\n"
|
||
return header + "\n" + warning + options
|
||
}
|
||
|
||
func (m Model) renderAPIKeyCreate() string {
|
||
header := fmt.Sprintf("\n New API key for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
|
||
label := m.styles.Selected.Render(" Key name: ")
|
||
return header + label + m.apiKeyNameInput.View() + "\n"
|
||
}
|
||
|
||
func (m Model) renderAPIKeyReveal() string {
|
||
sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
|
||
warn := m.styles.Error.Render(" !! COPY NOW — this key will NEVER be shown again !!")
|
||
nameLine := fmt.Sprintf(" Key name: %s", m.styles.Bold.Render(m.revealedAPIKey.Name))
|
||
idLine := fmt.Sprintf(" Key ID: %s %s",
|
||
m.styles.Bold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)),
|
||
m.styles.Muted.Render("(save this — needed for future revocation)"))
|
||
|
||
keyLine := m.styles.Resolved.Render(" " + m.revealedAPIKey.Key)
|
||
|
||
return "\n" + sep + "\n\n" +
|
||
warn + "\n\n" +
|
||
nameLine + "\n" +
|
||
idLine + "\n\n" +
|
||
m.styles.Muted.Render(" Key value:") + "\n" +
|
||
keyLine + "\n\n" +
|
||
sep + "\n"
|
||
}
|
||
|
||
func (m Model) renderAPIKeyRevokeByID() string {
|
||
header := fmt.Sprintf("\n Revoke API key for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
|
||
hint := line(m.styles.Muted, " Enter the integer key ID (shown when the key was created).")
|
||
label := m.styles.Selected.Render(" Key ID: ")
|
||
return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n"
|
||
}
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
func divider(s Styles, title string, width int) string {
|
||
prefix := "── "
|
||
if title != "" {
|
||
prefix += title + " "
|
||
}
|
||
remaining := width - len(prefix) - 2
|
||
if remaining > 0 {
|
||
prefix += strings.Repeat("─", remaining)
|
||
}
|
||
return s.Muted.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
|
||
}
|
||
|
||
// truncate shortens s to max terminal cells, marking the cut with an ellipsis.
|
||
//
|
||
// Counted in runes rather than bytes: these strings are laid out against
|
||
// fixed-width columns, and a byte cut through a multi-byte rune would both
|
||
// mis-measure the column and emit a broken character. Server-supplied text —
|
||
// labels, annotations, delivery errors — is not guaranteed to be ASCII.
|
||
func truncate(s string, max int) string {
|
||
if max < 1 {
|
||
return ""
|
||
}
|
||
r := []rune(s)
|
||
if len(r) <= max {
|
||
return s
|
||
}
|
||
return string(r[:max-1]) + "…"
|
||
}
|