Files
terdut-tui/internal/tui/update.go
T
Niklas Ye 27008086b0
CI / test (push) Successful in 12s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 14s
Follow terdut-server into teams: switch team, per-team schedule
terdut-server v0.12 made everything team-scoped and v0.20 is what this
client now targets. Against it the old client was wrong in three ways:
the schedule moved to /api/teams/{id}/schedule, GET /api/schedule/current
became a list with one entry per team, and users, incidents, alerts and
schedule entries all grew fields the client ignored.

T steps through all teams and then each of yours. The header names what
is showing, and incident and alert rows gain a Team column when more than
one team can appear. team: in config.yaml picks the team to start on, by
name or id; an unknown one is reported and falls back to all teams.

The schedule is one team's rota, so it shows the active team, or with
all teams showing the first one you own. Writes need an owner or an
administrator, and the picker offers only the team's members, since the
server answers 404 for anybody else. Both are checked up front and the
reason goes in the status bar, rather than surfacing as a 403 after the
user has picked somebody. Stats are not team-scoped by the server and
stay that way here.

Users shows an admin/disabled Flags column. Creating and deleting users
is administrators only, and topic, keys and password work on your own
row or on anyone's for an administrator; the server enforces the same
rule, this only explains it before the round trip.

The server has no version endpoint, so an older one is recognised by
GET /api/teams answering 404, and the TUI says it needs v0.20 or later.
Connecting now also loads /api/teams and /api/me with the key, which
means a wrong key fails on start instead of on the first list; /healthz
does not check it. There is no fallback to the pre-team paths.

Rebuilding a table whose column count changes under loaded rows panicked
inside bubbles, because it re-renders the old rows on SetColumns. The
rows are now cleared first and the cursor put back, so a refresh still
does not jump to the top.

Escalation ladders, invites, integrations and the admin settings are
left to the server's web UI. Checked against a real v0.20.1 server with
two teams, an administrator and a plain member.

Breaking: requires terdut-server v0.20.0 or later. Use terdut-tui v0.9.x
with servers before v0.12.
2026-09-23 21:57:28 +02:00

1400 lines
38 KiB
Go

package tui
import (
"fmt"
"slices"
"strconv"
"strings"
"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) {
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:
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()
return m, tea.Batch(
tickCmd(m.refreshInterval),
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.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 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 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 modeUserCreate:
return m.handleUserCreateKey(msg)
case modeUserNotifyEdit:
return m.handleUserNotifyEditKey(msg)
case modeAPIKeyMenu:
return m.handleAPIKeyMenuKey(msg)
case modePasswordSet:
return m.handlePasswordKey(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 "T":
if !m.connected || len(m.teams) == 0 {
return m, nil
}
return m.switchTeam()
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
}
// switchTeam steps the active team through all teams, then each of the caller's
// teams in turn, 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) switchTeam() (Model, tea.Cmd) {
next := int64(0)
if m.activeTeamID == 0 {
next = m.teams[0].ID
} else {
for i, t := range m.teams {
if t.ID == m.activeTeamID && i+1 < len(m.teams) {
next = m.teams[i+1].ID
}
}
}
m.activeTeamID = next
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":
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
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
}
// 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
}