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: switch m.confirmTarget { case confirmDeleteComment: return m.renderDetail() case confirmDeleteUser: return m.renderUsers() default: return m.renderSchedule() } case modeStats: return m.renderStats() case modeScheduleUserPicker: return m.renderUserPicker() case modeUserCreate: return m.renderUserCreate() case modeAPIKeyMenu: return m.renderAPIKeyMenu() case modeAPIKeyCreate: return m.renderAPIKeyCreate() case modeAPIKeyReveal: return m.renderAPIKeyReveal() case modeAPIKeyRevokeByID: return m.renderAPIKeyRevokeByID() 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" } case confirmDeleteUser: deleteDesc = fmt.Sprintf("user %s (cascades all API keys)", m.selectedUser.Username) } return "\n" + styleError.Render(fmt.Sprintf(" Delete %s? [y/N]", deleteDesc)) case modeStats: if m.statusMsg != "" { return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" esc·back") } return "\n" + styleFooter.Render(" esc·back") case modeScheduleUserPicker: mode := "day" if m.pickerAssignWeek { mode = "week" } return "\n" + styleFooter.Render(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", mode)) case modeUserCreate: if m.statusMsg != "" { return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" tab·next field enter·create esc·cancel") } return "\n" + styleFooter.Render(" tab·next field enter·create esc·cancel") case modeAPIKeyMenu: return "\n" + styleFooter.Render(" n·new key r·revoke by ID esc·back") case modeAPIKeyCreate: if m.statusMsg != "" { return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" enter·create esc·back") } return "\n" + styleFooter.Render(" enter·create esc·back") case modeAPIKeyReveal: footer := " c·copy to clipboard esc·done" if m.statusMsg != "" { return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(footer) } return "\n" + styleFooter.Render(footer) case modeAPIKeyRevokeByID: if m.statusMsg != "" { return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" enter·revoke esc·back") } return "\n" + styleFooter.Render(" enter·revoke esc·back") default: if m.activeSection == sectionSchedule { actions := styleFooter.Render(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit") if m.statusMsg != "" { return styleStatus.Render(" "+m.statusMsg) + "\n" + actions } return "\n" + actions } if m.activeSection == sectionUsers { actions := styleFooter.Render(" n·new user d·delete k·API keys r·refresh tab·section q·quit") if m.statusMsg != "" { return styleStatus.Render(" "+m.statusMsg) + "\n" + actions } return "\n" + actions } 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 m.renderUsers() } 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, 6) windowLabel := styleMuted.Render(fmt.Sprintf(" %s — %s", from.Format("Jan 02"), to.Format("Jan 02, 2006"))) gap := m.width - lipgloss.Width(onCallLine) - lipgloss.Width(windowLabel) if gap < 0 { gap = 0 } header := "\n" + onCallLine + strings.Repeat(" ", gap) + windowLabel + "\n" return header + m.scheduleTable.View() } func (m Model) renderUserPicker() string { if m.usersLoading { return "\n" + styleMuted.Render(" Loading users…") } 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", styleBold.Render(scope)) return header + m.userPickerTable.View() } func (m Model) renderAlerts() string { statsBar := m.renderStatsBar() var content string if m.loading && len(m.alerts) == 0 { content = styleMuted.Render(" Loading alerts…") } else if len(m.alerts) == 0 { label := m.filterStatus if label == "" { label = "all" } content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", label)) } else { content = m.alertTable.View() } return lipgloss.JoinVertical(lipgloss.Left, statsBar, content) } func (m Model) 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() } // ── Users ────────────────────────────────────────────────────────────────── func (m Model) renderUsers() string { if m.usersLoading { return "\n" + styleMuted.Render(" Loading users…") } if len(m.users) == 0 { return "\n" + styleMuted.Render(" No users found. Press n to create one.") } return "\n" + m.userManageTable.View() } func (m Model) renderUserCreate() string { header := "\n " + styleBold.Render("Create new user") + "\n\n" usernameLabel := " Username: " emailLabel := " Email: " if m.userFormFocus == 0 { usernameLabel = styleSelected.Render(" Username: ") } else { emailLabel = styleSelected.Render(" Email: ") } return header + usernameLabel + m.userFormInputs[0].View() + "\n" + emailLabel + m.userFormInputs[1].View() + "\n" } func (m Model) renderAPIKeyMenu() string { header := fmt.Sprintf("\n API keys for %s\n", styleBold.Render(m.selectedUser.Username)) warning := styleMuted.Render(" Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.\n") options := "\n" + styleAccent.Render(" n") + " · create a new API key\n" + styleAccent.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", styleBold.Render(m.selectedUser.Username)) label := styleSelected.Render(" Key name: ") return header + label + m.apiKeyNameInput.View() + "\n" } func (m Model) renderAPIKeyReveal() string { sep := styleMuted.Render(strings.Repeat("─", m.width)) warn := styleError.Render(" !! COPY NOW — this key will NEVER be shown again !!") nameLine := fmt.Sprintf(" Key name: %s", styleBold.Render(m.revealedAPIKey.Name)) idLine := fmt.Sprintf(" Key ID: %s %s", styleBold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)), styleMuted.Render("(save this — needed for future revocation)")) keyLine := styleResolved.Render(" " + m.revealedAPIKey.Key) return "\n" + sep + "\n\n" + warn + "\n\n" + nameLine + "\n" + idLine + "\n\n" + styleMuted.Render(" Key value:") + "\n" + keyLine + "\n\n" + sep + "\n" } func (m Model) renderAPIKeyRevokeByID() string { header := fmt.Sprintf("\n Revoke API key for %s\n", styleBold.Render(m.selectedUser.Username)) hint := styleMuted.Render(" Enter the integer key ID (shown when the key was created).\n") label := styleSelected.Render(" Key ID: ") return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n" } // ── 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] + "…" }