ba7a862789
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.
499 lines
13 KiB
Go
499 lines
13 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.rebuildTable()
|
|
m.rebuildScheduleTable()
|
|
m.rebuildUserPickerTable()
|
|
m.detailViewport.Width = m.width
|
|
m.detailViewport.Height = m.detailViewportHeight()
|
|
m.statsViewport.Width = m.width
|
|
m.statsViewport.Height = m.height - 5
|
|
m.refreshDetailContent()
|
|
m.refreshStatsContent()
|
|
return m, nil
|
|
|
|
// ── Dashboard messages ────────────────────────────────────────────────
|
|
|
|
case connectedMsg:
|
|
m.connected = true
|
|
m.err = nil
|
|
return m, tea.Batch(
|
|
tickCmd(m.refreshInterval),
|
|
fetchAlertsCmd(m.client, m.filterStatus),
|
|
fetchStatsCmd(m.client),
|
|
)
|
|
|
|
case connectErrMsg:
|
|
m.connected = false
|
|
m.err = msg.err
|
|
return m, nil
|
|
|
|
case alertsFetchedMsg:
|
|
m.alerts = msg.alerts
|
|
m.loading = false
|
|
m.rebuildTable()
|
|
return m, nil
|
|
|
|
case statsFetchedMsg:
|
|
m.stats = &msg.stats
|
|
return m, nil
|
|
|
|
case fetchDataErrMsg:
|
|
m.statusMsg = "refresh error: " + msg.err.Error()
|
|
return m, clearStatusCmd()
|
|
|
|
case tickMsg:
|
|
return m, tea.Batch(
|
|
tickCmd(m.refreshInterval),
|
|
fetchAlertsCmd(m.client, m.filterStatus),
|
|
fetchStatsCmd(m.client),
|
|
)
|
|
|
|
// ── Detail messages ───────────────────────────────────────────────────
|
|
|
|
case alertDetailFetchedMsg:
|
|
m.selectedAlert = msg.alert
|
|
m.comments = msg.comments
|
|
m.detailLoading = false
|
|
if m.commentCursor >= len(m.comments) {
|
|
m.commentCursor = -1
|
|
}
|
|
m.refreshDetailContent()
|
|
return m, nil
|
|
|
|
case alertDetailErrMsg:
|
|
m.detailLoading = false
|
|
m.statusMsg = "error: " + msg.err.Error()
|
|
return m, clearStatusCmd()
|
|
|
|
case actionErrMsg:
|
|
m.statusMsg = "error: " + msg.err.Error()
|
|
return m, clearStatusCmd()
|
|
|
|
case detailStatsFetchedMsg:
|
|
m.topAlerts = msg.top
|
|
m.hourStats = msg.byHour
|
|
m.dayStats = msg.byDay
|
|
m.statsLoading = false
|
|
m.refreshStatsContent()
|
|
return m, nil
|
|
|
|
case detailStatsErrMsg:
|
|
m.statsLoading = false
|
|
m.statusMsg = "stats error: " + msg.err.Error()
|
|
m.mode = modeDetail
|
|
return m, clearStatusCmd()
|
|
|
|
// ── Schedule messages ─────────────────────────────────────────────────
|
|
|
|
case scheduleFetchedMsg:
|
|
m.scheduleEntries = msg.entries
|
|
m.currentOnCall = msg.current
|
|
m.scheduleDays = buildScheduleDays(m.scheduleWindow, msg.entries)
|
|
m.scheduleLoading = false
|
|
m.rebuildScheduleTable()
|
|
return m, nil
|
|
|
|
case scheduleFetchErrMsg:
|
|
m.scheduleLoading = false
|
|
m.statusMsg = "schedule error: " + msg.err.Error()
|
|
return m, clearStatusCmd()
|
|
|
|
case scheduleActionErrMsg:
|
|
m.scheduleLoading = false
|
|
m.statusMsg = "error: " + msg.err.Error()
|
|
return m, clearStatusCmd()
|
|
|
|
case usersFetchedMsg:
|
|
m.users = msg.users
|
|
m.usersLoading = false
|
|
m.rebuildUserPickerTable()
|
|
return m, nil
|
|
|
|
// ── Common ────────────────────────────────────────────────────────────
|
|
|
|
case clearStatusMsg:
|
|
m.statusMsg = ""
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
return m.routeKey(msg)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// routeKey passes the key to the active component then to our handler.
|
|
func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch m.mode {
|
|
case modeDetail:
|
|
var vpCmd tea.Cmd
|
|
m.detailViewport, vpCmd = m.detailViewport.Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(vpCmd, ourCmd)
|
|
|
|
case modeConfirmDelete:
|
|
// No component to scroll; just handle y/n.
|
|
return m.handleKey(msg)
|
|
|
|
case modeComment:
|
|
var inputCmd tea.Cmd
|
|
m.commentInput, inputCmd = m.commentInput.Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(inputCmd, ourCmd)
|
|
|
|
case modeStats:
|
|
var vpCmd tea.Cmd
|
|
m.statsViewport, vpCmd = m.statsViewport.Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(vpCmd, ourCmd)
|
|
|
|
case modeScheduleUserPicker:
|
|
var tableCmd tea.Cmd
|
|
m.userPickerTable, tableCmd = m.userPickerTable.Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(tableCmd, ourCmd)
|
|
|
|
default: // modeDashboard
|
|
if m.connected {
|
|
switch m.activeSection {
|
|
case sectionAlerts:
|
|
var tableCmd tea.Cmd
|
|
m.alertTable, tableCmd = m.alertTable.Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(tableCmd, ourCmd)
|
|
case sectionSchedule:
|
|
var tableCmd tea.Cmd
|
|
m.scheduleTable, tableCmd = m.scheduleTable.Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(tableCmd, ourCmd)
|
|
}
|
|
}
|
|
return m.handleKey(msg)
|
|
}
|
|
}
|
|
|
|
func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch m.mode {
|
|
case modeDetail:
|
|
return m.handleDetailKey(msg)
|
|
case modeComment:
|
|
return m.handleCommentKey(msg)
|
|
case modeConfirmDelete:
|
|
return m.handleConfirmKey(msg)
|
|
case modeStats:
|
|
return m.handleStatsKey(msg)
|
|
case modeScheduleUserPicker:
|
|
return m.handleUserPickerKey(msg)
|
|
default:
|
|
return m.handleDashboardKey(msg)
|
|
}
|
|
}
|
|
|
|
// ── Dashboard ─────────────────────────────────────────────────────────────
|
|
|
|
func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "q", "ctrl+c":
|
|
return m, tea.Quit
|
|
|
|
case "tab":
|
|
next := section((int(m.activeSection) + 1) % 3)
|
|
m.activeSection = next
|
|
if next == sectionSchedule && len(m.scheduleDays) == 0 {
|
|
m.scheduleLoading = true
|
|
from := m.scheduleWindow
|
|
to := m.scheduleWindow.AddDate(0, 0, 13)
|
|
return m, fetchScheduleCmd(m.client, from, to)
|
|
}
|
|
return m, nil
|
|
|
|
case "r":
|
|
if !m.connected {
|
|
return m, connectCmd(m.client)
|
|
}
|
|
if m.activeSection == sectionSchedule {
|
|
m.scheduleLoading = true
|
|
from := m.scheduleWindow
|
|
to := m.scheduleWindow.AddDate(0, 0, 13)
|
|
return m, fetchScheduleCmd(m.client, from, to)
|
|
}
|
|
m.statusMsg = "Refreshing…"
|
|
return m, tea.Batch(
|
|
fetchAlertsCmd(m.client, m.filterStatus),
|
|
fetchStatsCmd(m.client),
|
|
clearStatusCmd(),
|
|
)
|
|
|
|
case "f":
|
|
if m.activeSection != sectionAlerts || !m.connected {
|
|
return m, nil
|
|
}
|
|
switch m.filterStatus {
|
|
case "firing":
|
|
m.filterStatus = "resolved"
|
|
case "resolved":
|
|
m.filterStatus = ""
|
|
default:
|
|
m.filterStatus = "firing"
|
|
}
|
|
m.loading = true
|
|
return m, fetchAlertsCmd(m.client, m.filterStatus)
|
|
|
|
case "enter":
|
|
if m.activeSection == sectionAlerts && len(m.alerts) > 0 {
|
|
cursor := m.alertTable.Cursor()
|
|
if cursor < len(m.alerts) {
|
|
m.selectedAlert = m.alerts[cursor]
|
|
m.mode = modeDetail
|
|
m.commentCursor = -1
|
|
m.detailLoading = true
|
|
m.detailViewport = viewport.New(m.width, m.detailViewportHeight())
|
|
return m, fetchAlertDetailCmd(m.client, m.selectedAlert.ID)
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
// Schedule-specific keys
|
|
case "left", "h":
|
|
if m.activeSection == sectionSchedule {
|
|
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, -7)
|
|
m.scheduleLoading = true
|
|
from := m.scheduleWindow
|
|
to := m.scheduleWindow.AddDate(0, 0, 13)
|
|
return m, fetchScheduleCmd(m.client, from, to)
|
|
}
|
|
return m, nil
|
|
|
|
case "right", "l":
|
|
if m.activeSection == sectionSchedule {
|
|
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, 7)
|
|
m.scheduleLoading = true
|
|
from := m.scheduleWindow
|
|
to := m.scheduleWindow.AddDate(0, 0, 13)
|
|
return m, fetchScheduleCmd(m.client, from, to)
|
|
}
|
|
return m, nil
|
|
|
|
case "+":
|
|
if m.activeSection != sectionSchedule || !m.connected {
|
|
return m, nil
|
|
}
|
|
m.mode = modeScheduleUserPicker
|
|
if len(m.users) == 0 {
|
|
m.usersLoading = true
|
|
return m, fetchUsersCmd(m.client)
|
|
}
|
|
m.rebuildUserPickerTable()
|
|
return m, nil
|
|
|
|
case "d":
|
|
if m.activeSection != sectionSchedule || !m.connected {
|
|
return m, nil
|
|
}
|
|
cursor := m.scheduleTable.Cursor()
|
|
if cursor >= len(m.scheduleDays) {
|
|
return m, nil
|
|
}
|
|
day := m.scheduleDays[cursor]
|
|
if day.entry == nil {
|
|
m.statusMsg = "no assignment to delete on this date"
|
|
return m, clearStatusCmd()
|
|
}
|
|
m.pendingDeleteEntry = day.entry
|
|
m.confirmTarget = confirmDeleteScheduleEntry
|
|
m.mode = modeConfirmDelete
|
|
return m, nil
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// ── Detail ────────────────────────────────────────────────────────────────
|
|
|
|
func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc", "backspace":
|
|
m.mode = modeDashboard
|
|
m.statusMsg = ""
|
|
return m, nil
|
|
|
|
case "a":
|
|
if m.selectedAlert.AcknowledgedByID != nil {
|
|
m.statusMsg = "already acknowledged"
|
|
return m, clearStatusCmd()
|
|
}
|
|
return m, acknowledgeCmd(m.client, m.selectedAlert.ID)
|
|
|
|
case "A":
|
|
if m.selectedAlert.AcknowledgedByID == nil {
|
|
m.statusMsg = "not acknowledged"
|
|
return m, clearStatusCmd()
|
|
}
|
|
return m, unacknowledgeCmd(m.client, m.selectedAlert.ID)
|
|
|
|
case "c":
|
|
m.mode = modeComment
|
|
m.commentInput.Reset()
|
|
m.commentInput.Focus()
|
|
m.detailViewport.Height = m.detailViewportHeight()
|
|
return m, nil
|
|
|
|
case "d":
|
|
if m.commentCursor < 0 || m.commentCursor >= len(m.comments) {
|
|
m.statusMsg = "select a comment first with [ / ]"
|
|
return m, clearStatusCmd()
|
|
}
|
|
m.pendingDeleteID = m.comments[m.commentCursor].ID
|
|
m.confirmTarget = confirmDeleteComment
|
|
m.mode = modeConfirmDelete
|
|
return m, nil
|
|
|
|
case "s":
|
|
m.statusMsg = "assign not yet supported by server"
|
|
return m, clearStatusCmd()
|
|
|
|
case "S":
|
|
m.mode = modeStats
|
|
m.statsLoading = true
|
|
m.statsViewport = viewport.New(m.width, m.height-5)
|
|
return m, fetchDetailStatsCmd(m.client)
|
|
|
|
case "[":
|
|
if len(m.comments) == 0 {
|
|
return m, nil
|
|
}
|
|
if m.commentCursor <= 0 {
|
|
m.commentCursor = len(m.comments) - 1
|
|
} else {
|
|
m.commentCursor--
|
|
}
|
|
m.refreshDetailContent()
|
|
return m, nil
|
|
|
|
case "]":
|
|
if len(m.comments) == 0 {
|
|
return m, nil
|
|
}
|
|
if m.commentCursor >= len(m.comments)-1 {
|
|
m.commentCursor = 0
|
|
} else {
|
|
m.commentCursor++
|
|
}
|
|
m.refreshDetailContent()
|
|
return m, nil
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// ── Comment compose ───────────────────────────────────────────────────────
|
|
|
|
func (m Model) handleCommentKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.mode = modeDetail
|
|
m.commentInput.Blur()
|
|
m.detailViewport.Height = m.detailViewportHeight()
|
|
return m, nil
|
|
|
|
case "enter":
|
|
content := strings.TrimSpace(m.commentInput.Value())
|
|
if content == "" {
|
|
return m, nil
|
|
}
|
|
m.mode = modeDetail
|
|
m.commentInput.Blur()
|
|
m.detailViewport.Height = m.detailViewportHeight()
|
|
return m, addCommentCmd(m.client, m.selectedAlert.ID, content)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// ── Confirm delete ────────────────────────────────────────────────────────
|
|
|
|
func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch strings.ToLower(msg.String()) {
|
|
case "y":
|
|
switch m.confirmTarget {
|
|
case confirmDeleteComment:
|
|
alertID := m.selectedAlert.ID
|
|
commentID := m.pendingDeleteID
|
|
m.mode = modeDetail
|
|
m.commentCursor = -1
|
|
m.pendingDeleteID = 0
|
|
return m, deleteCommentCmd(m.client, alertID, commentID)
|
|
case confirmDeleteScheduleEntry:
|
|
entry := m.pendingDeleteEntry
|
|
m.mode = modeDashboard
|
|
m.pendingDeleteEntry = nil
|
|
m.scheduleLoading = true
|
|
from := m.scheduleWindow
|
|
to := m.scheduleWindow.AddDate(0, 0, 13)
|
|
return m, deleteScheduleEntryCmd(m.client, entry.ID, from, to)
|
|
}
|
|
default:
|
|
switch m.confirmTarget {
|
|
case confirmDeleteComment:
|
|
m.mode = modeDetail
|
|
case confirmDeleteScheduleEntry:
|
|
m.mode = modeDashboard
|
|
}
|
|
m.pendingDeleteID = 0
|
|
m.pendingDeleteEntry = nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// ── Stats ─────────────────────────────────────────────────────────────────
|
|
|
|
func (m Model) handleStatsKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
if msg.String() == "esc" {
|
|
m.mode = modeDetail
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// ── User picker ───────────────────────────────────────────────────────────
|
|
|
|
func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.mode = modeDashboard
|
|
return m, nil
|
|
|
|
case "enter":
|
|
cursor := m.userPickerTable.Cursor()
|
|
if cursor >= len(m.users) {
|
|
return m, nil
|
|
}
|
|
user := m.users[cursor]
|
|
scheduleCursor := m.scheduleTable.Cursor()
|
|
if scheduleCursor >= len(m.scheduleDays) {
|
|
m.mode = modeDashboard
|
|
return m, nil
|
|
}
|
|
date := m.scheduleDays[scheduleCursor].date.Format("2006-01-02")
|
|
from := m.scheduleWindow
|
|
to := m.scheduleWindow.AddDate(0, 0, 13)
|
|
m.mode = modeDashboard
|
|
m.scheduleLoading = true
|
|
return m, assignScheduleCmd(m.client, user.ID, date, from, to)
|
|
}
|
|
|
|
return m, nil
|
|
}
|