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) } // 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.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: return m.renderPrompt(m.styles.Header.Render("Note: ") + 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 [/]·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") default: switch m.activeSection { case sectionIncidents: return withStatus(" enter·detail x·archive f·filter " + m.teamHint() + "r·refresh tab·section 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 tab·section 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, 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") // 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 { 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) } 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)" 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 ": ", 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]) + "…" }