Files
terdut-tui/internal/tui/model.go
T
Niklas Ye ee25552a53
CI / test (push) Successful in 17s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 23s
Sign in through the server's single sign-on, with a code
The sign-in screen asks the server how it can be signed in to
(GET /api/auth/config) and offers what it finds: the password form, and
"Sign in with <provider>" when the server can do a device login. The TUI
shows a link and a short code, the person approves it in any browser, and
the next poll hands over the ordinary session, so it works over SSH where
no browser can be opened. The terminal never talks to the identity
provider.

The password form is hidden when the server has turned password login
off. `auth: sso` in config.yaml starts the SSO login straight away, but not
right after signing out, where that would sign the person straight back
in; any other value is refused when the config is read. Polling honours the
server's interval, backs off on slow_down, and gives up after repeated
failures rather than retrying forever.

A server without /api/auth/config answers 404 and is treated as passwords
only, so the sign-in screen is the one it had. Needs terdut-server v0.29.0
for SSO.
2026-09-26 21:47:13 +02:00

1547 lines
45 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
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
// 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)
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{
&noteIn, &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,
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))
}
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 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{}
})
}