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 } 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 } 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 // 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 noteCursor int 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{ ¬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, 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 } // 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 { return nil } 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.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 { 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{} } } // 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} } return incidentDetailFetchedMsg{incident: *incident, timeline: timeline} } 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) tea.Cmd { return incidentActionCmd(client, id, func() error { _, err := client.AddNote(id, content) 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{} }) }