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.
1581 lines
46 KiB
Go
1581 lines
46 KiB
Go
package tui
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
|
"git.ryuvia.com/niklas/terdut-tui/internal/session"
|
|
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
|
|
"github.com/charmbracelet/bubbles/help"
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/bubbles/table"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
// ── Enums ──────────────────────────────────────────────────────────────────
|
|
|
|
type section int
|
|
|
|
const (
|
|
// Incidents lead: they are the work. Alerts is the raw feed underneath.
|
|
sectionIncidents section = iota
|
|
sectionAlerts
|
|
sectionStats
|
|
sectionArchived
|
|
sectionSchedule
|
|
sectionUsers
|
|
|
|
sectionCount = 6
|
|
)
|
|
|
|
type mode int
|
|
|
|
const (
|
|
modeDashboard mode = iota
|
|
modeIncidentDetail
|
|
modeAlertDetail
|
|
modeNote
|
|
modeSnooze
|
|
modeConfirm
|
|
modeUserPicker
|
|
modeTeamPicker
|
|
modeUserCreate
|
|
modeUserNotifyEdit
|
|
modeAPIKeyMenu
|
|
modeAPIKeyCreate
|
|
modeAPIKeyReveal
|
|
modeAPIKeyRevokeByID
|
|
modePasswordSet
|
|
modeLogin
|
|
)
|
|
|
|
// Fields of the sign-in form, in tab order.
|
|
const (
|
|
loginUsername = iota
|
|
loginPassword
|
|
loginFieldCount
|
|
)
|
|
|
|
// Fields of the set-password form, in tab order.
|
|
const (
|
|
pwCurrent = iota
|
|
pwNew
|
|
pwRepeat
|
|
pwFieldCount
|
|
)
|
|
|
|
// minPasswordLen mirrors the server's rule, so a short password is refused
|
|
// here rather than after a round trip.
|
|
const minPasswordLen = 10
|
|
|
|
type confirmTarget int
|
|
|
|
const (
|
|
confirmDeleteNote confirmTarget = iota
|
|
confirmResolveIncident
|
|
confirmDeleteScheduleEntry
|
|
confirmDeleteUser
|
|
confirmReassignSchedule
|
|
)
|
|
|
|
// pickerTarget says what the user picker is choosing a person for.
|
|
type pickerTarget int
|
|
|
|
const (
|
|
pickerSchedule pickerTarget = iota
|
|
pickerIncidentAssignee
|
|
)
|
|
|
|
// incidentFilters is the cycle the f key walks in the Incidents section. The
|
|
// empty string is the server default: open, unsnoozed incidents — the queue.
|
|
var incidentFilters = []string{"", api.StatusTriggered, api.StatusAcknowledged, api.StatusResolved, "snoozed"}
|
|
|
|
// alertFilters is the equivalent cycle for the raw alert feed.
|
|
var alertFilters = []string{"firing", "resolved", "", "archived"}
|
|
|
|
// incidentQuery translates a filter from the cycle into server query terms.
|
|
func incidentQuery(filter string) (status string, snoozed bool) {
|
|
if filter == "snoozed" {
|
|
return "", true
|
|
}
|
|
return filter, false
|
|
}
|
|
|
|
// filterLabel renders a filter for the status bar.
|
|
func filterLabel(filter string) string {
|
|
if filter == "" {
|
|
return "open"
|
|
}
|
|
return filter
|
|
}
|
|
|
|
// ── Messages ───────────────────────────────────────────────────────────────
|
|
|
|
// dashboard
|
|
type connectedMsg struct {
|
|
teams []api.Team
|
|
me api.Me
|
|
}
|
|
type connectErrMsg struct{ err error }
|
|
type loginDoneMsg struct{}
|
|
type loginErrMsg struct{ err error }
|
|
|
|
// Single sign-on. A device login is a chain: the server hands out a code
|
|
// (deviceStartedMsg), the client waits out the interval (devicePollMsg), asks
|
|
// (devicePendingMsg, or loginDoneMsg on approval), and waits again. Every message
|
|
// carries the attempt it belongs to, so the late answers of an attempt that was
|
|
// cancelled or replaced are dropped rather than acted on.
|
|
type authConfigMsg struct{ cfg api.AuthConfig }
|
|
type deviceStartedMsg struct {
|
|
attempt int
|
|
login api.DeviceLogin
|
|
}
|
|
type devicePollMsg struct{ attempt int }
|
|
type devicePendingMsg struct {
|
|
attempt int
|
|
slower bool // the server asked for fewer polls
|
|
err error // a poll that failed in a way worth retrying (network, 5xx)
|
|
}
|
|
type deviceFailedMsg struct {
|
|
attempt int
|
|
err error
|
|
}
|
|
|
|
// ssoLogin is a device login in progress; the zero value is none. attempt only
|
|
// ever goes up: starting, cancelling and finishing all bump it, which is what
|
|
// makes the messages of an earlier attempt stale.
|
|
type ssoLogin struct {
|
|
attempt int
|
|
active bool // started, and not yet cancelled, failed or finished
|
|
login *api.DeviceLogin // nil until the server has answered
|
|
// interval is the wait between polls: the server's, lengthened when it says
|
|
// it is being asked too often.
|
|
interval time.Duration
|
|
// failures counts polls in a row that failed for a reason other than "not
|
|
// yet", so a dead connection ends the wait instead of spinning forever.
|
|
failures int
|
|
}
|
|
|
|
const (
|
|
defaultDevicePoll = 5 * time.Second
|
|
maxPollFailures = 3
|
|
)
|
|
|
|
type logoutDoneMsg struct{}
|
|
type incidentsFetchedMsg struct{ incidents []api.Incident }
|
|
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
|
|
type incidentActionDoneMsg struct {
|
|
incidents []api.Incident
|
|
status string
|
|
}
|
|
type alertsFetchedMsg struct{ alerts []api.Alert }
|
|
type statsFetchedMsg struct {
|
|
incidents api.IncidentStats
|
|
alerts api.AlertStats
|
|
}
|
|
type fetchDataErrMsg struct{ err error }
|
|
type tickMsg time.Time
|
|
type clearStatusMsg struct{}
|
|
|
|
// detail
|
|
type incidentDetailFetchedMsg struct {
|
|
incident api.Incident
|
|
timeline []api.IncidentEvent
|
|
similar []api.SimilarIncident
|
|
}
|
|
type alertDetailFetchedMsg struct{ alert api.Alert }
|
|
type detailErrMsg struct{ err error }
|
|
type actionErrMsg struct{ err error }
|
|
type detailStatsFetchedMsg struct {
|
|
top []api.TopAlert
|
|
byHour []api.HourStat
|
|
byDay []api.DayStat
|
|
}
|
|
type detailStatsErrMsg struct{ err error }
|
|
|
|
// schedule
|
|
type scheduleFetchedMsg struct {
|
|
entries []api.ScheduleEntry
|
|
current []api.ScheduleEntry
|
|
}
|
|
type pickerReadyMsg struct {
|
|
users []api.User
|
|
members map[int64]bool
|
|
}
|
|
type scheduleFetchErrMsg struct{ err error }
|
|
type scheduleActionErrMsg struct{ err error }
|
|
type usersFetchedMsg struct{ users []api.User }
|
|
|
|
// user management
|
|
type apiKeyCreatedMsg struct{ key api.APIKey }
|
|
type apiKeyRevokedMsg struct{}
|
|
type userActionErrMsg struct{ err error }
|
|
type meFetchedMsg struct{ me api.Me }
|
|
type passwordSetMsg struct{ username string }
|
|
|
|
// ── Model ──────────────────────────────────────────────────────────────────
|
|
|
|
type scheduleDay struct {
|
|
date time.Time
|
|
entry *api.ScheduleEntry
|
|
}
|
|
|
|
// pendingAssign is an on-call assignment held back by the reassignment
|
|
// confirmation, because some of its dates belong to somebody else.
|
|
type pendingAssign struct {
|
|
userID int64
|
|
username string
|
|
dates []string
|
|
// taken are the dates currently held by other people, and holders the
|
|
// distinct names holding them — both only for wording the prompt.
|
|
taken []string
|
|
holders []string
|
|
}
|
|
|
|
type Model struct {
|
|
client *api.Client
|
|
serverURL string
|
|
refreshInterval time.Duration
|
|
theme theme.Theme // kept to build a fresh model when signing out
|
|
|
|
activeSection section
|
|
mode mode
|
|
width int
|
|
height int
|
|
|
|
// Teams. The server scopes everything to the caller's teams; activeTeamID
|
|
// narrows the incident and alert lists to one of them, 0 meaning all. The
|
|
// schedule is per team and always needs a concrete one, see scheduleTeam.
|
|
teams []api.Team
|
|
activeTeamID int64
|
|
defaultTeam string // config's `team`, resolved on connect
|
|
meID int64
|
|
isAdmin bool
|
|
teamPickerTable table.Model
|
|
|
|
// Sign-in. Until the server accepts a session the TUI is in modeLogin;
|
|
// loginNote is a line the form shows above the fields (why we are here), and
|
|
// ticking says the refresh timer is already running, so signing in again
|
|
// after signing out does not start a second one.
|
|
loginInputs [loginFieldCount]textinput.Model
|
|
loginFocus int
|
|
loggingIn bool
|
|
loginErr string
|
|
loginNote string
|
|
ticking bool
|
|
|
|
// How the server can be signed in to, nil until it has answered. authPref is
|
|
// the config's `auth`, which only chooses among what the server offers.
|
|
authInfo *api.AuthConfig
|
|
authPref string
|
|
sso ssoLogin
|
|
|
|
// Connection & dashboard
|
|
connected bool
|
|
loading bool
|
|
err error
|
|
statusMsg string
|
|
incidentStats *api.IncidentStats
|
|
alertStats *api.AlertStats
|
|
|
|
// Incidents
|
|
incidents []api.Incident
|
|
incidentFilter string
|
|
incidentTable table.Model
|
|
|
|
// Alerts (read-only feed)
|
|
alerts []api.Alert
|
|
alertFilter string
|
|
alertTable table.Model
|
|
|
|
// Archived incidents
|
|
archivedIncidents []api.Incident
|
|
archivedLoading bool
|
|
archivedTable table.Model
|
|
|
|
// Incident detail
|
|
selectedIncident api.Incident
|
|
timeline []api.IncidentEvent
|
|
similar []api.SimilarIncident
|
|
noteCursor int
|
|
notePinned bool // the note being typed is the resolution note
|
|
detailLoading bool
|
|
detailViewport viewport.Model
|
|
|
|
// Alert detail
|
|
selectedAlert api.Alert
|
|
|
|
// Note compose & snooze
|
|
noteInput textinput.Model
|
|
snoozeInput textinput.Model
|
|
|
|
// Confirm
|
|
confirmTarget confirmTarget
|
|
pendingDeleteID int64 // note event ID
|
|
pendingDeleteEntry *api.ScheduleEntry
|
|
pendingAssign *pendingAssign
|
|
|
|
// Stats
|
|
topAlerts []api.TopAlert
|
|
hourStats []api.HourStat
|
|
dayStats []api.DayStat
|
|
// statsLoaded tracks the first fetch separately from emptiness: a server with
|
|
// no alerts yet legitimately returns three empty slices.
|
|
statsLoaded bool
|
|
statsLoading bool
|
|
statsViewport viewport.Model
|
|
|
|
// Schedule
|
|
scheduleWindow time.Time
|
|
scheduleEntries []api.ScheduleEntry
|
|
scheduleDays []scheduleDay
|
|
currentOnCall []api.ScheduleEntry
|
|
scheduleLoading bool
|
|
scheduleTable table.Model
|
|
|
|
// User picker (schedule assignment and incident assignee)
|
|
users []api.User
|
|
usersLoading bool
|
|
userPickerTable table.Model
|
|
pickerTarget pickerTarget
|
|
pickerAssignWeek bool
|
|
// pickerMembers is who belongs to the schedule's team, so the schedule picker
|
|
// offers only people the server will accept. Nil until fetched.
|
|
pickerMembers map[int64]bool
|
|
|
|
// User management section
|
|
userManageTable table.Model
|
|
selectedUser api.User
|
|
userFormInputs [2]textinput.Model
|
|
userFormFocus int
|
|
ntfyTopicInput textinput.Model
|
|
apiKeyNameInput textinput.Model
|
|
apiKeyRevokeInput textinput.Model
|
|
revealedAPIKey api.APIKey
|
|
|
|
// Set-password form. The current-password field is shown only when the
|
|
// target is the key's own user and already has a password, which is the
|
|
// one case the server asks for it; pwLoading covers the /api/me lookup
|
|
// that decides it.
|
|
pwInputs [pwFieldCount]textinput.Model
|
|
pwFocus int
|
|
pwNeedCurrent bool
|
|
pwLoading bool
|
|
|
|
help help.Model
|
|
keys keyMap
|
|
styles Styles
|
|
}
|
|
|
|
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration, th theme.Theme) Model {
|
|
st := newStyles(th)
|
|
ts := st.Table()
|
|
|
|
// Each table sees a key before the section's own handler does, so any
|
|
// key a section uses as an action must be taken out of that table's
|
|
// navigation bindings, or the cursor moves first and the action lands on
|
|
// a different row. See tableKeyMap.
|
|
incidentT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("f")))
|
|
incidentT.SetStyles(ts)
|
|
|
|
alertT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("f")))
|
|
alertT.SetStyles(ts)
|
|
|
|
archivedT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
|
|
archivedT.SetStyles(ts)
|
|
|
|
schedT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d")))
|
|
schedT.SetStyles(ts)
|
|
|
|
pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
|
|
pickerT.SetStyles(ts)
|
|
|
|
teamPickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
|
|
teamPickerT.SetStyles(ts)
|
|
|
|
manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k", "p")))
|
|
manageT.SetStyles(ts)
|
|
|
|
// Sized by the first tea.WindowSizeMsg; built here so it carries the default
|
|
// scroll keymap, which the zero value lacks.
|
|
statsVP := viewport.New(0, 0)
|
|
|
|
noteIn := textinput.New()
|
|
noteIn.Placeholder = "type your note…"
|
|
noteIn.CharLimit = 1000
|
|
|
|
snoozeIn := textinput.New()
|
|
snoozeIn.Placeholder = "duration, e.g. 2h or 30m"
|
|
snoozeIn.CharLimit = 16
|
|
|
|
usernameIn := textinput.New()
|
|
usernameIn.Placeholder = "username"
|
|
usernameIn.CharLimit = 64
|
|
|
|
emailIn := textinput.New()
|
|
emailIn.Placeholder = "email"
|
|
emailIn.CharLimit = 128
|
|
|
|
topicIn := textinput.New()
|
|
topicIn.Placeholder = "ntfy topic — empty clears it"
|
|
topicIn.CharLimit = 128
|
|
|
|
keyNameIn := textinput.New()
|
|
keyNameIn.Placeholder = "key name (e.g. laptop)"
|
|
keyNameIn.CharLimit = 64
|
|
|
|
revokeIn := textinput.New()
|
|
revokeIn.Placeholder = "integer key ID"
|
|
revokeIn.CharLimit = 20
|
|
|
|
var pwIn [pwFieldCount]textinput.Model
|
|
for i, placeholder := range [pwFieldCount]string{"current password", "new password (min. 10 characters)", "repeat new password"} {
|
|
pwIn[i] = textinput.New()
|
|
pwIn[i].Placeholder = placeholder
|
|
pwIn[i].EchoMode = textinput.EchoPassword
|
|
pwIn[i].EchoCharacter = '•'
|
|
pwIn[i].CharLimit = 72 // bcrypt's limit; the server refuses longer
|
|
}
|
|
|
|
var loginIn [loginFieldCount]textinput.Model
|
|
for i, placeholder := range [loginFieldCount]string{"username", "password"} {
|
|
loginIn[i] = textinput.New()
|
|
loginIn[i].Placeholder = placeholder
|
|
loginIn[i].CharLimit = 72 // bcrypt's limit, and the server refuses longer passwords
|
|
}
|
|
loginIn[loginPassword].EchoMode = textinput.EchoPassword
|
|
loginIn[loginPassword].EchoCharacter = '•'
|
|
for i := range loginIn {
|
|
loginIn[i] = st.Input(loginIn[i])
|
|
}
|
|
|
|
for _, in := range []*textinput.Model{
|
|
¬eIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn,
|
|
} {
|
|
*in = st.Input(*in)
|
|
}
|
|
for i := range pwIn {
|
|
pwIn[i] = st.Input(pwIn[i])
|
|
}
|
|
|
|
helpModel := help.New()
|
|
helpModel.Styles = st.Help()
|
|
|
|
now := time.Now().UTC()
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
weekday := int(today.Weekday())
|
|
if weekday == 0 {
|
|
weekday = 7 // ISO: Sunday = 7
|
|
}
|
|
window := today.AddDate(0, 0, -(weekday - 1))
|
|
|
|
startMode := modeDashboard
|
|
if client != nil && !client.HasSession() {
|
|
startMode = modeLogin
|
|
loginIn[loginUsername].Focus()
|
|
}
|
|
|
|
return Model{
|
|
client: client,
|
|
serverURL: serverURL,
|
|
refreshInterval: refreshInterval,
|
|
theme: th,
|
|
activeSection: sectionIncidents,
|
|
mode: startMode,
|
|
loginInputs: loginIn,
|
|
loading: true,
|
|
incidentFilter: "",
|
|
alertFilter: "firing",
|
|
noteCursor: -1,
|
|
incidentTable: incidentT,
|
|
alertTable: alertT,
|
|
archivedTable: archivedT,
|
|
statsViewport: statsVP,
|
|
noteInput: noteIn,
|
|
snoozeInput: snoozeIn,
|
|
scheduleWindow: window,
|
|
scheduleTable: schedT,
|
|
userPickerTable: pickerT,
|
|
teamPickerTable: teamPickerT,
|
|
userManageTable: manageT,
|
|
userFormInputs: [2]textinput.Model{usernameIn, emailIn},
|
|
ntfyTopicInput: topicIn,
|
|
apiKeyNameInput: keyNameIn,
|
|
apiKeyRevokeInput: revokeIn,
|
|
pwInputs: pwIn,
|
|
help: helpModel,
|
|
keys: keys,
|
|
styles: st,
|
|
}
|
|
}
|
|
|
|
// WithDefaultTeam names the team to start on, by name or id. It is resolved
|
|
// against the caller's teams once connected; an unknown one is reported and the
|
|
// TUI starts on all teams.
|
|
func (m Model) WithDefaultTeam(team string) Model {
|
|
m.defaultTeam = strings.TrimSpace(team)
|
|
return m
|
|
}
|
|
|
|
// WithAuth sets the default way to sign in, from the config's `auth`: "sso"
|
|
// starts a single sign-on login by itself when the server offers one.
|
|
func (m Model) WithAuth(pref string) Model {
|
|
m.authPref = pref
|
|
return m
|
|
}
|
|
|
|
// WithLogin prefills the sign-in form's username and sets a note shown above it.
|
|
func (m Model) WithLogin(username, note string) Model {
|
|
m.loginInputs[loginUsername].SetValue(username)
|
|
m.loginNote = note
|
|
if username != "" && m.mode == modeLogin {
|
|
// The name is known, so the only thing left to type is the password.
|
|
m.loginInputs[loginUsername].Blur()
|
|
m.loginFocus = loginPassword
|
|
m.loginInputs[loginPassword].Focus()
|
|
}
|
|
return m
|
|
}
|
|
|
|
// Init tries the saved session, if there is one; otherwise the sign-in form is
|
|
// already showing and there is nothing to do until it is submitted.
|
|
func (m Model) Init() tea.Cmd {
|
|
if m.mode == modeLogin {
|
|
// Ask how the server can be signed in to, so the form offers the right
|
|
// thing. Nothing depends on the answer arriving: the password form is
|
|
// already usable.
|
|
return authConfigCmd(m.client)
|
|
}
|
|
return connectCmd(m.client)
|
|
}
|
|
|
|
// ── Table rebuilders ───────────────────────────────────────────────────────
|
|
|
|
// tableKeyMap is the bubbles table keymap without the given keys.
|
|
//
|
|
// The table's defaults claim several letters -- k up, d half a page down, f a
|
|
// page down -- and the dashboard hands every key to the table before the
|
|
// section's own handler reads the cursor. A letter that is both, like k for
|
|
// API keys in Users, therefore moved the cursor and then acted on the row it
|
|
// had moved to. Each table gives up the letters its section acts on; the
|
|
// arrow keys and the rest of the defaults are untouched.
|
|
func tableKeyMap(reserved ...string) table.KeyMap {
|
|
km := table.DefaultKeyMap()
|
|
for _, b := range []*key.Binding{
|
|
&km.LineUp, &km.LineDown, &km.PageUp, &km.PageDown,
|
|
&km.HalfPageUp, &km.HalfPageDown, &km.GotoTop, &km.GotoBottom,
|
|
} {
|
|
var keep []string
|
|
for _, k := range b.Keys() {
|
|
if !slices.Contains(reserved, k) {
|
|
keep = append(keep, k)
|
|
}
|
|
}
|
|
b.SetKeys(keep...)
|
|
}
|
|
return km
|
|
}
|
|
|
|
// setRows replaces a table's rows and keeps its cursor in a state the rest of
|
|
// this package can rely on: valid whenever the table has any rows at all.
|
|
//
|
|
// bubbles does not do that on its own. SetRows only clamps the cursor *down*
|
|
// (`if m.cursor > len(rows)-1`), so setting zero rows drives it to -1 and
|
|
// nothing ever brings it back — filling the table later leaves -1 in place,
|
|
// because -1 is not greater than len-1. Every table here is rebuilt from empty
|
|
// once at startup, when the first WindowSizeMsg arrives before any fetch has
|
|
// returned, so without this every cursor is -1 until the user happens to press
|
|
// up or down. Indexing a slice with that panics, which is exactly what
|
|
// assigning an on-call week did.
|
|
func setRows(t *table.Model, rows []table.Row) {
|
|
t.SetRows(rows)
|
|
if len(rows) > 0 && t.Cursor() < 0 {
|
|
t.SetCursor(0)
|
|
}
|
|
}
|
|
|
|
// setTable replaces a table's columns and rows together, for tables whose column
|
|
// count can change (the Team column comes and goes). bubbles re-renders the
|
|
// existing rows as soon as SetColumns is called, and a row with a different
|
|
// number of cells than the new columns indexes past the end and panics, so the
|
|
// old rows have to go first. The cursor is put back afterwards, since a refresh
|
|
// must not send it to the top.
|
|
func setTable(t *table.Model, cols []table.Column, rows []table.Row) {
|
|
cursor := t.Cursor()
|
|
t.SetRows(nil)
|
|
t.SetColumns(cols)
|
|
setRows(t, rows)
|
|
if cursor > 0 && cursor < len(rows) {
|
|
t.SetCursor(cursor)
|
|
}
|
|
}
|
|
|
|
func (m *Model) rebuildIncidentTable() {
|
|
setTable(&m.incidentTable, incidentColumns(m.width, m.showTeamColumn()), incidentRows(m.incidents, m.showTeamColumn()))
|
|
m.incidentTable.SetHeight(tableHeight(m.height, 8))
|
|
}
|
|
|
|
func (m *Model) rebuildTable() {
|
|
setTable(&m.alertTable, alertColumns(m.width, m.showTeamColumn()), alertRows(m.alerts, m.showTeamColumn()))
|
|
m.alertTable.SetHeight(tableHeight(m.height, 8))
|
|
}
|
|
|
|
func (m *Model) rebuildArchivedTable() {
|
|
setTable(&m.archivedTable, incidentColumns(m.width, m.showTeamColumn()), incidentRows(m.archivedIncidents, m.showTeamColumn()))
|
|
m.archivedTable.SetHeight(tableHeight(m.height, 8))
|
|
}
|
|
|
|
func (m *Model) rebuildScheduleTable() {
|
|
m.scheduleTable.SetColumns(scheduleColumns(m.width))
|
|
setRows(&m.scheduleTable, scheduleRows(m.scheduleDays))
|
|
m.scheduleTable.SetHeight(tableHeight(m.height, 10))
|
|
}
|
|
|
|
func (m *Model) rebuildUserPickerTable() {
|
|
m.userPickerTable.SetColumns(userPickerColumns(m.width))
|
|
pickable := m.pickerUsers()
|
|
rows := make([]table.Row, len(pickable))
|
|
for i, u := range pickable {
|
|
rows[i] = table.Row{u.Username, u.Email}
|
|
}
|
|
setRows(&m.userPickerTable, rows)
|
|
m.userPickerTable.SetHeight(tableHeight(m.height, 10))
|
|
}
|
|
|
|
// rebuildTeamPickerTable lists "All teams" first, then each of the caller's
|
|
// teams in the same order switchTeam / selectTeam use, so a row's position
|
|
// always matches its place in m.teams.
|
|
func (m *Model) rebuildTeamPickerTable() {
|
|
m.teamPickerTable.SetColumns(teamPickerColumns(m.width))
|
|
rows := make([]table.Row, 0, len(m.teams)+1)
|
|
rows = append(rows, table.Row{m.styles.Muted.Render("●"), "All teams", ""})
|
|
for _, t := range m.teams {
|
|
rows = append(rows, table.Row{m.styles.TeamColor(t.ID).Render("●"), t.Name, t.Role})
|
|
}
|
|
setRows(&m.teamPickerTable, rows)
|
|
m.teamPickerTable.SetHeight(tableHeight(m.height, 10))
|
|
}
|
|
|
|
func (m *Model) rebuildUserManageTable() {
|
|
m.userManageTable.SetColumns(userManageColumns(m.width))
|
|
rows := make([]table.Row, len(m.users))
|
|
for i, u := range m.users {
|
|
topic := u.Topic()
|
|
if topic == "" {
|
|
topic = "—"
|
|
}
|
|
rows[i] = table.Row{u.Username, u.Email, topic, userFlags(u), u.CreatedAt.UTC().Format("2006-01-02")}
|
|
}
|
|
setRows(&m.userManageTable, rows)
|
|
m.userManageTable.SetHeight(tableHeight(m.height, 10))
|
|
}
|
|
|
|
func tableHeight(windowHeight, chrome int) int {
|
|
h := windowHeight - chrome
|
|
if h < 1 {
|
|
h = 1
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (m *Model) refreshDetailContent() {
|
|
if m.width == 0 {
|
|
return
|
|
}
|
|
if m.mode == modeAlertDetail {
|
|
m.detailViewport.SetContent(buildAlertDetailContent(m.styles, m.selectedAlert, m.width))
|
|
return
|
|
}
|
|
m.detailViewport.SetContent(
|
|
buildIncidentDetailContent(m.styles, m.selectedIncident, m.timeline, m.similar, m.noteCursor, m.width))
|
|
}
|
|
|
|
func (m *Model) refreshStatsContent() {
|
|
m.statsViewport.SetContent(
|
|
buildStatsContent(m.styles, m.incidentStats, m.topAlerts, m.hourStats, m.dayStats, m.width))
|
|
}
|
|
|
|
func (m Model) statsViewportHeight() int {
|
|
h := m.height - 5
|
|
if h < 1 {
|
|
h = 1
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (m Model) detailViewportHeight() int {
|
|
h := m.height - 5
|
|
if m.mode == modeNote || m.mode == modeSnooze {
|
|
h -= 2
|
|
}
|
|
if h < 1 {
|
|
h = 1
|
|
}
|
|
return h
|
|
}
|
|
|
|
// showTeamColumn is whether list rows need saying which team they belong to:
|
|
// only when they can come from more than one.
|
|
func (m Model) showTeamColumn() bool {
|
|
return m.activeTeamID == 0 && len(m.teams) > 1
|
|
}
|
|
|
|
// activeTeam returns the team the lists are narrowed to.
|
|
func (m Model) activeTeam() (api.Team, bool) {
|
|
return m.teamByID(m.activeTeamID)
|
|
}
|
|
|
|
func (m Model) teamByID(id int64) (api.Team, bool) {
|
|
for _, t := range m.teams {
|
|
if t.ID == id {
|
|
return t, true
|
|
}
|
|
}
|
|
return api.Team{}, false
|
|
}
|
|
|
|
// scheduleTeam is the team whose schedule the Schedule section shows. That is
|
|
// the active team; with all teams showing it is the first one the caller owns,
|
|
// else their first, because a rota belongs to one team and there is no
|
|
// meaningful union to display.
|
|
func (m Model) scheduleTeam() (api.Team, bool) {
|
|
if t, ok := m.activeTeam(); ok {
|
|
return t, true
|
|
}
|
|
for _, t := range m.teams {
|
|
if t.Role == api.RoleOwner {
|
|
return t, true
|
|
}
|
|
}
|
|
if len(m.teams) > 0 {
|
|
return m.teams[0], true
|
|
}
|
|
return api.Team{}, false
|
|
}
|
|
|
|
// canEditSchedule mirrors the server: writes need a team owner or an
|
|
// administrator. Saying so up front beats a 403 after picking a user.
|
|
func (m Model) canEditSchedule(t api.Team) bool {
|
|
return m.isAdmin || t.Role == api.RoleOwner
|
|
}
|
|
|
|
// canManageUser mirrors the server's self-or-admin rule for a user's password,
|
|
// ntfy topic and API keys.
|
|
func (m Model) canManageUser(u api.User) bool {
|
|
return m.isAdmin || u.ID == m.meID
|
|
}
|
|
|
|
// resolveTeam finds a team by id or, failing that, by name.
|
|
func resolveTeam(teams []api.Team, want string) (api.Team, bool) {
|
|
if id, err := strconv.ParseInt(want, 10, 64); err == nil {
|
|
for _, t := range teams {
|
|
if t.ID == id {
|
|
return t, true
|
|
}
|
|
}
|
|
}
|
|
for _, t := range teams {
|
|
if strings.EqualFold(t.Name, want) {
|
|
return t, true
|
|
}
|
|
}
|
|
return api.Team{}, false
|
|
}
|
|
|
|
// pickerUsers is who the user picker offers. Disabled accounts are never worth
|
|
// assigning to. For the schedule it is also limited to the team's members: the
|
|
// server answers 404 for anyone else, and shows nothing until they are known.
|
|
func (m Model) pickerUsers() []api.User {
|
|
out := make([]api.User, 0, len(m.users))
|
|
for _, u := range m.users {
|
|
if u.IsDisabled() {
|
|
continue
|
|
}
|
|
if m.pickerTarget == pickerSchedule && !m.pickerMembers[u.ID] {
|
|
continue
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// userFlags is the Users table's marker column.
|
|
func userFlags(u api.User) string {
|
|
var flags []string
|
|
if u.IsAdmin {
|
|
flags = append(flags, "admin")
|
|
}
|
|
if u.IsDisabled() {
|
|
flags = append(flags, "disabled")
|
|
}
|
|
if len(flags) == 0 {
|
|
return "—"
|
|
}
|
|
return strings.Join(flags, ",")
|
|
}
|
|
|
|
// noteEvents filters a timeline down to the deletable entries, which is what
|
|
// the [ and ] cursor walks.
|
|
func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
|
|
notes := make([]api.IncidentEvent, 0, len(timeline))
|
|
for _, e := range timeline {
|
|
if e.Type == api.EventNote || e.Type == api.EventResolutionNote {
|
|
notes = append(notes, e)
|
|
}
|
|
}
|
|
return notes
|
|
}
|
|
|
|
// ── Column definitions ─────────────────────────────────────────────────────
|
|
|
|
// teamW is the width of the Team column shown when rows can span teams.
|
|
const teamW = 14
|
|
|
|
func incidentColumns(width int, showTeam bool) []table.Column {
|
|
const sevW, statusW, timeW = 9, 15, 12
|
|
titleW := width/2 - 10
|
|
if showTeam {
|
|
titleW -= teamW + 2
|
|
}
|
|
if titleW < 20 {
|
|
titleW = 20
|
|
}
|
|
// 10 = bubbles' Padding(0, 1) on each of the five cells.
|
|
assigneeW := width - sevW - titleW - statusW - timeW - 10
|
|
if showTeam {
|
|
assigneeW -= teamW + 2
|
|
}
|
|
if assigneeW < 8 {
|
|
assigneeW = 8
|
|
}
|
|
cols := []table.Column{
|
|
{Title: "Sev", Width: sevW},
|
|
{Title: "Incident", Width: titleW},
|
|
}
|
|
if showTeam {
|
|
cols = append(cols, table.Column{Title: "Team", Width: teamW})
|
|
}
|
|
return append(cols,
|
|
table.Column{Title: "Status", Width: statusW},
|
|
table.Column{Title: "Assignee", Width: assigneeW},
|
|
table.Column{Title: "Triggered", Width: timeW},
|
|
)
|
|
}
|
|
|
|
func alertColumns(width int, showTeam bool) []table.Column {
|
|
const statusW, timeW = 10, 12
|
|
nameW := width/2 - 14
|
|
if showTeam {
|
|
nameW -= teamW + 2
|
|
}
|
|
if nameW < 20 {
|
|
nameW = 20
|
|
}
|
|
// 10 = bubbles' Padding(0, 1) on each of the five cells.
|
|
incW := width - nameW - statusW - 2*timeW - 10
|
|
if showTeam {
|
|
incW -= teamW + 2
|
|
}
|
|
if incW < 8 {
|
|
incW = 8
|
|
}
|
|
cols := []table.Column{{Title: "Name", Width: nameW}}
|
|
if showTeam {
|
|
cols = append(cols, table.Column{Title: "Team", Width: teamW})
|
|
}
|
|
return append(cols,
|
|
table.Column{Title: "Status", Width: statusW},
|
|
table.Column{Title: "Started", Width: timeW},
|
|
table.Column{Title: "Last Seen", Width: timeW},
|
|
table.Column{Title: "Incident", Width: incW},
|
|
)
|
|
}
|
|
|
|
func scheduleColumns(width int) []table.Column {
|
|
dateW := 18
|
|
onCallW := width - dateW - 6
|
|
if onCallW < 15 {
|
|
onCallW = 15
|
|
}
|
|
return []table.Column{
|
|
{Title: "Date", Width: dateW},
|
|
{Title: "On-Call", Width: onCallW},
|
|
}
|
|
}
|
|
|
|
func userPickerColumns(width int) []table.Column {
|
|
usernameW := 25
|
|
emailW := width - usernameW - 6
|
|
if emailW < 15 {
|
|
emailW = 15
|
|
}
|
|
return []table.Column{
|
|
{Title: "Username", Width: usernameW},
|
|
{Title: "Email", Width: emailW},
|
|
}
|
|
}
|
|
|
|
func teamPickerColumns(width int) []table.Column {
|
|
dotW := 3
|
|
roleW := 10
|
|
nameW := width - dotW - roleW - 8
|
|
if nameW < 15 {
|
|
nameW = 15
|
|
}
|
|
return []table.Column{
|
|
{Title: "", Width: dotW},
|
|
{Title: "Team", Width: nameW},
|
|
{Title: "Role", Width: roleW},
|
|
}
|
|
}
|
|
|
|
func userManageColumns(width int) []table.Column {
|
|
createdW := 12
|
|
usernameW := 25
|
|
topicW := 22
|
|
flagsW := 14
|
|
// 10 = bubbles' Padding(0, 1) on each of the five cells.
|
|
emailW := width - usernameW - topicW - flagsW - createdW - 10
|
|
if emailW < 15 {
|
|
emailW = 15
|
|
}
|
|
return []table.Column{
|
|
{Title: "Username", Width: usernameW},
|
|
{Title: "Email", Width: emailW},
|
|
{Title: "Ntfy Topic", Width: topicW},
|
|
{Title: "Flags", Width: flagsW},
|
|
{Title: "Created", Width: createdW},
|
|
}
|
|
}
|
|
|
|
// ── Row builders ───────────────────────────────────────────────────────────
|
|
|
|
func incidentRows(incidents []api.Incident, showTeam bool) []table.Row {
|
|
now := time.Now()
|
|
rows := make([]table.Row, len(incidents))
|
|
for i, inc := range incidents {
|
|
severity := inc.Severity
|
|
if severity == "" {
|
|
severity = "—"
|
|
}
|
|
// bubbles' table renders plain strings, so a snoozed incident is marked
|
|
// in the status cell rather than styled.
|
|
status := inc.Status
|
|
if inc.IsSnoozed() {
|
|
status += " (zzz)"
|
|
}
|
|
assignee := inc.AssignedTo
|
|
if assignee == "" {
|
|
assignee = "—"
|
|
}
|
|
if showTeam {
|
|
rows[i] = table.Row{severity, inc.Title, teamLabel(inc.TeamName), status, assignee, humanAgo(now, inc.TriggeredAt)}
|
|
continue
|
|
}
|
|
rows[i] = table.Row{severity, inc.Title, status, assignee, humanAgo(now, inc.TriggeredAt)}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func alertRows(alerts []api.Alert, showTeam bool) []table.Row {
|
|
now := time.Now()
|
|
rows := make([]table.Row, len(alerts))
|
|
for i, a := range alerts {
|
|
incident := "—"
|
|
if a.IncidentID != nil {
|
|
incident = fmt.Sprintf("#%d", *a.IncidentID)
|
|
}
|
|
if showTeam {
|
|
rows[i] = table.Row{a.Name, teamLabel(a.TeamName), a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), incident}
|
|
continue
|
|
}
|
|
rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), incident}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// teamLabel is a team name for a table cell, with a dash when the server sent none.
|
|
func teamLabel(name string) string {
|
|
if name == "" {
|
|
return "—"
|
|
}
|
|
return name
|
|
}
|
|
|
|
func scheduleRows(days []scheduleDay) []table.Row {
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
rows := make([]table.Row, len(days))
|
|
for i, d := range days {
|
|
dateStr := d.date.Format("2006-01-02")
|
|
showWeek := i == 0 || d.date.Weekday() == time.Monday
|
|
_, week := d.date.ISOWeek()
|
|
weekPrefix := " "
|
|
if showWeek {
|
|
weekPrefix = fmt.Sprintf("W%02d ", week)
|
|
}
|
|
label := weekPrefix + d.date.Format("Jan 02 Mon")
|
|
if dateStr == today {
|
|
label = weekPrefix + "Today " + d.date.Format("Mon")
|
|
}
|
|
onCall := "—"
|
|
if d.entry != nil {
|
|
onCall = d.entry.Username
|
|
}
|
|
rows[i] = table.Row{label, onCall}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func buildScheduleDays(window time.Time, entries []api.ScheduleEntry) []scheduleDay {
|
|
entryMap := make(map[string]api.ScheduleEntry, len(entries))
|
|
for _, e := range entries {
|
|
entryMap[e.Date] = e
|
|
}
|
|
days := make([]scheduleDay, 7)
|
|
for i := range days {
|
|
date := window.AddDate(0, 0, i)
|
|
day := scheduleDay{date: date}
|
|
if e, ok := entryMap[date.Format("2006-01-02")]; ok {
|
|
e2 := e
|
|
day.entry = &e2
|
|
}
|
|
days[i] = day
|
|
}
|
|
return days
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
func humanAgo(now, t time.Time) string {
|
|
d := now.Sub(t)
|
|
if d < 0 {
|
|
d = 0
|
|
}
|
|
return humanDuration(d) + " ago"
|
|
}
|
|
|
|
// humanUntil renders a future deadline, used for snooze expiry.
|
|
func humanUntil(now, t time.Time) string {
|
|
d := t.Sub(now)
|
|
if d <= 0 {
|
|
return "expired"
|
|
}
|
|
return "in " + humanDuration(d)
|
|
}
|
|
|
|
func humanDuration(d time.Duration) string {
|
|
switch {
|
|
case d < time.Minute:
|
|
return "moments"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%dm", int(d.Minutes()))
|
|
case d < 24*time.Hour:
|
|
h := int(d.Hours())
|
|
m := int(d.Minutes()) % 60
|
|
if m == 0 {
|
|
return fmt.Sprintf("%dh", h)
|
|
}
|
|
return fmt.Sprintf("%dh %dm", h, m)
|
|
default:
|
|
days := int(d.Hours()) / 24
|
|
h := int(d.Hours()) % 24
|
|
if h == 0 {
|
|
return fmt.Sprintf("%dd", days)
|
|
}
|
|
return fmt.Sprintf("%dd %dh", days, h)
|
|
}
|
|
}
|
|
|
|
// humanSeconds renders an MTTA/MTTR average.
|
|
func humanSeconds(secs *float64) string {
|
|
if secs == nil {
|
|
return "—"
|
|
}
|
|
return humanDuration(time.Duration(*secs) * time.Second)
|
|
}
|
|
|
|
// ── Commands ───────────────────────────────────────────────────────────────
|
|
|
|
// errServerTooOld is what connecting to a server without teams looks like: the
|
|
// server has no version endpoint, so its missing /api/teams is the tell.
|
|
var errServerTooOld = errors.New("this server predates teams -- terdut-tui needs terdut-server v0.20 or later")
|
|
|
|
// connectCmd checks the server is up, then loads the caller's teams and identity
|
|
// with their key. /healthz is unauthenticated, so this is also the first thing
|
|
// to notice a wrong key.
|
|
func connectCmd(client *api.Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := client.HealthCheck(); err != nil {
|
|
return connectErrMsg{err}
|
|
}
|
|
teams, err := client.ListTeams()
|
|
var se *api.StatusError
|
|
if errors.As(err, &se) && se.Code == http.StatusNotFound {
|
|
return connectErrMsg{errServerTooOld}
|
|
}
|
|
if err != nil {
|
|
return connectErrMsg{err}
|
|
}
|
|
me, err := client.Me()
|
|
if err != nil {
|
|
return connectErrMsg{err}
|
|
}
|
|
return connectedMsg{teams: teams, me: *me}
|
|
}
|
|
}
|
|
|
|
// loginCmd signs in and saves the session, so the next run can resume it. Failing
|
|
// to save is not failing to sign in: the session works for this run either way.
|
|
func loginCmd(client *api.Client, serverURL, username, password string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
token, err := client.Login(username, password)
|
|
if err != nil {
|
|
return loginErrMsg{err}
|
|
}
|
|
_ = session.Save(serverURL, token)
|
|
return loginDoneMsg{}
|
|
}
|
|
}
|
|
|
|
// authConfigCmd asks how the server can be signed in to. A server too old to be
|
|
// asked, or one that cannot be reached, is treated as offering passwords only: the
|
|
// form that always existed is the safe fallback, and it reports a real connection
|
|
// problem itself when it is submitted.
|
|
func authConfigCmd(client *api.Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
cfg, err := client.AuthConfig()
|
|
if err != nil {
|
|
cfg = api.AuthConfig{PasswordLogin: true}
|
|
}
|
|
return authConfigMsg{cfg}
|
|
}
|
|
}
|
|
|
|
// startDeviceCmd asks the server to begin a device login.
|
|
func startDeviceCmd(client *api.Client, attempt int) tea.Cmd {
|
|
return func() tea.Msg {
|
|
login, err := client.StartDeviceLogin()
|
|
if err != nil {
|
|
return deviceFailedMsg{attempt, err}
|
|
}
|
|
return deviceStartedMsg{attempt, *login}
|
|
}
|
|
}
|
|
|
|
// devicePollAfter waits out the interval before the next poll.
|
|
func devicePollAfter(attempt int, interval time.Duration) tea.Cmd {
|
|
return tea.Tick(interval, func(time.Time) tea.Msg { return devicePollMsg{attempt} })
|
|
}
|
|
|
|
// pollDeviceCmd asks whether the login has been approved. Approval saves the
|
|
// session the way a password sign-in does, and ends in the same loginDoneMsg.
|
|
func pollDeviceCmd(client *api.Client, serverURL string, attempt int, deviceCode string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
token, err := client.PollDeviceLogin(deviceCode)
|
|
switch {
|
|
case err == nil:
|
|
_ = session.Save(serverURL, token)
|
|
return loginDoneMsg{}
|
|
case errors.Is(err, api.ErrDevicePending):
|
|
return devicePendingMsg{attempt: attempt}
|
|
case errors.Is(err, api.ErrDeviceSlowDown):
|
|
return devicePendingMsg{attempt: attempt, slower: true}
|
|
case errors.Is(err, api.ErrDeviceExpired), errors.Is(err, api.ErrDeviceDenied):
|
|
return deviceFailedMsg{attempt, err}
|
|
}
|
|
// Anything else is the network or the server having a moment, which a
|
|
// person waiting on a browser should not have to start over for.
|
|
return devicePendingMsg{attempt: attempt, err: err}
|
|
}
|
|
}
|
|
|
|
// logoutCmd ends the session on the server and deletes the saved one. The saved
|
|
// copy goes even when the server cannot be reached, because the person asked to
|
|
// be signed out and a token left on disk would say otherwise.
|
|
func logoutCmd(client *api.Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
_ = client.Logout()
|
|
_ = session.Clear()
|
|
return logoutDoneMsg{}
|
|
}
|
|
}
|
|
|
|
// forgetSessionCmd drops a saved session the server no longer honours.
|
|
func forgetSessionCmd() tea.Cmd {
|
|
return func() tea.Msg {
|
|
_ = session.Clear()
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func fetchIncidentsCmd(client *api.Client, teamID int64, filter string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
status, snoozed := incidentQuery(filter)
|
|
incidents, err := client.ListIncidents(teamID, status, false, snoozed, 500)
|
|
if err != nil {
|
|
return fetchDataErrMsg{err}
|
|
}
|
|
return incidentsFetchedMsg{incidents}
|
|
}
|
|
}
|
|
|
|
func fetchArchivedIncidentsCmd(client *api.Client, teamID int64) tea.Cmd {
|
|
return func() tea.Msg {
|
|
// Archived incidents are all resolved, so the status filter has to be
|
|
// widened past the server's open-only default or nothing comes back.
|
|
incidents, err := client.ListIncidents(teamID, api.StatusResolved, true, false, 500)
|
|
if err != nil {
|
|
return fetchDataErrMsg{err}
|
|
}
|
|
return archivedIncidentsFetchedMsg{incidents}
|
|
}
|
|
}
|
|
|
|
func fetchAlertsCmd(client *api.Client, teamID int64, filter string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
status, archived := filter, false
|
|
if filter == "archived" {
|
|
status, archived = "", true
|
|
}
|
|
alerts, err := client.ListAlerts(teamID, status, archived, 500)
|
|
if err != nil {
|
|
return fetchDataErrMsg{err}
|
|
}
|
|
return alertsFetchedMsg{alerts}
|
|
}
|
|
}
|
|
|
|
func fetchStatsCmd(client *api.Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
incidents, err := client.GetIncidentStats()
|
|
if err != nil {
|
|
return fetchDataErrMsg{err}
|
|
}
|
|
alerts, err := client.GetAlertStats()
|
|
if err != nil {
|
|
return fetchDataErrMsg{err}
|
|
}
|
|
return statsFetchedMsg{incidents: *incidents, alerts: *alerts}
|
|
}
|
|
}
|
|
|
|
// incidentDetail reloads an incident and its timeline. Every detail-mode action
|
|
// funnels through it so the view always reflects what the server just did.
|
|
func incidentDetail(client *api.Client, id int64) tea.Msg {
|
|
incident, err := client.GetIncident(id)
|
|
if err != nil {
|
|
return detailErrMsg{err}
|
|
}
|
|
timeline, err := client.GetIncidentTimeline(id)
|
|
if err != nil {
|
|
return detailErrMsg{err}
|
|
}
|
|
// Best effort: an older server has no such endpoint, and the incident is
|
|
// still worth showing without it.
|
|
similar, _ := client.GetSimilarIncidents(id)
|
|
return incidentDetailFetchedMsg{incident: *incident, timeline: timeline, similar: similar}
|
|
}
|
|
|
|
func fetchIncidentDetailCmd(client *api.Client, id int64) tea.Cmd {
|
|
return func() tea.Msg { return incidentDetail(client, id) }
|
|
}
|
|
|
|
// incidentActionCmd performs an action then reloads the detail view, reporting
|
|
// the server's error rather than a stale success.
|
|
func incidentActionCmd(client *api.Client, id int64, action func() error) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := action(); err != nil {
|
|
return actionErrMsg{err}
|
|
}
|
|
return incidentDetail(client, id)
|
|
}
|
|
}
|
|
|
|
func acknowledgeIncidentCmd(client *api.Client, id int64) tea.Cmd {
|
|
return incidentActionCmd(client, id, func() error {
|
|
_, err := client.AcknowledgeIncident(id)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func unacknowledgeIncidentCmd(client *api.Client, id int64) tea.Cmd {
|
|
return incidentActionCmd(client, id, func() error { return client.UnacknowledgeIncident(id) })
|
|
}
|
|
|
|
func resolveIncidentCmd(client *api.Client, id int64) tea.Cmd {
|
|
return incidentActionCmd(client, id, func() error {
|
|
_, err := client.ResolveIncident(id)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func assignIncidentCmd(client *api.Client, id, userID int64) tea.Cmd {
|
|
return incidentActionCmd(client, id, func() error {
|
|
_, err := client.AssignIncident(id, userID)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func snoozeIncidentCmd(client *api.Client, id int64, duration string) tea.Cmd {
|
|
return incidentActionCmd(client, id, func() error {
|
|
_, err := client.SnoozeIncident(id, duration)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func unsnoozeIncidentCmd(client *api.Client, id int64) tea.Cmd {
|
|
return incidentActionCmd(client, id, func() error { return client.UnsnoozeIncident(id) })
|
|
}
|
|
|
|
func addNoteCmd(client *api.Client, id int64, content string, pinned bool) tea.Cmd {
|
|
return incidentActionCmd(client, id, func() error {
|
|
_, err := client.AddNote(id, content, pinned)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func deleteNoteCmd(client *api.Client, id, eventID int64) tea.Cmd {
|
|
return incidentActionCmd(client, id, func() error { return client.DeleteNote(id, eventID) })
|
|
}
|
|
|
|
// archiveIncidentCmd archives from the list view, so it reloads the list rather
|
|
// than a detail pane.
|
|
func archiveIncidentCmd(client *api.Client, id, teamID int64, filter string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if _, err := client.ArchiveIncident(id); err != nil {
|
|
return actionErrMsg{err}
|
|
}
|
|
status, snoozed := incidentQuery(filter)
|
|
incidents, err := client.ListIncidents(teamID, status, false, snoozed, 500)
|
|
if err != nil {
|
|
return actionErrMsg{err}
|
|
}
|
|
return incidentActionDoneMsg{incidents: incidents, status: "Incident archived"}
|
|
}
|
|
}
|
|
|
|
func unarchiveIncidentCmd(client *api.Client, id, teamID int64) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := client.UnarchiveIncident(id); err != nil {
|
|
return actionErrMsg{err}
|
|
}
|
|
incidents, err := client.ListIncidents(teamID, api.StatusResolved, true, false, 500)
|
|
if err != nil {
|
|
return actionErrMsg{err}
|
|
}
|
|
return archivedIncidentsFetchedMsg{incidents}
|
|
}
|
|
}
|
|
|
|
func fetchAlertDetailCmd(client *api.Client, alertID int64) tea.Cmd {
|
|
return func() tea.Msg {
|
|
alert, err := client.GetAlert(alertID)
|
|
if err != nil {
|
|
return detailErrMsg{err}
|
|
}
|
|
return alertDetailFetchedMsg{alert: *alert}
|
|
}
|
|
}
|
|
|
|
func fetchDetailStatsCmd(client *api.Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
top, err := client.GetTopAlerts(10)
|
|
if err != nil {
|
|
return detailStatsErrMsg{err}
|
|
}
|
|
byHour, err := client.GetStatsByHour()
|
|
if err != nil {
|
|
return detailStatsErrMsg{err}
|
|
}
|
|
byDay, err := client.GetStatsByDay()
|
|
if err != nil {
|
|
return detailStatsErrMsg{err}
|
|
}
|
|
return detailStatsFetchedMsg{top: top, byHour: byHour, byDay: byDay}
|
|
}
|
|
}
|
|
|
|
// loadSchedule reads one team's window and everyone's on-call today, the way
|
|
// every schedule command ends so the view reflects what the server now holds.
|
|
func loadSchedule(client *api.Client, teamID int64, from, to time.Time) (scheduleFetchedMsg, error) {
|
|
entries, err := client.GetSchedule(teamID, from.Format("2006-01-02"), to.Format("2006-01-02"))
|
|
if err != nil {
|
|
return scheduleFetchedMsg{}, err
|
|
}
|
|
current, err := client.GetCurrentOnCall()
|
|
if err != nil {
|
|
return scheduleFetchedMsg{}, err
|
|
}
|
|
return scheduleFetchedMsg{entries: entries, current: current}, nil
|
|
}
|
|
|
|
func fetchScheduleCmd(client *api.Client, teamID int64, from, to time.Time) tea.Cmd {
|
|
return func() tea.Msg {
|
|
msg, err := loadSchedule(client, teamID, from, to)
|
|
if err != nil {
|
|
return scheduleFetchErrMsg{err}
|
|
}
|
|
return msg
|
|
}
|
|
}
|
|
|
|
func assignScheduleCmd(client *api.Client, teamID, userID int64, dates []string, replace bool, from, to time.Time) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if _, err := client.AssignSchedule(teamID, userID, dates, replace); err != nil {
|
|
return scheduleActionErrMsg{err}
|
|
}
|
|
msg, err := loadSchedule(client, teamID, from, to)
|
|
if err != nil {
|
|
return scheduleActionErrMsg{err}
|
|
}
|
|
return msg
|
|
}
|
|
}
|
|
|
|
func deleteScheduleEntryCmd(client *api.Client, teamID, entryID int64, from, to time.Time) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := client.DeleteScheduleEntry(teamID, entryID); err != nil {
|
|
return scheduleActionErrMsg{err}
|
|
}
|
|
msg, err := loadSchedule(client, teamID, from, to)
|
|
if err != nil {
|
|
return scheduleActionErrMsg{err}
|
|
}
|
|
return msg
|
|
}
|
|
}
|
|
|
|
// fetchPickerCmd loads what the schedule's user picker offers: everyone, and who
|
|
// belongs to the team, since only members can be put on its rota.
|
|
func fetchPickerCmd(client *api.Client, teamID int64) tea.Cmd {
|
|
return func() tea.Msg {
|
|
users, err := client.ListUsers()
|
|
if err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
members, err := client.ListTeamMembers(teamID)
|
|
if err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
ids := make(map[int64]bool, len(members))
|
|
for _, mem := range members {
|
|
ids[mem.UserID] = true
|
|
}
|
|
return pickerReadyMsg{users: users, members: ids}
|
|
}
|
|
}
|
|
|
|
func fetchUsersCmd(client *api.Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
users, err := client.ListUsers()
|
|
if err != nil {
|
|
return usersFetchedMsg{} // empty on error, statusMsg set elsewhere
|
|
}
|
|
return usersFetchedMsg{users: users}
|
|
}
|
|
}
|
|
|
|
func createUserCmd(client *api.Client, username, email string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if _, err := client.CreateUser(username, email); err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
users, err := client.ListUsers()
|
|
if err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
return usersFetchedMsg{users: users}
|
|
}
|
|
}
|
|
|
|
// setUserNotifyTargetCmd points a user's pages at a topic, or clears it when
|
|
// topic is empty. It re-lists afterwards so the table shows what the server
|
|
// stored rather than what was typed.
|
|
func setUserNotifyTargetCmd(client *api.Client, userID int64, topic string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if _, err := client.SetUserNotifyTarget(userID, topic); err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
users, err := client.ListUsers()
|
|
if err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
return usersFetchedMsg{users: users}
|
|
}
|
|
}
|
|
|
|
func deleteUserCmd(client *api.Client, userID int64) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := client.DeleteUser(userID); err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
users, err := client.ListUsers()
|
|
if err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
return usersFetchedMsg{users: users}
|
|
}
|
|
}
|
|
|
|
func createAPIKeyCmd(client *api.Client, userID int64, name string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
key, err := client.CreateAPIKey(userID, name)
|
|
if err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
return apiKeyCreatedMsg{key: *key}
|
|
}
|
|
}
|
|
|
|
func fetchMeCmd(client *api.Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
me, err := client.Me()
|
|
var se *api.StatusError
|
|
if errors.As(err, &se) && se.Code == http.StatusNotFound {
|
|
return userActionErrMsg{errors.New("this server has no passwords -- needs terdut-server v0.10.2 or later")}
|
|
}
|
|
if err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
return meFetchedMsg{me: *me}
|
|
}
|
|
}
|
|
|
|
func setPasswordCmd(client *api.Client, user api.User, password, current string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := client.SetPassword(user.ID, password, current); err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
return passwordSetMsg{username: user.Username}
|
|
}
|
|
}
|
|
|
|
func deleteAPIKeyCmd(client *api.Client, userID, keyID int64) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := client.DeleteAPIKey(userID, keyID); err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
return apiKeyRevokedMsg{}
|
|
}
|
|
}
|
|
|
|
func tickCmd(interval time.Duration) tea.Cmd {
|
|
return tea.Tick(interval, func(t time.Time) tea.Msg {
|
|
return tickMsg(t)
|
|
})
|
|
}
|
|
|
|
func clearStatusCmd() tea.Cmd {
|
|
return tea.Tick(3*time.Second, func(time.Time) tea.Msg {
|
|
return clearStatusMsg{}
|
|
})
|
|
}
|