Files
terdut-tui/internal/tui/view.go
T
Niklas Ye ba7a862789 feat: Stage 4 — on-call schedule calendar view
Adds a 14-day schedule list accessible via the Schedule tab.

Keybindings in schedule section:
  ←/→ (or h/l)  shift the 2-week window back/forward by one week
  j/k            navigate rows
  +              open user picker — select a user to assign to the date
  d              delete the selected day's assignment (y/N confirmation)
  r              refresh schedule from server

The user picker fetches the user list from the server on first open
(cached for the session). Selecting a user assigns them to the
highlighted date (POST /api/schedule all-or-nothing; 409 conflicts
surface as a status bar error).

The on-call header shows today's scheduled person and the current
window date range. On-call data refreshes on schedule actions.

API additions: GetSchedule, GetCurrentOnCall, AssignSchedule,
DeleteScheduleEntry, ListUsers. ScheduleEntry and User types added.
2026-05-21 13:56:57 +02:00

464 lines
13 KiB
Go

package tui
import (
"fmt"
"sort"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api"
)
var sectionNames = []string{"Alerts", "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 := styleHeader.Render("terdut-tui")
right := styleMuted.Render(m.serverURL)
gap := m.width - lipgloss.Width(title) - lipgloss.Width(right)
if gap < 0 {
gap = 0
}
return title + strings.Repeat(" ", gap) + right
}
func (m Model) renderTabs() string {
var tabs []string
for i, name := range sectionNames {
if section(i) == m.activeSection {
tabs = append(tabs, styleTabActive.Render(name))
} else {
tabs = append(tabs, styleTabInactive.Render(name))
}
}
sep := styleMuted.Render(strings.Repeat("─", m.width))
return strings.Join(tabs, "") + "\n" + sep
}
func (m Model) renderBody() string {
if m.err != nil {
return "\n" + styleError.Render(fmt.Sprintf(" Error: %v", m.err)) +
"\n" + styleMuted.Render(" Press r to retry.")
}
if !m.connected {
return "\n" + styleMuted.Render(" Connecting…")
}
switch m.mode {
case modeDetail:
return m.renderDetail()
case modeComment:
return m.renderCommentCompose()
case modeConfirmDelete:
if m.confirmTarget == confirmDeleteComment {
return m.renderDetail()
}
return m.renderSchedule()
case modeStats:
return m.renderStats()
case modeScheduleUserPicker:
return m.renderUserPicker()
default:
return m.renderDashboard()
}
}
func (m Model) renderFooter() string {
switch m.mode {
case modeDetail:
actions := styleFooter.Render(" a·ack A·unack c·comment [/]·select d·del s·assign S·stats esc·back")
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
}
return "\n" + actions
case modeComment:
return "\n" + styleFooter.Render(" enter·submit esc·cancel")
case modeConfirmDelete:
var deleteDesc string
switch m.confirmTarget {
case confirmDeleteComment:
if m.commentCursor >= 0 && m.commentCursor < len(m.comments) {
deleteDesc = fmt.Sprintf("comment by %s", m.comments[m.commentCursor].Username)
} else {
deleteDesc = "comment"
}
case confirmDeleteScheduleEntry:
if m.pendingDeleteEntry != nil {
deleteDesc = fmt.Sprintf("on-call for %s (%s)", m.pendingDeleteEntry.Date, m.pendingDeleteEntry.Username)
} else {
deleteDesc = "schedule entry"
}
}
return "\n" + styleError.Render(fmt.Sprintf(" Delete %s? [y/N]", deleteDesc))
case modeStats:
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" esc·back")
}
return "\n" + styleFooter.Render(" esc·back")
case modeScheduleUserPicker:
return "\n" + styleFooter.Render(" j/k·navigate enter·select esc·cancel")
default:
if m.activeSection == sectionSchedule {
actions := styleFooter.Render(" +·assign d·del ←/→·shift week tab·section r·refresh q·quit")
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
}
return "\n" + actions
}
helpView := styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
if m.statusMsg != "" {
status := styleStatus.Render(m.statusMsg)
gap := m.width - lipgloss.Width(status) - lipgloss.Width(helpView)
if gap < 0 {
gap = 0
}
return "\n" + status + strings.Repeat(" ", gap) + helpView
}
return "\n" + helpView
}
}
// ── Dashboard ──────────────────────────────────────────────────────────────
func (m Model) renderDashboard() string {
switch m.activeSection {
case sectionAlerts:
return m.renderAlerts()
case sectionSchedule:
return m.renderSchedule()
case sectionUsers:
return "\n" + styleMuted.Render(" User management — coming in Stage 5")
}
return ""
}
// ── Schedule ───────────────────────────────────────────────────────────────
func (m Model) renderSchedule() string {
if m.scheduleLoading {
return "\n" + styleMuted.Render(" Loading schedule…")
}
// On-call header
var onCallLine string
if m.currentOnCall != nil {
onCallLine = fmt.Sprintf(" On-call today: %s",
styleAlertName.Render(m.currentOnCall.Username))
} else {
onCallLine = styleMuted.Render(" On-call today: nobody scheduled")
}
// Window label
from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 13)
windowLabel := styleMuted.Render(fmt.Sprintf(" %s — %s",
from.Format("Jan 02"), to.Format("Jan 02, 2006")))
gap := m.width - lipgloss.Width(onCallLine) - lipgloss.Width(windowLabel)
if gap < 0 {
gap = 0
}
header := "\n" + onCallLine + strings.Repeat(" ", gap) + windowLabel + "\n"
return header + m.scheduleTable.View()
}
func (m Model) renderUserPicker() string {
if m.usersLoading {
return "\n" + styleMuted.Render(" Loading users…")
}
selectedDate := ""
cursor := m.scheduleTable.Cursor()
if cursor < len(m.scheduleDays) {
d := m.scheduleDays[cursor]
if d.date.Format("2006-01-02") == time.Now().UTC().Format("2006-01-02") {
selectedDate = "Today (" + d.date.Format("Mon") + ")"
} else {
selectedDate = d.date.Format("Jan 02 (Mon)")
}
}
header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n",
styleBold.Render(selectedDate))
return header + m.userPickerTable.View()
}
func (m Model) renderAlerts() string {
statsBar := m.renderStatsBar()
var content string
if m.loading && len(m.alerts) == 0 {
content = styleMuted.Render(" Loading alerts…")
} else if len(m.alerts) == 0 {
label := m.filterStatus
if label == "" {
label = "all"
}
content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", label))
} else {
content = m.alertTable.View()
}
return lipgloss.JoinVertical(lipgloss.Left, statsBar, content)
}
func (m Model) renderStatsBar() string {
total, firing, resolved := 0, 0, 0
if m.stats != nil {
total = m.stats.Total
firing = m.stats.Firing
resolved = m.stats.Resolved
}
filterLabel := m.filterStatus
if filterLabel == "" {
filterLabel = "all"
}
left := fmt.Sprintf(" Total: %d %s %s",
total,
styleFiring.Render(fmt.Sprintf("Firing: %d", firing)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
)
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel))
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 0 {
gap = 0
}
return left + strings.Repeat(" ", gap) + right
}
// ── Detail ─────────────────────────────────────────────────────────────────
func (m Model) renderDetail() string {
if m.detailLoading {
return "\n" + styleMuted.Render(" Loading alert details…")
}
return m.detailViewport.View()
}
func (m Model) renderCommentCompose() string {
vp := m.detailViewport.View()
sep := styleMuted.Render(strings.Repeat("─", m.width))
prompt := styleHeader.Render("Comment: ") + m.commentInput.View()
return vp + "\n" + sep + "\n" + prompt
}
// ── Stats ──────────────────────────────────────────────────────────────────
func (m Model) renderStats() string {
if m.statsLoading {
return "\n" + styleMuted.Render(" Loading statistics…")
}
return m.statsViewport.View()
}
// ── Content builders ───────────────────────────────────────────────────────
func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width int) string {
now := time.Now()
var b strings.Builder
contentW := width - 4
// Name + status header
name := styleAlertName.Render(alert.Name)
var statusStr string
if alert.Status == "firing" {
statusStr = styleFiring.Render("● FIRING")
} else {
statusStr = styleResolved.Render("✓ RESOLVED")
}
nameGap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
if nameGap < 1 {
nameGap = 1
}
b.WriteString("\n " + name + strings.Repeat(" ", nameGap) + statusStr + "\n\n")
// Timeline
b.WriteString(fmt.Sprintf(" Started: %s (%s)\n",
alert.StartsAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, alert.StartsAt)))
if alert.EndsAt != nil {
b.WriteString(fmt.Sprintf(" Ended: %s\n", alert.EndsAt.UTC().Format("2006-01-02 15:04 UTC")))
}
if alert.GeneratorURL != "" {
url := alert.GeneratorURL
if len(url) > contentW-12 {
url = url[:contentW-15] + "…"
}
b.WriteString(fmt.Sprintf(" Source: %s\n", url))
}
b.WriteString("\n")
// Labels
if len(alert.Labels) > 0 {
b.WriteString(divider("Labels", width))
for _, k := range sortedKeys(alert.Labels) {
v := alert.Labels[k]
if len(v) > contentW-24 {
v = v[:contentW-27] + "…"
}
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, v))
}
b.WriteString("\n")
}
// Annotations
if len(alert.Annotations) > 0 {
b.WriteString(divider("Annotations", width))
for _, k := range sortedKeys(alert.Annotations) {
v := alert.Annotations[k]
if len(v) > contentW-24 {
v = v[:contentW-27] + "…"
}
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, v))
}
b.WriteString("\n")
}
// Acknowledgement
b.WriteString(divider("Acknowledgement", width))
if alert.AcknowledgedByID != nil {
ackAt := ""
if alert.AcknowledgedAt != nil {
ackAt = " at " + alert.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC")
}
b.WriteString(styleResolved.Render(fmt.Sprintf(" ✓ Acknowledged by %s%s\n", alert.AcknowledgedBy, ackAt)))
} else {
b.WriteString(styleMuted.Render(" Not acknowledged\n"))
}
b.WriteString("\n")
// Comments
b.WriteString(divider(fmt.Sprintf("Comments (%d)", len(comments)), width))
if len(comments) == 0 {
b.WriteString(styleMuted.Render(" No comments yet.\n"))
} else {
for i, c := range comments {
prefix := " "
authorLine := fmt.Sprintf("%s%s • %s", prefix, styleBold.Render(c.Username), humanAgo(now, c.CreatedAt))
if i == cursor {
authorLine = styleSelected.Render("> ") + styleBold.Render(c.Username) +
styleMuted.Render(fmt.Sprintf(" • %s", humanAgo(now, c.CreatedAt)))
}
b.WriteString(authorLine + "\n")
b.WriteString(" " + c.Content + "\n\n")
}
}
return b.String()
}
func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string {
barWidth := width/2 - 10
if barWidth < 8 {
barWidth = 8
}
if barWidth > 40 {
barWidth = 40
}
var b strings.Builder
b.WriteString("\n")
// Top alerts
b.WriteString(divider("Top Alerts", width))
if len(top) == 0 {
b.WriteString(styleMuted.Render(" No data.\n"))
} else {
maxCount := top[0].Count
for i, a := range top {
bar := styleResolved.Render(strings.Repeat("█", renderBarWidth(a.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %2d. %-30s %s %d\n", i+1, truncate(a.Name, 30), bar, a.Count))
}
}
b.WriteString("\n")
// By hour
b.WriteString(divider("Alerts by Hour (UTC)", width))
if len(byHour) > 0 {
maxCount := 0
for _, h := range byHour {
if h.Count > maxCount {
maxCount = h.Count
}
}
for _, h := range byHour {
bar := styleFiring.Render(strings.Repeat("█", renderBarWidth(h.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %2dh %-*s %d\n", h.Hour, barWidth, bar, h.Count))
}
} else {
b.WriteString(styleMuted.Render(" No data.\n"))
}
b.WriteString("\n")
// By day
b.WriteString(divider("Alerts by Day", width))
if len(byDay) > 0 {
maxCount := 0
for _, d := range byDay {
if d.Count > maxCount {
maxCount = d.Count
}
}
for _, d := range byDay {
bar := styleAccent.Render(strings.Repeat("█", renderBarWidth(d.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %-4s %-*s %d\n", d.DayName[:3], barWidth, bar, d.Count))
}
} else {
b.WriteString(styleMuted.Render(" No data.\n"))
}
return b.String()
}
// ── Helpers ────────────────────────────────────────────────────────────────
func divider(title string, width int) string {
prefix := "── " + title + " "
remaining := width - len(prefix) - 2
if remaining > 0 {
prefix += strings.Repeat("─", remaining)
}
return styleMuted.Render(prefix) + "\n"
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func renderBarWidth(count, maxCount, maxWidth int) int {
if maxCount == 0 {
return 0
}
w := count * maxWidth / maxCount
if w == 0 && count > 0 {
w = 1
}
return w
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-1] + "…"
}