024dc095a5
T used to silently cycle Model.activeTeamID through the caller's teams with no visible list of choices. It now opens a full picker (modelled on the existing user picker) listing every team plus "All teams", each with a stable identity colour from a new six-colour theme palette. The same colour now also shows as a bullet next to "team: <name>" in the header, so the active team stays visible without opening the picker. Adds an Identity palette to the theme package (six hues, skipping the ones that already mean firing/critical), Styles.TeamColor to pick one by team id, and identity_1..6 as theme-file tokens alongside the existing twelve so a fully custom theme can still set every token.
1756 lines
49 KiB
Go
1756 lines
49 KiB
Go
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
|
|
}
|