ee25552a53
The sign-in screen asks the server how it can be signed in to (GET /api/auth/config) and offers what it finds: the password form, and "Sign in with <provider>" when the server can do a device login. The TUI shows a link and a short code, the person approves it in any browser, and the next poll hands over the ordinary session, so it works over SSH where no browser can be opened. The terminal never talks to the identity provider. The password form is hidden when the server has turned password login off. `auth: sso` in config.yaml starts the SSO login straight away, but not right after signing out, where that would sign the person straight back in; any other value is refused when the config is read. Polling honours the server's interval, backs off on slow_down, and gives up after repeated failures rather than retrying forever. A server without /api/auth/config answers 404 and is treated as passwords only, so the sign-in screen is the one it had. Needs terdut-server v0.29.0 for SSO.
1071 lines
35 KiB
Go
1071 lines
35 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")
|
||
}
|
||
if m.sso.active {
|
||
b.WriteString(m.renderSSOWait())
|
||
return b.String()
|
||
}
|
||
if m.offersPasswords() {
|
||
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")
|
||
} else {
|
||
b.WriteString(m.styles.Muted.Render(" This server signs in with "+m.ssoName()+".") + "\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")
|
||
}
|
||
if m.canSSO() && m.offersPasswords() {
|
||
b.WriteString("\n" + m.styles.Muted.Render(" or press ctrl+o to sign in with "+m.ssoName()) + "\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// renderSSOWait is the single sign-on screen: the link to open and the code to
|
||
// check against it, while the client waits for the approval.
|
||
func (m Model) renderSSOWait() string {
|
||
var b strings.Builder
|
||
l := m.sso.login
|
||
if l == nil {
|
||
b.WriteString(m.styles.Muted.Render(" Contacting the server…") + "\n")
|
||
return b.String()
|
||
}
|
||
b.WriteString(" " + m.styles.Header.Render("Sign in with "+m.ssoName()) + "\n\n")
|
||
b.WriteString(" Open this link in a browser, on any device, and approve the sign-in:\n\n")
|
||
b.WriteString(" " + m.styles.Accent.Render(l.VerificationURL) + "\n\n")
|
||
b.WriteString(" " + m.styles.Header.Render("Code: ") + m.styles.Bold.Render(l.UserCode) +
|
||
m.styles.Muted.Render(" it should match the code on that page") + "\n\n")
|
||
b.WriteString(m.styles.Muted.Render(fmt.Sprintf(" Waiting for approval… good for %d minutes", (l.ExpiresIn+59)/60)) + "\n")
|
||
return b.String()
|
||
}
|
||
|
||
// loginHelp is the sign-in footer, which depends on what the server offers.
|
||
func (m Model) loginHelp() string {
|
||
switch {
|
||
case m.sso.active:
|
||
return " esc·cancel"
|
||
case !m.offersPasswords():
|
||
if m.canSSO() {
|
||
return " enter·sign in with " + m.ssoName() + " esc·quit"
|
||
}
|
||
return " esc·quit"
|
||
case m.canSSO():
|
||
return " tab·next field enter·sign in ctrl+o·" + m.ssoName() + " esc·quit"
|
||
}
|
||
return " tab·next field enter·sign in esc·quit"
|
||
}
|
||
|
||
// 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(m.loginHelp())
|
||
|
||
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]) + "…"
|
||
}
|