package tui import ( "errors" "fmt" "net/http" "slices" "strconv" "strings" "time" "git.ryuvia.com/niklas/terdut-tui/internal/api" "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" ) func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // A 401 from anything means the server no longer honours the session: it // expired, was ended from the web UI, or the account was disabled. Whatever // was being done cannot succeed, so go back to the sign-in form and say why, // rather than leaving every action to fail with "server returned 401". if err := msgError(msg); api.IsUnauthorized(err) && m.mode != modeLogin { m, entry := m.requireLogin("your session has ended — sign in again").enterLogin(true) return m, tea.Batch(forgetSessionCmd(), entry) } 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.rebuildTeamPickerTable() 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 loginDoneMsg: m.loggingIn = false m.sso = ssoLogin{attempt: m.sso.attempt + 1} m.loginErr = "" m.loginNote = "" m.loginInputs[loginPassword].Reset() m.blurLoginForm() m.mode = modeDashboard m.err = nil return m, connectCmd(m.client) case loginErrMsg: m.loggingIn = false m.loginErr = loginErrorText(msg.err) m.loginInputs[loginPassword].Reset() m.focusLogin(loginPassword) return m, nil case logoutDoneMsg: // Not auto-started even with auth: sso: somebody who has just signed out // did not ask to be signed straight back in. return m.requireLogin("you have signed out").enterLogin(false) case authConfigMsg: cfg := msg.cfg m.authInfo = &cfg if m.mode == modeLogin && m.autoStartsSSO() { return m.startSSO() } return m, nil case deviceStartedMsg: if !m.sso.current(msg.attempt) { return m, nil } login := msg.login m.sso.login = &login m.sso.interval = time.Duration(login.Interval) * time.Second if m.sso.interval <= 0 { m.sso.interval = defaultDevicePoll } return m, devicePollAfter(m.sso.attempt, m.sso.interval) case devicePollMsg: if !m.sso.current(msg.attempt) || m.sso.login == nil { return m, nil } return m, pollDeviceCmd(m.client, m.serverURL, m.sso.attempt, m.sso.login.DeviceCode) case devicePendingMsg: if !m.sso.current(msg.attempt) { return m, nil } if msg.err != nil { if m.sso.failures++; m.sso.failures >= maxPollFailures { return m.failSSO(msg.err), nil } } else { m.sso.failures = 0 } if msg.slower { m.sso.interval += defaultDevicePoll } return m, devicePollAfter(m.sso.attempt, m.sso.interval) case deviceFailedMsg: if !m.sso.current(msg.attempt) { return m, nil } return m.failSSO(msg.err), nil case connectedMsg: firstConnect := len(m.teams) == 0 m.connected = true m.err = nil m.teams = msg.teams m.meID = msg.me.User.ID m.isAdmin = msg.me.User.IsAdmin var statusCmd tea.Cmd if firstConnect && m.activeTeamID == 0 && m.defaultTeam != "" { if t, ok := resolveTeam(m.teams, m.defaultTeam); ok { m.activeTeamID = t.ID } else { m.statusMsg = fmt.Sprintf("team %q not found -- showing all teams", m.defaultTeam) statusCmd = clearStatusCmd() } } m.rebuildIncidentTable() m.rebuildTable() m.rebuildArchivedTable() var tick tea.Cmd if !m.ticking { m.ticking = true tick = tickCmd(m.refreshInterval) } return m, tea.Batch( tick, fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter), fetchStatsCmd(m.client), statusCmd, ) 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.similar = msg.similar 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 pickerReadyMsg: if m.mode != modeUserPicker { return m, nil // the picker was closed before the lookup came back } m.users = msg.users m.pickerMembers = msg.members 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 meFetchedMsg: if m.mode != modePasswordSet { return m, nil // the form was closed before the lookup came back } m.pwLoading = false m.pwNeedCurrent = msg.me.User.ID == m.selectedUser.ID && msg.me.HasPassword m.pwFocus = pwNew if m.pwNeedCurrent { m.pwFocus = pwCurrent } m.pwInputs[m.pwFocus].Focus() return m, nil case passwordSetMsg: m.statusMsg = "password set for " + msg.username + " -- their other web sessions were signed out" return m, clearStatusCmd() case userActionErrMsg: m.usersLoading = false m.pwLoading = false m.blurPasswordForm() 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.activeTeamID, m.incidentFilter), fetchStatsCmd(m.client)) case sectionAlerts: return tea.Batch(fetchAlertsCmd(m.client, m.activeTeamID, 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, m.activeTeamID) case sectionSchedule: return m.fetchScheduleWindowCmd() case sectionUsers: return fetchUsersCmd(m.client) } return nil } // fetchScheduleWindowCmd reloads the schedule window for the schedule's team, or // does nothing when the caller belongs to none. func (m Model) fetchScheduleWindowCmd() tea.Cmd { team, ok := m.scheduleTeam() if !ok { return nil } return fetchScheduleCmd(m.client, team.ID, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) } // 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 modeTeamPicker: var tableCmd tea.Cmd m.teamPickerTable, tableCmd = m.teamPickerTable.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) case modeLogin: var inputCmd tea.Cmd if !m.loggingIn && !m.sso.active && m.offersPasswords() { m.loginInputs[m.loginFocus], inputCmd = m.loginInputs[m.loginFocus].Update(msg) } m2, ourCmd := m.handleKey(msg) return m2, tea.Batch(inputCmd, ourCmd) case modePasswordSet: var inputCmd tea.Cmd if !m.pwLoading { m.pwInputs[m.pwFocus], inputCmd = m.pwInputs[m.pwFocus].Update(msg) } m2, ourCmd := m.handleKey(msg) return m2, tea.Batch(inputCmd, ourCmd) 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 modeTeamPicker: return m.handleTeamPickerKey(msg) case modeUserCreate: return m.handleUserCreateKey(msg) case modeUserNotifyEdit: return m.handleUserNotifyEditKey(msg) case modeAPIKeyMenu: return m.handleAPIKeyMenuKey(msg) case modePasswordSet: return m.handlePasswordKey(msg) case modeLogin: return m.handleLoginKey(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.activeTeamID, m.incidentFilter) case sectionAlerts: m.alertFilter = nextFilter(alertFilters, m.alertFilter) m.loading = true return m, fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter) } return m, nil case "L": if !m.connected { return m, nil } m.statusMsg = "Signing out…" return m, logoutCmd(m.client) case "T": if !m.connected || len(m.teams) == 0 { return m, nil } return m.openTeamPicker() 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.activeTeamID, 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, m.activeTeamID) } return m, nil // Schedule-specific keys case "left", "h": if m.activeSection == sectionSchedule { m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, -7) cmd := m.fetchScheduleWindowCmd() m.scheduleLoading = cmd != nil return m, cmd } return m, nil case "right", "l": if m.activeSection == sectionSchedule { m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, 7) cmd := m.fetchScheduleWindowCmd() m.scheduleLoading = cmd != nil return m, cmd } return m, nil case "+": if m.activeSection != sectionSchedule || !m.connected { return m, nil } if !m.checkScheduleEditable() { return m, clearStatusCmd() } m.pickerAssignWeek = false return m.openUserPicker(pickerSchedule) case "W": if m.activeSection != sectionSchedule || !m.connected { return m, nil } if !m.checkScheduleEditable() { return m, clearStatusCmd() } 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 < 0 || 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 < 0 || cursor >= len(m.users) { return m, nil } if !m.isAdmin { m.statusMsg = "only administrators can delete users" return m, clearStatusCmd() } 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 } if !m.isAdmin { m.statusMsg = "only administrators can create users" return m, clearStatusCmd() } 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 < 0 || cursor >= len(m.users) { return m, nil } m.selectedUser = m.users[cursor] if !m.canManageUser(m.selectedUser) { cmd := m.refuseUserAction("notification topic") return m, cmd } // 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 < 0 || cursor >= len(m.users) { return m, nil } m.selectedUser = m.users[cursor] if !m.canManageUser(m.selectedUser) { cmd := m.refuseUserAction("API keys") return m, cmd } m.mode = modeAPIKeyMenu return m, nil case "p": 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] if !m.canManageUser(m.selectedUser) { cmd := m.refuseUserAction("password") return m, cmd } for i := range m.pwInputs { m.pwInputs[i].Reset() m.pwInputs[i].Blur() } m.pwNeedCurrent = false m.pwLoading = true m.mode = modePasswordSet // Whether the form needs the current password depends on who the key // belongs to, which the client does not otherwise know. return m, fetchMeCmd(m.client) } return m, nil } // refuseUserAction explains a self-or-admin action declined up front, and is what // the status bar clears afterwards. The server refuses these with a 403 anyway. func (m *Model) refuseUserAction(what string) tea.Cmd { m.statusMsg = "only administrators can change another user's " + what return clearStatusCmd() } // 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.activeTeamID, 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, m.activeTeamID) } case sectionSchedule: if len(m.scheduleDays) == 0 { cmd := m.fetchScheduleWindowCmd() m.scheduleLoading = cmd != nil return cmd } 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 target == pickerSchedule { // Only the team's own members can go on its rota, so who they are has to // be known before anybody is offered. team, ok := m.scheduleTeam() if !ok { m.mode = modeDashboard return m, nil } m.pickerMembers = nil m.usersLoading = true return m, fetchPickerCmd(m.client, team.ID) } if len(m.users) == 0 { m.usersLoading = true return m, fetchUsersCmd(m.client) } m.rebuildUserPickerTable() return m, nil } // openTeamPicker opens the full list of the caller's teams, plus "All teams", // for the "T" key to choose among rather than blindly cycling through them. // Team data is already loaded at connect time, so no fetch is needed. func (m Model) openTeamPicker() (Model, tea.Cmd) { m.mode = modeTeamPicker m.rebuildTeamPickerTable() return m, nil } // selectTeam sets the active team to teamID (0 meaning all teams) and reloads // what depends on it. Sections that are not on screen are emptied rather than // fetched, so they load when next opened; the incident queue is the exception // because it is what the caller returns to. func (m Model) selectTeam(teamID int64) (Model, tea.Cmd) { m.activeTeamID = teamID m.incidents, m.alerts, m.archivedIncidents = nil, nil, nil m.scheduleEntries, m.scheduleDays, m.currentOnCall = nil, nil, nil m.loading = true m.rebuildIncidentTable() m.rebuildTable() m.rebuildArchivedTable() m.rebuildScheduleTable() label := "all teams" if t, ok := m.activeTeam(); ok { label = t.Name } m.statusMsg = "Team: " + label cmds := []tea.Cmd{clearStatusCmd()} if m.activeSection != sectionIncidents { cmds = append(cmds, fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter)) } cmds = append(cmds, m.refreshActiveSection()) if m.activeSection == sectionSchedule { m.scheduleLoading = true } return m, tea.Batch(cmds...) } // checkScheduleEditable says why a schedule change is refused, in the status // bar, and reports whether it may go ahead. The server enforces the same rule. func (m *Model) checkScheduleEditable() bool { team, ok := m.scheduleTeam() switch { case !ok: m.statusMsg = "you are not in any team" case !m.canEditSchedule(team): m.statusMsg = "only owners of " + team.Name + " can change its schedule" default: return true } return false } // 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.activeTeamID) } m.mode = modeDashboard return m, archiveIncidentCmd(m.client, inc.ID, m.activeTeamID, m.incidentFilter) case "c", "C": m.mode = modeNote m.notePinned = msg.String() == "C" 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, m.notePinned) } 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 m.pendingAssign = 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 team, _ := m.scheduleTeam() return m, deleteScheduleEntryCmd(m.client, team.ID, 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) case confirmReassignSchedule: p := m.pendingAssign m.mode = modeDashboard m.pendingAssign = nil if p == nil { return m, nil } m.scheduleLoading = true team, _ := m.scheduleTeam() return m, assignScheduleCmd(m.client, team.ID, p.userID, p.dates, true, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) } 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() pickable := m.pickerUsers() if cursor < 0 || cursor >= len(pickable) { return m, nil } user := pickable[cursor] if m.pickerTarget == pickerIncidentAssignee { m.mode = modeIncidentDetail return m, assignIncidentCmd(m.client, m.selectedIncident.ID, user.ID) } scheduleCursor := m.scheduleTable.Cursor() if scheduleCursor < 0 || 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")} } // The server refuses a date somebody else holds, so ask before taking // it rather than letting the request come back 409. The answer is // already on screen — no round trip is needed to work out who loses // their shift. taken, holders := m.scheduleConflicts(dates, user.ID) if len(taken) > 0 { m.pendingAssign = &pendingAssign{ userID: user.ID, username: user.Username, dates: dates, taken: taken, holders: holders, } m.confirmTarget = confirmReassignSchedule m.mode = modeConfirm return m, nil } m.mode = modeDashboard m.scheduleLoading = true // Nobody else loses anything, but the server rejects any date that // already exists — including days this same person already holds, which // is a no-op worth letting through silently. team, _ := m.scheduleTeam() return m, assignScheduleCmd(m.client, team.ID, user.ID, dates, m.scheduleOccupied(dates), m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) } return m, nil } // ── Team picker ─────────────────────────────────────────────────────────── // handleTeamPickerKey reads the cursor's team off the table built by // rebuildTeamPickerTable, where row 0 is always "All teams" and row i (i>=1) // is m.teams[i-1]. func (m Model) handleTeamPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) { switch msg.String() { case "esc": m.mode = modeDashboard return m, nil case "enter": cursor := m.teamPickerTable.Cursor() id := int64(0) if cursor > 0 { i := cursor - 1 if i < 0 || i >= len(m.teams) { return m, nil } id = m.teams[i].ID } m, cmd := m.selectTeam(id) m.mode = modeDashboard return m, cmd } return m, nil } // scheduleConflicts reports which of dates are already held by somebody other // than newUserID, and the distinct names holding them. // // Days the target already owns are not conflicts — reassigning somebody to // their own shift takes nothing from anyone, and prompting for it would be // noise. The server still needs replace for those, since it rejects any date // that exists. func (m Model) scheduleConflicts(dates []string, newUserID int64) (taken, holders []string) { held := make(map[string]api.ScheduleEntry, len(m.scheduleDays)) for _, d := range m.scheduleDays { if d.entry != nil { held[d.entry.Date] = *d.entry } } seen := make(map[string]bool) for _, date := range dates { e, ok := held[date] if !ok || e.UserID == newUserID { continue } taken = append(taken, date) if !seen[e.Username] { seen[e.Username] = true holders = append(holders, e.Username) } } return taken, holders } // scheduleOccupied reports whether any of dates already has an entry at all, // including one belonging to the incoming user. That is what decides whether // the request needs replace, as opposed to whether it needs confirming. func (m Model) scheduleOccupied(dates []string) bool { held := make(map[string]bool, len(m.scheduleDays)) for _, d := range m.scheduleDays { if d.entry != nil { held[d.entry.Date] = true } } for _, date := range dates { if held[date] { return true } } return false } // ── 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 } // ── Set password ────────────────────────────────────────────────────────────── // pwFields is the set-password form's fields in tab order. func (m Model) pwFields() []int { if m.pwNeedCurrent { return []int{pwCurrent, pwNew, pwRepeat} } return []int{pwNew, pwRepeat} } func (m *Model) blurPasswordForm() { for i := range m.pwInputs { m.pwInputs[i].Blur() } } func (m Model) handlePasswordKey(msg tea.KeyMsg) (Model, tea.Cmd) { switch msg.String() { case "esc": m.blurPasswordForm() m.pwLoading = false m.mode = modeDashboard return m, nil } if m.pwLoading { return m, nil } switch msg.String() { case "tab", "shift+tab": fields := m.pwFields() i := slices.Index(fields, m.pwFocus) step := 1 if msg.String() == "shift+tab" { step = len(fields) - 1 } m.pwInputs[m.pwFocus].Blur() m.pwFocus = fields[(i+step)%len(fields)] m.pwInputs[m.pwFocus].Focus() return m, nil case "enter": password := m.pwInputs[pwNew].Value() switch { case m.pwNeedCurrent && m.pwInputs[pwCurrent].Value() == "": m.statusMsg = "enter your current password" return m, clearStatusCmd() case len(password) < minPasswordLen: m.statusMsg = fmt.Sprintf("the password must be at least %d characters", minPasswordLen) return m, clearStatusCmd() case password != m.pwInputs[pwRepeat].Value(): m.statusMsg = "the two new passwords do not match" return m, clearStatusCmd() } current := "" if m.pwNeedCurrent { current = m.pwInputs[pwCurrent].Value() } m.blurPasswordForm() m.mode = modeDashboard m.statusMsg = "Setting password…" return m, setPasswordCmd(m.client, m.selectedUser, password, current) } return m, nil } // ── Sign in ─────────────────────────────────────────────────────────────────── // msgError digs the error out of the messages that carry one, so the 401 check // in Update covers them all in one place. func msgError(msg tea.Msg) error { switch msg := msg.(type) { case connectErrMsg: return msg.err case fetchDataErrMsg: return msg.err case detailErrMsg: return msg.err case actionErrMsg: return msg.err case detailStatsErrMsg: return msg.err case scheduleFetchErrMsg: return msg.err case scheduleActionErrMsg: return msg.err case userActionErrMsg: return msg.err } return nil } // requireLogin returns to the sign-in form with everything the previous session // loaded dropped, so a different account never sees the last one's incidents. func (m Model) requireLogin(note string) Model { name := m.loginInputs[loginUsername].Value() fresh := NewModel(m.client, m.serverURL, m.refreshInterval, m.theme) fresh.width, fresh.height = m.width, m.height fresh.defaultTeam = m.defaultTeam fresh.authInfo, fresh.authPref = m.authInfo, m.authPref fresh.ticking = m.ticking fresh.mode = modeLogin fresh.loginInputs[loginUsername].SetValue(name) fresh.loginNote = note fresh.focusLogin(loginUsername) if name != "" { fresh.focusLogin(loginPassword) } fresh.rebuildIncidentTable() fresh.rebuildTable() fresh.rebuildArchivedTable() fresh.rebuildScheduleTable() fresh.rebuildUserPickerTable() fresh.rebuildUserManageTable() return fresh } func (m *Model) blurLoginForm() { for i := range m.loginInputs { m.loginInputs[i].Blur() } } func (m *Model) focusLogin(field int) { m.blurLoginForm() m.loginFocus = field m.loginInputs[field].Focus() } // loginErrorText turns a failed sign-in into something to act on. The server // answers a wrong password, an unknown user and an account with no password with // the same 401, so the last one has to be named here or it reads as a typo. func loginErrorText(err error) string { var se *api.StatusError if errors.As(err, &se) { switch se.Code { case http.StatusUnauthorized: return "invalid username or password — an account with no password cannot sign in; set one in the web UI first" case http.StatusTooManyRequests: return "too many attempts — wait a few minutes and try again" } } return err.Error() } // ── Single sign-on ──────────────────────────────────────────────────────────── // current reports whether a message belongs to the attempt in progress. Anything // else is the late answer of one that was cancelled, replaced or finished. func (s ssoLogin) current(attempt int) bool { return s.active && attempt == s.attempt } // canSSO is whether the server can sign in a client with no browser. func (m Model) canSSO() bool { return m.authInfo != nil && m.authInfo.DeviceLogin } // offersPasswords is whether the password form is worth showing. Until the // server has answered it is: the form is what a server too old to be asked has. func (m Model) offersPasswords() bool { return m.authInfo == nil || m.authInfo.PasswordLogin } // autoStartsSSO is whether the form should start a single sign-on login by // itself: when the config asks for it, and when the server has no passwords, so // that there is nothing else to show. func (m Model) autoStartsSSO() bool { return m.canSSO() && !m.sso.active && (m.authPref == "sso" || !m.offersPasswords()) } // ssoName is what the provider is called on screen. func (m Model) ssoName() string { if m.authInfo != nil && m.authInfo.OIDC.Name != "" { return m.authInfo.OIDC.Name } return "single sign-on" } // enterLogin is what to do on arriving at the sign-in form other than by // starting up: learn how the server can be signed in to if that is not known, // and, when auto is set, start a single sign-on login if the config or the // server's lack of passwords calls for one. func (m Model) enterLogin(auto bool) (Model, tea.Cmd) { if m.authInfo == nil { return m, authConfigCmd(m.client) } if auto && m.autoStartsSSO() { return m.startSSO() } return m, nil } // startSSO begins a device login, replacing any earlier attempt. func (m Model) startSSO() (Model, tea.Cmd) { m.sso = ssoLogin{attempt: m.sso.attempt + 1, active: true} m.loginErr = "" return m, startDeviceCmd(m.client, m.sso.attempt) } // cancelSSO abandons the attempt in progress. The server forgets the login when // it expires; there is nothing to tell it. func (m Model) cancelSSO() Model { m.sso = ssoLogin{attempt: m.sso.attempt + 1} return m } // failSSO ends the attempt and says why on the form. func (m Model) failSSO(err error) Model { m = m.cancelSSO() m.loginErr = ssoErrorText(err, m.ssoName()) return m } // ssoErrorText turns a failed single sign-on into something to act on. func ssoErrorText(err error, name string) string { var se *api.StatusError switch { case errors.Is(err, api.ErrDeviceExpired): return "the sign-in expired before it was approved — start it again" case errors.Is(err, api.ErrDeviceDenied): return "the sign-in was refused in the browser" case errors.As(err, &se) && se.Code == http.StatusNotFound: return "this server does not offer sign-in with " + name case errors.As(err, &se) && se.Code == http.StatusTooManyRequests: return "too many attempts — wait a few minutes and try again" } return "sign-in with " + name + " failed: " + err.Error() } func (m Model) handleLoginKey(msg tea.KeyMsg) (Model, tea.Cmd) { switch msg.String() { case "ctrl+c": return m, tea.Quit case "esc": // Backs out of a single sign-on wait before it quits the program, so a // wrong turn does not cost the session. if m.sso.active { return m.cancelSSO(), nil } return m, tea.Quit } if m.loggingIn || m.sso.active { return m, nil } if msg.String() == "ctrl+o" && m.canSSO() { return m.startSSO() } // With no password form there is one thing to do, and enter does it. if msg.String() == "enter" && !m.offersPasswords() { if m.canSSO() { return m.startSSO() } return m, nil } switch msg.String() { case "tab", "shift+tab", "down", "up": next := loginPassword if m.loginFocus == loginPassword { next = loginUsername } m.focusLogin(next) return m, nil case "enter": username := strings.TrimSpace(m.loginInputs[loginUsername].Value()) password := m.loginInputs[loginPassword].Value() switch { case username == "": m.loginErr = "enter your username" m.focusLogin(loginUsername) return m, nil case password == "": m.loginErr = "enter your password" m.focusLogin(loginPassword) return m, nil } m.loggingIn = true m.loginErr = "" return m, loginCmd(m.client, m.serverURL, username, password) } return m, nil }