85ad2d65ee
Release / test (push) Failing after 6s
Release / release (push) Has been skipped
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
terdut-server pages the on-call person through ntfy, but none of it was reachable from here. A user's topic could only be set with curl, so a new user silently got no pages and quietly fell back to the shared fallback topic — which carries no Acknowledge button. And nothing said whether anybody had been paged at all. The Users section grows an Ntfy Topic column and t to edit it, prefilled with the current value. Submitting an empty field clears the topic rather than being rejected as a mistake: clearing is how somebody is taken off their own topic, and it is what the server means by an empty string. Nil and empty arrive as the same thing, because the server stores a blank topic as NULL, so User.Topic flattens the two instead of leaving every caller to. The incident timeline renders the server's notified and notify_failed events. No new fetch — the timeline endpoint already carried them, and unknown types already fell through to a generic label; this is about saying something useful. An event with no user means the fallback topic, not "the server acted", which is the difference between somebody having been paged and the rota having been empty. Both need terdut-server v0.6.0 or later, and the timeline entries a server newer than that. Against an older one the column stays empty and editing a topic reports the server's 404, which is the honest answer.
1049 lines
28 KiB
Go
1049 lines
28 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 modeUserNotifyEdit:
|
|
var inputCmd tea.Cmd
|
|
m.ntfyTopicInput, inputCmd = m.ntfyTopicInput.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 modeUserNotifyEdit:
|
|
return m.handleUserNotifyEditKey(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 "t":
|
|
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]
|
|
// Prefilled with what they have, so editing a topic does not mean
|
|
// retyping it, and clearing one is a deliberate wipe.
|
|
m.ntfyTopicInput.SetValue(m.selectedUser.Topic())
|
|
m.ntfyTopicInput.CursorEnd()
|
|
m.ntfyTopicInput.Focus()
|
|
m.mode = modeUserNotifyEdit
|
|
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
|
|
}
|
|
|
|
// handleUserNotifyEditKey edits one user's ntfy topic.
|
|
//
|
|
// Unlike the other forms here, an empty value is not a mistake to reject: it is
|
|
// how a topic is cleared, which the server accepts and treats as NULL.
|
|
func (m Model) handleUserNotifyEditKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.ntfyTopicInput.Blur()
|
|
m.mode = modeDashboard
|
|
return m, nil
|
|
|
|
case "enter":
|
|
topic := strings.TrimSpace(m.ntfyTopicInput.Value())
|
|
m.ntfyTopicInput.Blur()
|
|
m.mode = modeDashboard
|
|
m.usersLoading = true
|
|
return m, setUserNotifyTargetCmd(m.client, m.selectedUser.ID, topic)
|
|
}
|
|
|
|
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
|
|
}
|