4740687b96
Release / test (push) Failing after 6s
Release / build (amd64, darwin) (push) Has been skipped
Release / build (amd64, linux) (push) Has been skipped
Release / build (arm64, darwin) (push) Has been skipped
Release / build (arm64, linux) (push) Has been skipped
Release / release (push) Has been skipped
Stats was the one full-screen view reached by a key of its own rather than by tab, and the interface was less coherent for it. It is now a section sitting third, after Alerts, and behaves like every other one: tab in, tab out, r to refresh. Three things fall out of the move. It auto-refreshes for the first time — the tick handler skips every non-dashboard mode, which is why the overlay never updated while it was open. Its error path no longer forces the queue back into view on a failed fetch, an assumption that only made sense while stats floated above the dashboard. And first-visit loading keys off a statsLoaded flag rather than slice emptiness, because the three empty slices a quiet server returns are a real answer, not a missing one; the loading placeholder is likewise suppressed once something has been drawn, so a background refresh cannot blank the page out from under whoever is reading it. The S key is gone, and with it the ability to peek at statistics from an open incident and land back on it. That round-trip was the only thing statsReturnMode bought, and it was the whole reason stats needed a mode.
1002 lines
27 KiB
Go
1002 lines
27 KiB
Go
package tui
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/atotto/clipboard"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/yeniklas/terdut-tui/internal/api"
|
|
)
|
|
|
|
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.rebuildIncidentTable()
|
|
m.rebuildTable()
|
|
m.rebuildArchivedTable()
|
|
m.rebuildScheduleTable()
|
|
m.rebuildUserPickerTable()
|
|
m.rebuildUserManageTable()
|
|
m.detailViewport.Width = m.width
|
|
m.detailViewport.Height = m.detailViewportHeight()
|
|
m.statsViewport.Width = m.width
|
|
m.statsViewport.Height = m.statsViewportHeight()
|
|
m.refreshDetailContent()
|
|
m.refreshStatsContent()
|
|
return m, nil
|
|
|
|
// ── Dashboard messages ────────────────────────────────────────────────
|
|
|
|
case connectedMsg:
|
|
m.connected = true
|
|
m.err = nil
|
|
return m, tea.Batch(
|
|
tickCmd(m.refreshInterval),
|
|
fetchIncidentsCmd(m.client, m.incidentFilter),
|
|
fetchStatsCmd(m.client),
|
|
)
|
|
|
|
case connectErrMsg:
|
|
m.connected = false
|
|
m.err = msg.err
|
|
return m, nil
|
|
|
|
case incidentsFetchedMsg:
|
|
m.incidents = msg.incidents
|
|
m.loading = false
|
|
m.rebuildIncidentTable()
|
|
return m, nil
|
|
|
|
case archivedIncidentsFetchedMsg:
|
|
m.archivedIncidents = msg.incidents
|
|
m.archivedLoading = false
|
|
m.rebuildArchivedTable()
|
|
m.mode = modeDashboard
|
|
return m, nil
|
|
|
|
case incidentActionDoneMsg:
|
|
m.incidents = msg.incidents
|
|
m.loading = false
|
|
m.rebuildIncidentTable()
|
|
m.mode = modeDashboard
|
|
m.statusMsg = msg.status
|
|
return m, clearStatusCmd()
|
|
|
|
case alertsFetchedMsg:
|
|
m.alerts = msg.alerts
|
|
m.loading = false
|
|
m.rebuildTable()
|
|
return m, nil
|
|
|
|
case statsFetchedMsg:
|
|
m.incidentStats = &msg.incidents
|
|
m.alertStats = &msg.alerts
|
|
return m, nil
|
|
|
|
case fetchDataErrMsg:
|
|
m.loading = false
|
|
m.archivedLoading = false
|
|
m.statusMsg = "refresh error: " + msg.err.Error()
|
|
return m, clearStatusCmd()
|
|
|
|
case tickMsg:
|
|
return m, tea.Batch(tickCmd(m.refreshInterval), m.refreshActiveSection())
|
|
|
|
// ── Detail messages ───────────────────────────────────────────────────
|
|
|
|
case incidentDetailFetchedMsg:
|
|
m.selectedIncident = msg.incident
|
|
m.timeline = msg.timeline
|
|
m.detailLoading = false
|
|
if m.noteCursor >= len(noteEvents(m.timeline)) {
|
|
m.noteCursor = -1
|
|
}
|
|
m.refreshDetailContent()
|
|
return m, nil
|
|
|
|
case alertDetailFetchedMsg:
|
|
m.selectedAlert = msg.alert
|
|
m.detailLoading = false
|
|
m.refreshDetailContent()
|
|
return m, nil
|
|
|
|
case detailErrMsg:
|
|
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.statsLoaded = true
|
|
m.refreshStatsContent()
|
|
return m, nil
|
|
|
|
case detailStatsErrMsg:
|
|
m.statsLoading = false
|
|
// Mark it loaded even on failure, so tabbing back in does not re-fire the
|
|
// request every time. The tick and r still retry.
|
|
m.statsLoaded = true
|
|
m.statusMsg = "stats error: " + msg.err.Error()
|
|
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()
|
|
m.rebuildUserManageTable()
|
|
return m, nil
|
|
|
|
case apiKeyCreatedMsg:
|
|
m.revealedAPIKey = msg.key
|
|
m.mode = modeAPIKeyReveal
|
|
return m, nil
|
|
|
|
case apiKeyRevokedMsg:
|
|
m.statusMsg = "API key revoked"
|
|
m.mode = modeDashboard
|
|
return m, clearStatusCmd()
|
|
|
|
case userActionErrMsg:
|
|
m.usersLoading = false
|
|
m.statusMsg = "error: " + msg.err.Error()
|
|
m.mode = modeDashboard
|
|
return m, clearStatusCmd()
|
|
|
|
// ── Common ────────────────────────────────────────────────────────────
|
|
|
|
case clearStatusMsg:
|
|
m.statusMsg = ""
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
return m.routeKey(msg)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// refreshActiveSection reloads whatever the auto-refresh tick should keep fresh.
|
|
// Detail views reload too — an incident someone else acknowledged should stop
|
|
// looking unclaimed while you are staring at it.
|
|
func (m Model) refreshActiveSection() tea.Cmd {
|
|
switch m.mode {
|
|
case modeIncidentDetail:
|
|
return fetchIncidentDetailCmd(m.client, m.selectedIncident.ID)
|
|
case modeAlertDetail:
|
|
return fetchAlertDetailCmd(m.client, m.selectedAlert.ID)
|
|
case modeDashboard:
|
|
// fall through to the section refresh below
|
|
default:
|
|
// Modal states (compose, pickers, confirmations) are left alone: a
|
|
// refresh underneath a prompt would move the ground under the user.
|
|
return nil
|
|
}
|
|
|
|
switch m.activeSection {
|
|
case sectionIncidents:
|
|
return tea.Batch(fetchIncidentsCmd(m.client, m.incidentFilter), fetchStatsCmd(m.client))
|
|
case sectionAlerts:
|
|
return tea.Batch(fetchAlertsCmd(m.client, m.alertFilter), fetchStatsCmd(m.client))
|
|
case sectionStats:
|
|
// Both: fetchStatsCmd feeds the Incident Response block, the other the charts.
|
|
return tea.Batch(fetchStatsCmd(m.client), fetchDetailStatsCmd(m.client))
|
|
case sectionArchived:
|
|
return fetchArchivedIncidentsCmd(m.client)
|
|
case sectionSchedule:
|
|
return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
|
case sectionUsers:
|
|
return fetchUsersCmd(m.client)
|
|
}
|
|
return 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 modeIncidentDetail, modeAlertDetail:
|
|
var vpCmd tea.Cmd
|
|
m.detailViewport, vpCmd = m.detailViewport.Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(vpCmd, ourCmd)
|
|
|
|
case modeConfirm:
|
|
// No component to scroll; just handle y/n.
|
|
return m.handleKey(msg)
|
|
|
|
case modeNote:
|
|
var inputCmd tea.Cmd
|
|
m.noteInput, inputCmd = m.noteInput.Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(inputCmd, ourCmd)
|
|
|
|
case modeSnooze:
|
|
var inputCmd tea.Cmd
|
|
m.snoozeInput, inputCmd = m.snoozeInput.Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(inputCmd, ourCmd)
|
|
|
|
case modeUserPicker:
|
|
var tableCmd tea.Cmd
|
|
m.userPickerTable, tableCmd = m.userPickerTable.Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(tableCmd, ourCmd)
|
|
|
|
case modeUserCreate:
|
|
var inputCmd tea.Cmd
|
|
m.userFormInputs[m.userFormFocus], inputCmd = m.userFormInputs[m.userFormFocus].Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(inputCmd, ourCmd)
|
|
|
|
case modeAPIKeyCreate:
|
|
var inputCmd tea.Cmd
|
|
m.apiKeyNameInput, inputCmd = m.apiKeyNameInput.Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(inputCmd, ourCmd)
|
|
|
|
case modeAPIKeyRevokeByID:
|
|
var inputCmd tea.Cmd
|
|
m.apiKeyRevokeInput, inputCmd = m.apiKeyRevokeInput.Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(inputCmd, ourCmd)
|
|
|
|
case modeAPIKeyMenu, modeAPIKeyReveal:
|
|
return m.handleKey(msg)
|
|
|
|
default: // modeDashboard
|
|
if m.connected {
|
|
switch m.activeSection {
|
|
case sectionIncidents:
|
|
var tableCmd tea.Cmd
|
|
m.incidentTable, tableCmd = m.incidentTable.Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(tableCmd, ourCmd)
|
|
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 sectionStats:
|
|
var vpCmd tea.Cmd
|
|
m.statsViewport, vpCmd = m.statsViewport.Update(msg)
|
|
m2, ourCmd := m.handleKey(msg)
|
|
return m2, tea.Batch(vpCmd, ourCmd)
|
|
case sectionArchived:
|
|
var tableCmd tea.Cmd
|
|
m.archivedTable, tableCmd = m.archivedTable.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)
|
|
case sectionUsers:
|
|
var tableCmd tea.Cmd
|
|
m.userManageTable, tableCmd = m.userManageTable.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 modeIncidentDetail:
|
|
return m.handleIncidentDetailKey(msg)
|
|
case modeAlertDetail:
|
|
return m.handleAlertDetailKey(msg)
|
|
case modeNote:
|
|
return m.handleNoteKey(msg)
|
|
case modeSnooze:
|
|
return m.handleSnoozeKey(msg)
|
|
case modeConfirm:
|
|
return m.handleConfirmKey(msg)
|
|
case modeUserPicker:
|
|
return m.handleUserPickerKey(msg)
|
|
case modeUserCreate:
|
|
return m.handleUserCreateKey(msg)
|
|
case modeAPIKeyMenu:
|
|
return m.handleAPIKeyMenuKey(msg)
|
|
case modeAPIKeyCreate:
|
|
return m.handleAPIKeyCreateKey(msg)
|
|
case modeAPIKeyReveal:
|
|
return m.handleAPIKeyRevealKey(msg)
|
|
case modeAPIKeyRevokeByID:
|
|
return m.handleAPIKeyRevokeKey(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":
|
|
m.activeSection = section((int(m.activeSection) + 1) % sectionCount)
|
|
return m, m.loadSectionIfEmpty()
|
|
|
|
case "shift+tab":
|
|
m.activeSection = section((int(m.activeSection) + sectionCount - 1) % sectionCount)
|
|
return m, m.loadSectionIfEmpty()
|
|
|
|
case "r":
|
|
if !m.connected {
|
|
return m, connectCmd(m.client)
|
|
}
|
|
m.statusMsg = "Refreshing…"
|
|
return m, tea.Batch(m.refreshActiveSection(), clearStatusCmd())
|
|
|
|
case "f":
|
|
if !m.connected {
|
|
return m, nil
|
|
}
|
|
switch m.activeSection {
|
|
case sectionIncidents:
|
|
m.incidentFilter = nextFilter(incidentFilters, m.incidentFilter)
|
|
m.loading = true
|
|
return m, fetchIncidentsCmd(m.client, m.incidentFilter)
|
|
case sectionAlerts:
|
|
m.alertFilter = nextFilter(alertFilters, m.alertFilter)
|
|
m.loading = true
|
|
return m, fetchAlertsCmd(m.client, m.alertFilter)
|
|
}
|
|
return m, nil
|
|
|
|
case "enter":
|
|
switch m.activeSection {
|
|
case sectionIncidents:
|
|
if inc, ok := m.incidentAtCursor(); ok {
|
|
return m.openIncident(inc)
|
|
}
|
|
case sectionArchived:
|
|
if i := m.archivedTable.Cursor(); i >= 0 && i < len(m.archivedIncidents) {
|
|
return m.openIncident(m.archivedIncidents[i])
|
|
}
|
|
case sectionAlerts:
|
|
if i := m.alertTable.Cursor(); i >= 0 && i < len(m.alerts) {
|
|
m.selectedAlert = m.alerts[i]
|
|
m.mode = modeAlertDetail
|
|
m.detailLoading = true
|
|
m.detailViewport = viewport.New(m.width, m.detailViewportHeight())
|
|
return m, fetchAlertDetailCmd(m.client, m.selectedAlert.ID)
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case "x":
|
|
switch m.activeSection {
|
|
case sectionIncidents:
|
|
inc, ok := m.incidentAtCursor()
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
if inc.IsOpen() {
|
|
m.statusMsg = "resolve the incident before archiving it"
|
|
return m, clearStatusCmd()
|
|
}
|
|
return m, archiveIncidentCmd(m.client, inc.ID, m.incidentFilter)
|
|
case sectionArchived:
|
|
i := m.archivedTable.Cursor()
|
|
if i < 0 || i >= len(m.archivedIncidents) {
|
|
return m, nil
|
|
}
|
|
m.archivedLoading = true
|
|
return m, unarchiveIncidentCmd(m.client, m.archivedIncidents[i].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
|
|
return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
|
}
|
|
return m, nil
|
|
|
|
case "right", "l":
|
|
if m.activeSection == sectionSchedule {
|
|
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, 7)
|
|
m.scheduleLoading = true
|
|
return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
|
}
|
|
return m, nil
|
|
|
|
case "+":
|
|
if m.activeSection != sectionSchedule || !m.connected {
|
|
return m, nil
|
|
}
|
|
m.pickerAssignWeek = false
|
|
return m.openUserPicker(pickerSchedule)
|
|
|
|
case "W":
|
|
if m.activeSection != sectionSchedule || !m.connected {
|
|
return m, nil
|
|
}
|
|
m.pickerAssignWeek = true
|
|
return m.openUserPicker(pickerSchedule)
|
|
|
|
case "d":
|
|
switch m.activeSection {
|
|
case sectionSchedule:
|
|
if !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 = modeConfirm
|
|
case sectionUsers:
|
|
if !m.connected || len(m.users) == 0 {
|
|
return m, nil
|
|
}
|
|
cursor := m.userManageTable.Cursor()
|
|
if cursor >= len(m.users) {
|
|
return m, nil
|
|
}
|
|
m.selectedUser = m.users[cursor]
|
|
m.confirmTarget = confirmDeleteUser
|
|
m.mode = modeConfirm
|
|
}
|
|
return m, nil
|
|
|
|
case "n":
|
|
if m.activeSection != sectionUsers || !m.connected {
|
|
return m, nil
|
|
}
|
|
m.userFormInputs[0].Reset()
|
|
m.userFormInputs[1].Reset()
|
|
m.userFormFocus = 0
|
|
m.userFormInputs[0].Focus()
|
|
m.userFormInputs[1].Blur()
|
|
m.mode = modeUserCreate
|
|
return m, nil
|
|
|
|
case "k":
|
|
if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 {
|
|
return m, nil
|
|
}
|
|
cursor := m.userManageTable.Cursor()
|
|
if cursor >= len(m.users) {
|
|
return m, nil
|
|
}
|
|
m.selectedUser = m.users[cursor]
|
|
m.mode = modeAPIKeyMenu
|
|
return m, nil
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// loadSectionIfEmpty fetches a section's data the first time it is opened.
|
|
func (m *Model) loadSectionIfEmpty() tea.Cmd {
|
|
switch m.activeSection {
|
|
case sectionAlerts:
|
|
if len(m.alerts) == 0 {
|
|
m.loading = true
|
|
return fetchAlertsCmd(m.client, m.alertFilter)
|
|
}
|
|
case sectionStats:
|
|
if !m.statsLoaded {
|
|
m.statsLoading = true
|
|
return tea.Batch(fetchStatsCmd(m.client), fetchDetailStatsCmd(m.client))
|
|
}
|
|
case sectionArchived:
|
|
if len(m.archivedIncidents) == 0 {
|
|
m.archivedLoading = true
|
|
return fetchArchivedIncidentsCmd(m.client)
|
|
}
|
|
case sectionSchedule:
|
|
if len(m.scheduleDays) == 0 {
|
|
m.scheduleLoading = true
|
|
return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
|
}
|
|
case sectionUsers:
|
|
if len(m.users) == 0 {
|
|
m.usersLoading = true
|
|
return fetchUsersCmd(m.client)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m Model) incidentAtCursor() (api.Incident, bool) {
|
|
i := m.incidentTable.Cursor()
|
|
if i < 0 || i >= len(m.incidents) {
|
|
return api.Incident{}, false
|
|
}
|
|
return m.incidents[i], true
|
|
}
|
|
|
|
func (m Model) openIncident(inc api.Incident) (Model, tea.Cmd) {
|
|
m.selectedIncident = inc
|
|
m.mode = modeIncidentDetail
|
|
m.noteCursor = -1
|
|
m.detailLoading = true
|
|
m.detailViewport = viewport.New(m.width, m.detailViewportHeight())
|
|
return m, fetchIncidentDetailCmd(m.client, inc.ID)
|
|
}
|
|
|
|
func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
|
|
m.pickerTarget = target
|
|
m.mode = modeUserPicker
|
|
if len(m.users) == 0 {
|
|
m.usersLoading = true
|
|
return m, fetchUsersCmd(m.client)
|
|
}
|
|
m.rebuildUserPickerTable()
|
|
return m, nil
|
|
}
|
|
|
|
// nextFilter advances a filter cycle, wrapping at the end.
|
|
func nextFilter(cycle []string, current string) string {
|
|
for i, f := range cycle {
|
|
if f == current {
|
|
return cycle[(i+1)%len(cycle)]
|
|
}
|
|
}
|
|
return cycle[0]
|
|
}
|
|
|
|
// ── Incident detail ───────────────────────────────────────────────────────
|
|
|
|
func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
inc := m.selectedIncident
|
|
|
|
switch msg.String() {
|
|
case "esc", "backspace":
|
|
m.mode = modeDashboard
|
|
m.statusMsg = ""
|
|
return m, nil
|
|
|
|
case "a":
|
|
if !inc.IsOpen() {
|
|
return m.rejectClosed()
|
|
}
|
|
if inc.AcknowledgedByID != nil {
|
|
m.statusMsg = "already acknowledged by " + inc.AcknowledgedBy
|
|
return m, clearStatusCmd()
|
|
}
|
|
return m, acknowledgeIncidentCmd(m.client, inc.ID)
|
|
|
|
case "A":
|
|
if !inc.IsOpen() {
|
|
return m.rejectClosed()
|
|
}
|
|
if inc.AcknowledgedByID == nil {
|
|
m.statusMsg = "not acknowledged"
|
|
return m, clearStatusCmd()
|
|
}
|
|
return m, unacknowledgeIncidentCmd(m.client, inc.ID)
|
|
|
|
case "R":
|
|
if !inc.IsOpen() {
|
|
return m.rejectClosed()
|
|
}
|
|
m.confirmTarget = confirmResolveIncident
|
|
m.mode = modeConfirm
|
|
return m, nil
|
|
|
|
case "s":
|
|
if !inc.IsOpen() {
|
|
return m.rejectClosed()
|
|
}
|
|
return m.openUserPicker(pickerIncidentAssignee)
|
|
|
|
case "z":
|
|
if !inc.IsOpen() {
|
|
return m.rejectClosed()
|
|
}
|
|
m.mode = modeSnooze
|
|
m.snoozeInput.Reset()
|
|
m.snoozeInput.Focus()
|
|
m.detailViewport.Height = m.detailViewportHeight()
|
|
return m, nil
|
|
|
|
case "Z":
|
|
if !inc.IsOpen() {
|
|
return m.rejectClosed()
|
|
}
|
|
if !inc.IsSnoozed() {
|
|
m.statusMsg = "not snoozed"
|
|
return m, clearStatusCmd()
|
|
}
|
|
return m, unsnoozeIncidentCmd(m.client, inc.ID)
|
|
|
|
case "x":
|
|
if inc.IsOpen() {
|
|
m.statusMsg = "resolve the incident before archiving it"
|
|
return m, clearStatusCmd()
|
|
}
|
|
if inc.ArchivedAt != nil {
|
|
m.archivedLoading = true
|
|
m.mode = modeDashboard
|
|
return m, unarchiveIncidentCmd(m.client, inc.ID)
|
|
}
|
|
m.mode = modeDashboard
|
|
return m, archiveIncidentCmd(m.client, inc.ID, m.incidentFilter)
|
|
|
|
case "c":
|
|
m.mode = modeNote
|
|
m.noteInput.Reset()
|
|
m.noteInput.Focus()
|
|
m.detailViewport.Height = m.detailViewportHeight()
|
|
return m, nil
|
|
|
|
case "d":
|
|
notes := noteEvents(m.timeline)
|
|
if m.noteCursor < 0 || m.noteCursor >= len(notes) {
|
|
m.statusMsg = "select a note first with [ / ]"
|
|
return m, clearStatusCmd()
|
|
}
|
|
m.pendingDeleteID = notes[m.noteCursor].ID
|
|
m.confirmTarget = confirmDeleteNote
|
|
m.mode = modeConfirm
|
|
return m, nil
|
|
|
|
case "[":
|
|
return m.moveNoteCursor(-1), nil
|
|
|
|
case "]":
|
|
return m.moveNoteCursor(1), nil
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// rejectClosed reports why an action did nothing on a resolved incident. The
|
|
// server answers 409 anyway; saying so up front is friendlier than a round trip.
|
|
func (m Model) rejectClosed() (Model, tea.Cmd) {
|
|
m.statusMsg = "incident is resolved — a new occurrence opens a new incident"
|
|
return m, clearStatusCmd()
|
|
}
|
|
|
|
func (m Model) moveNoteCursor(delta int) Model {
|
|
notes := noteEvents(m.timeline)
|
|
if len(notes) == 0 {
|
|
return m
|
|
}
|
|
switch {
|
|
case m.noteCursor < 0:
|
|
if delta > 0 {
|
|
m.noteCursor = 0
|
|
} else {
|
|
m.noteCursor = len(notes) - 1
|
|
}
|
|
default:
|
|
m.noteCursor = (m.noteCursor + delta + len(notes)) % len(notes)
|
|
}
|
|
m.refreshDetailContent()
|
|
return m
|
|
}
|
|
|
|
// ── Alert detail (read-only) ──────────────────────────────────────────────
|
|
|
|
func (m Model) handleAlertDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc", "backspace":
|
|
m.mode = modeDashboard
|
|
m.statusMsg = ""
|
|
return m, nil
|
|
|
|
case "i":
|
|
// Jump to the incident this alert belongs to — the place where anything
|
|
// can actually be done about it.
|
|
if m.selectedAlert.IncidentID == nil {
|
|
m.statusMsg = "this alert has no incident"
|
|
return m, clearStatusCmd()
|
|
}
|
|
return m.openIncident(api.Incident{ID: *m.selectedAlert.IncidentID})
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// ── Note compose ──────────────────────────────────────────────────────────
|
|
|
|
func (m Model) handleNoteKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.mode = modeIncidentDetail
|
|
m.noteInput.Blur()
|
|
m.detailViewport.Height = m.detailViewportHeight()
|
|
return m, nil
|
|
|
|
case "enter":
|
|
content := strings.TrimSpace(m.noteInput.Value())
|
|
if content == "" {
|
|
return m, nil
|
|
}
|
|
m.mode = modeIncidentDetail
|
|
m.noteInput.Blur()
|
|
m.detailViewport.Height = m.detailViewportHeight()
|
|
return m, addNoteCmd(m.client, m.selectedIncident.ID, content)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// ── Snooze ────────────────────────────────────────────────────────────────
|
|
|
|
func (m Model) handleSnoozeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.mode = modeIncidentDetail
|
|
m.snoozeInput.Blur()
|
|
m.detailViewport.Height = m.detailViewportHeight()
|
|
return m, nil
|
|
|
|
case "enter":
|
|
duration := strings.TrimSpace(m.snoozeInput.Value())
|
|
if duration == "" {
|
|
return m, nil
|
|
}
|
|
m.mode = modeIncidentDetail
|
|
m.snoozeInput.Blur()
|
|
m.detailViewport.Height = m.detailViewportHeight()
|
|
return m, snoozeIncidentCmd(m.client, m.selectedIncident.ID, duration)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// ── Confirm ───────────────────────────────────────────────────────────────
|
|
|
|
func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
if strings.ToLower(msg.String()) != "y" {
|
|
switch m.confirmTarget {
|
|
case confirmDeleteNote, confirmResolveIncident:
|
|
m.mode = modeIncidentDetail
|
|
default:
|
|
m.mode = modeDashboard
|
|
}
|
|
m.pendingDeleteID = 0
|
|
m.pendingDeleteEntry = nil
|
|
return m, nil
|
|
}
|
|
|
|
switch m.confirmTarget {
|
|
case confirmDeleteNote:
|
|
incidentID := m.selectedIncident.ID
|
|
eventID := m.pendingDeleteID
|
|
m.mode = modeIncidentDetail
|
|
m.noteCursor = -1
|
|
m.pendingDeleteID = 0
|
|
return m, deleteNoteCmd(m.client, incidentID, eventID)
|
|
|
|
case confirmResolveIncident:
|
|
m.mode = modeIncidentDetail
|
|
return m, resolveIncidentCmd(m.client, m.selectedIncident.ID)
|
|
|
|
case confirmDeleteScheduleEntry:
|
|
entry := m.pendingDeleteEntry
|
|
m.mode = modeDashboard
|
|
m.pendingDeleteEntry = nil
|
|
m.scheduleLoading = true
|
|
return m, deleteScheduleEntryCmd(m.client, entry.ID,
|
|
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
|
|
|
case confirmDeleteUser:
|
|
userID := m.selectedUser.ID
|
|
m.mode = modeDashboard
|
|
m.usersLoading = true
|
|
return m, deleteUserCmd(m.client, userID)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// ── User picker ───────────────────────────────────────────────────────────
|
|
|
|
func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc":
|
|
if m.pickerTarget == pickerIncidentAssignee {
|
|
m.mode = modeIncidentDetail
|
|
} else {
|
|
m.mode = modeDashboard
|
|
}
|
|
return m, nil
|
|
|
|
case "enter":
|
|
cursor := m.userPickerTable.Cursor()
|
|
if cursor < 0 || cursor >= len(m.users) {
|
|
return m, nil
|
|
}
|
|
user := m.users[cursor]
|
|
|
|
if m.pickerTarget == pickerIncidentAssignee {
|
|
m.mode = modeIncidentDetail
|
|
return m, assignIncidentCmd(m.client, m.selectedIncident.ID, user.ID)
|
|
}
|
|
|
|
scheduleCursor := m.scheduleTable.Cursor()
|
|
if scheduleCursor >= len(m.scheduleDays) {
|
|
m.mode = modeDashboard
|
|
return m, nil
|
|
}
|
|
d := m.scheduleDays[scheduleCursor].date
|
|
var dates []string
|
|
if m.pickerAssignWeek {
|
|
weekday := int(d.Weekday())
|
|
if weekday == 0 {
|
|
weekday = 7 // ISO: Sunday = 7
|
|
}
|
|
monday := d.AddDate(0, 0, -(weekday - 1))
|
|
for i := 0; i < 7; i++ {
|
|
dates = append(dates, monday.AddDate(0, 0, i).Format("2006-01-02"))
|
|
}
|
|
} else {
|
|
dates = []string{d.Format("2006-01-02")}
|
|
}
|
|
m.mode = modeDashboard
|
|
m.scheduleLoading = true
|
|
return m, assignScheduleCmd(m.client, user.ID, dates,
|
|
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// ── User management ───────────────────────────────────────────────────────────
|
|
|
|
func (m Model) handleUserCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.userFormInputs[0].Blur()
|
|
m.userFormInputs[1].Blur()
|
|
m.mode = modeDashboard
|
|
return m, nil
|
|
|
|
case "tab", "shift+tab":
|
|
m.userFormInputs[m.userFormFocus].Blur()
|
|
m.userFormFocus = (m.userFormFocus + 1) % 2
|
|
m.userFormInputs[m.userFormFocus].Focus()
|
|
return m, nil
|
|
|
|
case "enter":
|
|
username := strings.TrimSpace(m.userFormInputs[0].Value())
|
|
email := strings.TrimSpace(m.userFormInputs[1].Value())
|
|
if username == "" || email == "" {
|
|
m.statusMsg = "username and email are required"
|
|
return m, clearStatusCmd()
|
|
}
|
|
m.userFormInputs[0].Blur()
|
|
m.userFormInputs[1].Blur()
|
|
m.mode = modeDashboard
|
|
m.usersLoading = true
|
|
return m, createUserCmd(m.client, username, email)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) handleAPIKeyMenuKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.mode = modeDashboard
|
|
return m, nil
|
|
|
|
case "n":
|
|
m.apiKeyNameInput.Reset()
|
|
m.apiKeyNameInput.Focus()
|
|
m.mode = modeAPIKeyCreate
|
|
return m, nil
|
|
|
|
case "r":
|
|
m.apiKeyRevokeInput.Reset()
|
|
m.apiKeyRevokeInput.Focus()
|
|
m.mode = modeAPIKeyRevokeByID
|
|
return m, nil
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) handleAPIKeyCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.apiKeyNameInput.Blur()
|
|
m.mode = modeAPIKeyMenu
|
|
return m, nil
|
|
|
|
case "enter":
|
|
name := strings.TrimSpace(m.apiKeyNameInput.Value())
|
|
if name == "" {
|
|
m.statusMsg = "key name is required"
|
|
return m, clearStatusCmd()
|
|
}
|
|
m.apiKeyNameInput.Blur()
|
|
m.mode = modeDashboard
|
|
return m, createAPIKeyCmd(m.client, m.selectedUser.ID, name)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) handleAPIKeyRevealKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "c":
|
|
if err := clipboard.WriteAll(m.revealedAPIKey.Key); err != nil {
|
|
m.statusMsg = "clipboard error: " + err.Error()
|
|
} else {
|
|
m.statusMsg = "copied to clipboard"
|
|
}
|
|
return m, clearStatusCmd()
|
|
|
|
case "esc", "enter", "q":
|
|
m.revealedAPIKey = api.APIKey{}
|
|
m.mode = modeDashboard
|
|
return m, nil
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) handleAPIKeyRevokeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.apiKeyRevokeInput.Blur()
|
|
m.mode = modeAPIKeyMenu
|
|
return m, nil
|
|
|
|
case "enter":
|
|
raw := strings.TrimSpace(m.apiKeyRevokeInput.Value())
|
|
keyID, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil || keyID <= 0 {
|
|
m.statusMsg = "invalid key ID — must be a positive integer"
|
|
return m, clearStatusCmd()
|
|
}
|
|
m.apiKeyRevokeInput.Blur()
|
|
m.mode = modeDashboard
|
|
return m, deleteAPIKeyCmd(m.client, m.selectedUser.ID, keyID)
|
|
}
|
|
|
|
return m, nil
|
|
}
|