f4ca0059dc
The web UI signs in with a username and password and holds a session cookie; the TUI was the only client still needing an API key pasted into a config file. It now asks for the same credentials on a form at start. What is kept between runs is the session token, not the password, in session.json under the config directory, mode 0600 and keyed by server URL so one server's token is never offered to another. It resumes on the next start; the server's sessions last 30 days and slide with use. L signs out, which ends the session on the server and deletes the saved one even if the server cannot be reached. The client attaches the cookie by hand instead of using a cookie jar: the server marks it Secure behind https, and a jar drops a Secure cookie it is given over plain http, which would break a local server for no reason. It sends no Authorization header at all, since the server judges a request carrying one on that alone and never falls back to the cookie. Writes go through the server's cross-origin guard, which lets a client that sends neither Origin nor Sec-Fetch-Site through; checked against a real v0.20.1 server for both reads and writes. A 401 from anything means the session is gone (expired, ended from the web UI, or the account disabled), so the TUI returns to the form with the reason, forgets the saved token, and drops what the last session loaded rather than showing it to whoever signs in next. A 403 is a permission and leaves the session alone. The refresh timer is started once, so signing out and in does not leave two running. An account with no password cannot sign in, and the server answers it exactly like a wrong password, so the form's message says a password must be set first. Users created only for API access hit this. Breaking: api_key in config.yaml is no longer used. It is not an error to leave it there; the form says it is ignored. API keys still exist on the server and k in Users still manages them.
1568 lines
43 KiB
Go
1568 lines
43 KiB
Go
package tui
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"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) {
|
|
// 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 {
|
|
return m.requireLogin("your session has ended — sign in again"), forgetSessionCmd()
|
|
}
|
|
|
|
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 loginDoneMsg:
|
|
m.loggingIn = false
|
|
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:
|
|
return m.requireLogin("you have signed out"), 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.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 modeLogin:
|
|
var inputCmd tea.Cmd
|
|
if !m.loggingIn {
|
|
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 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.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
|
|
}
|
|
|
|
// ── 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.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()
|
|
}
|
|
|
|
func (m Model) handleLoginKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc":
|
|
return m, tea.Quit
|
|
}
|
|
if m.loggingIn {
|
|
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
|
|
}
|