Follow terdut-server into teams: switch team, per-team schedule
terdut-server v0.12 made everything team-scoped and v0.20 is what this
client now targets. Against it the old client was wrong in three ways:
the schedule moved to /api/teams/{id}/schedule, GET /api/schedule/current
became a list with one entry per team, and users, incidents, alerts and
schedule entries all grew fields the client ignored.
T steps through all teams and then each of yours. The header names what
is showing, and incident and alert rows gain a Team column when more than
one team can appear. team: in config.yaml picks the team to start on, by
name or id; an unknown one is reported and falls back to all teams.
The schedule is one team's rota, so it shows the active team, or with
all teams showing the first one you own. Writes need an owner or an
administrator, and the picker offers only the team's members, since the
server answers 404 for anybody else. Both are checked up front and the
reason goes in the status bar, rather than surfacing as a 403 after the
user has picked somebody. Stats are not team-scoped by the server and
stay that way here.
Users shows an admin/disabled Flags column. Creating and deleting users
is administrators only, and topic, keys and password work on your own
row or on anyone's for an administrator; the server enforces the same
rule, this only explains it before the round trip.
The server has no version endpoint, so an older one is recognised by
GET /api/teams answering 404, and the TUI says it needs v0.20 or later.
Connecting now also loads /api/teams and /api/me with the key, which
means a wrong key fails on start instead of on the first list; /healthz
does not check it. There is no fallback to the pre-team paths.
Rebuilding a table whose column count changes under loaded rows panicked
inside bubbles, because it re-renders the old rows on SetColumns. The
rows are now cleared first and the cursor put back, so a refresh still
does not jump to the top.
Escalation ladders, invites, integrations and the admin settings are
left to the server's web UI. Checked against a real v0.20.1 server with
two teams, an administrator and a plain member.
Breaking: requires terdut-server v0.20.0 or later. Use terdut-tui v0.9.x
with servers before v0.12.
This commit is contained in:
+285
-60
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
@@ -108,7 +110,10 @@ func filterLabel(filter string) string {
|
||||
// ── Messages ───────────────────────────────────────────────────────────────
|
||||
|
||||
// dashboard
|
||||
type connectedMsg struct{}
|
||||
type connectedMsg struct {
|
||||
teams []api.Team
|
||||
me api.Me
|
||||
}
|
||||
type connectErrMsg struct{ err error }
|
||||
type incidentsFetchedMsg struct{ incidents []api.Incident }
|
||||
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
|
||||
@@ -143,7 +148,11 @@ type detailStatsErrMsg struct{ err error }
|
||||
// schedule
|
||||
type scheduleFetchedMsg struct {
|
||||
entries []api.ScheduleEntry
|
||||
current *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 }
|
||||
@@ -185,6 +194,15 @@ type Model struct {
|
||||
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
|
||||
|
||||
// Connection & dashboard
|
||||
connected bool
|
||||
loading bool
|
||||
@@ -242,7 +260,7 @@ type Model struct {
|
||||
scheduleWindow time.Time
|
||||
scheduleEntries []api.ScheduleEntry
|
||||
scheduleDays []scheduleDay
|
||||
currentOnCall *api.ScheduleEntry
|
||||
currentOnCall []api.ScheduleEntry
|
||||
scheduleLoading bool
|
||||
scheduleTable table.Model
|
||||
|
||||
@@ -252,6 +270,9 @@ type Model struct {
|
||||
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
|
||||
@@ -395,6 +416,14 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return connectCmd(m.client)
|
||||
}
|
||||
@@ -444,21 +473,34 @@ func setRows(t *table.Model, rows []table.Row) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
m.incidentTable.SetColumns(incidentColumns(m.width))
|
||||
setRows(&m.incidentTable, incidentRows(m.incidents))
|
||||
setTable(&m.incidentTable, incidentColumns(m.width, m.showTeamColumn()), incidentRows(m.incidents, m.showTeamColumn()))
|
||||
m.incidentTable.SetHeight(tableHeight(m.height, 8))
|
||||
}
|
||||
|
||||
func (m *Model) rebuildTable() {
|
||||
m.alertTable.SetColumns(alertColumns(m.width))
|
||||
setRows(&m.alertTable, alertRows(m.alerts))
|
||||
setTable(&m.alertTable, alertColumns(m.width, m.showTeamColumn()), alertRows(m.alerts, m.showTeamColumn()))
|
||||
m.alertTable.SetHeight(tableHeight(m.height, 8))
|
||||
}
|
||||
|
||||
func (m *Model) rebuildArchivedTable() {
|
||||
m.archivedTable.SetColumns(incidentColumns(m.width))
|
||||
setRows(&m.archivedTable, incidentRows(m.archivedIncidents))
|
||||
setTable(&m.archivedTable, incidentColumns(m.width, m.showTeamColumn()), incidentRows(m.archivedIncidents, m.showTeamColumn()))
|
||||
m.archivedTable.SetHeight(tableHeight(m.height, 8))
|
||||
}
|
||||
|
||||
@@ -470,8 +512,9 @@ func (m *Model) rebuildScheduleTable() {
|
||||
|
||||
func (m *Model) rebuildUserPickerTable() {
|
||||
m.userPickerTable.SetColumns(userPickerColumns(m.width))
|
||||
rows := make([]table.Row, len(m.users))
|
||||
for i, u := range m.users {
|
||||
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)
|
||||
@@ -486,7 +529,7 @@ func (m *Model) rebuildUserManageTable() {
|
||||
if topic == "" {
|
||||
topic = "—"
|
||||
}
|
||||
rows[i] = table.Row{u.Username, u.Email, topic, u.CreatedAt.UTC().Format("2006-01-02")}
|
||||
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))
|
||||
@@ -536,6 +579,106 @@ func (m Model) detailViewportHeight() int {
|
||||
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 {
|
||||
@@ -550,44 +693,67 @@ func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
|
||||
|
||||
// ── Column definitions ─────────────────────────────────────────────────────
|
||||
|
||||
func incidentColumns(width int) []table.Column {
|
||||
// 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
|
||||
}
|
||||
return []table.Column{
|
||||
cols := []table.Column{
|
||||
{Title: "Sev", Width: sevW},
|
||||
{Title: "Incident", Width: titleW},
|
||||
{Title: "Status", Width: statusW},
|
||||
{Title: "Assignee", Width: assigneeW},
|
||||
{Title: "Triggered", Width: timeW},
|
||||
}
|
||||
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) []table.Column {
|
||||
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
|
||||
}
|
||||
return []table.Column{
|
||||
{Title: "Name", Width: nameW},
|
||||
{Title: "Status", Width: statusW},
|
||||
{Title: "Started", Width: timeW},
|
||||
{Title: "Last Seen", Width: timeW},
|
||||
{Title: "Incident", Width: incW},
|
||||
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 {
|
||||
@@ -618,8 +784,9 @@ func userManageColumns(width int) []table.Column {
|
||||
createdW := 12
|
||||
usernameW := 25
|
||||
topicW := 22
|
||||
// 8 = bubbles' Padding(0, 1) on each of the four cells.
|
||||
emailW := width - usernameW - topicW - createdW - 8
|
||||
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
|
||||
}
|
||||
@@ -627,13 +794,14 @@ func userManageColumns(width int) []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) []table.Row {
|
||||
func incidentRows(incidents []api.Incident, showTeam bool) []table.Row {
|
||||
now := time.Now()
|
||||
rows := make([]table.Row, len(incidents))
|
||||
for i, inc := range incidents {
|
||||
@@ -651,12 +819,16 @@ func incidentRows(incidents []api.Incident) []table.Row {
|
||||
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) []table.Row {
|
||||
func alertRows(alerts []api.Alert, showTeam bool) []table.Row {
|
||||
now := time.Now()
|
||||
rows := make([]table.Row, len(alerts))
|
||||
for i, a := range alerts {
|
||||
@@ -664,11 +836,23 @@ func alertRows(alerts []api.Alert) []table.Row {
|
||||
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))
|
||||
@@ -763,19 +947,38 @@ func humanSeconds(secs *float64) string {
|
||||
|
||||
// ── 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}
|
||||
}
|
||||
return connectedMsg{}
|
||||
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}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchIncidentsCmd(client *api.Client, filter string) tea.Cmd {
|
||||
func fetchIncidentsCmd(client *api.Client, teamID int64, filter string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
status, snoozed := incidentQuery(filter)
|
||||
incidents, err := client.ListIncidents(status, false, snoozed, 500)
|
||||
incidents, err := client.ListIncidents(teamID, status, false, snoozed, 500)
|
||||
if err != nil {
|
||||
return fetchDataErrMsg{err}
|
||||
}
|
||||
@@ -783,11 +986,11 @@ func fetchIncidentsCmd(client *api.Client, filter string) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func fetchArchivedIncidentsCmd(client *api.Client) tea.Cmd {
|
||||
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(api.StatusResolved, true, false, 500)
|
||||
incidents, err := client.ListIncidents(teamID, api.StatusResolved, true, false, 500)
|
||||
if err != nil {
|
||||
return fetchDataErrMsg{err}
|
||||
}
|
||||
@@ -795,13 +998,13 @@ func fetchArchivedIncidentsCmd(client *api.Client) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAlertsCmd(client *api.Client, filter string) tea.Cmd {
|
||||
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(status, archived, 500)
|
||||
alerts, err := client.ListAlerts(teamID, status, archived, 500)
|
||||
if err != nil {
|
||||
return fetchDataErrMsg{err}
|
||||
}
|
||||
@@ -901,13 +1104,13 @@ func deleteNoteCmd(client *api.Client, id, eventID int64) tea.Cmd {
|
||||
|
||||
// archiveIncidentCmd archives from the list view, so it reloads the list rather
|
||||
// than a detail pane.
|
||||
func archiveIncidentCmd(client *api.Client, id int64, filter string) tea.Cmd {
|
||||
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(status, false, snoozed, 500)
|
||||
incidents, err := client.ListIncidents(teamID, status, false, snoozed, 500)
|
||||
if err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
@@ -915,12 +1118,12 @@ func archiveIncidentCmd(client *api.Client, id int64, filter string) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func unarchiveIncidentCmd(client *api.Client, id int64) tea.Cmd {
|
||||
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(api.StatusResolved, true, false, 500)
|
||||
incidents, err := client.ListIncidents(teamID, api.StatusResolved, true, false, 500)
|
||||
if err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
@@ -956,51 +1159,73 @@ func fetchDetailStatsCmd(client *api.Client) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func fetchScheduleCmd(client *api.Client, from, to time.Time) tea.Cmd {
|
||||
// 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 {
|
||||
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
|
||||
msg, err := loadSchedule(client, teamID, from, to)
|
||||
if err != nil {
|
||||
return scheduleFetchErrMsg{err}
|
||||
}
|
||||
current, err := client.GetCurrentOnCall()
|
||||
if err != nil {
|
||||
return scheduleFetchErrMsg{err}
|
||||
}
|
||||
return scheduleFetchedMsg{entries: entries, current: current}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func assignScheduleCmd(client *api.Client, userID int64, dates []string, replace bool, from, to time.Time) tea.Cmd {
|
||||
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(userID, dates, replace); err != nil {
|
||||
if _, err := client.AssignSchedule(teamID, userID, dates, replace); err != nil {
|
||||
return scheduleActionErrMsg{err}
|
||||
}
|
||||
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
|
||||
msg, err := loadSchedule(client, teamID, from, to)
|
||||
if err != nil {
|
||||
return scheduleActionErrMsg{err}
|
||||
}
|
||||
current, err := client.GetCurrentOnCall()
|
||||
if err != nil {
|
||||
return scheduleActionErrMsg{err}
|
||||
}
|
||||
return scheduleFetchedMsg{entries: entries, current: current}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func deleteScheduleEntryCmd(client *api.Client, entryID int64, from, to time.Time) tea.Cmd {
|
||||
func deleteScheduleEntryCmd(client *api.Client, teamID, entryID int64, from, to time.Time) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := client.DeleteScheduleEntry(entryID); err != nil {
|
||||
if err := client.DeleteScheduleEntry(teamID, entryID); err != nil {
|
||||
return scheduleActionErrMsg{err}
|
||||
}
|
||||
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
|
||||
msg, err := loadSchedule(client, teamID, from, to)
|
||||
if err != nil {
|
||||
return scheduleActionErrMsg{err}
|
||||
}
|
||||
current, err := client.GetCurrentOnCall()
|
||||
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 scheduleActionErrMsg{err}
|
||||
return userActionErrMsg{err}
|
||||
}
|
||||
return scheduleFetchedMsg{entries: entries, current: current}
|
||||
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}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
-19
@@ -143,7 +143,7 @@ func TestIncidentRows(t *testing.T) {
|
||||
{Title: "Unowned", Status: api.StatusTriggered, TriggeredAt: time.Now()},
|
||||
{Title: "Quiet", Status: api.StatusTriggered, Severity: "info",
|
||||
AssignedTo: "alice", SnoozedUntil: &future, TriggeredAt: time.Now()},
|
||||
})
|
||||
}, false)
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected 3 rows, got %d", len(rows))
|
||||
}
|
||||
@@ -159,12 +159,35 @@ func TestIncidentRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rows only carry a team cell when the columns have a Team header for it, or
|
||||
// every cell after it would sit under the wrong heading.
|
||||
func TestRows_TeamCellMatchesTeamColumn(t *testing.T) {
|
||||
now := time.Now()
|
||||
incRows := incidentRows([]api.Incident{{Title: "DiskFull", TeamName: "Ops", Status: api.StatusTriggered, TriggeredAt: now}}, true)
|
||||
if got, want := len(incRows[0]), len(incidentColumns(120, true)); got != want {
|
||||
t.Errorf("incident row has %d cells for %d columns", got, want)
|
||||
}
|
||||
if incRows[0][2] != "Ops" {
|
||||
t.Errorf("expected the team after the title, got %v", incRows[0])
|
||||
}
|
||||
alRows := alertRows([]api.Alert{{Name: "DiskFull", StartsAt: now, ReceivedAt: now}}, true)
|
||||
if got, want := len(alRows[0]), len(alertColumns(120, true)); got != want {
|
||||
t.Errorf("alert row has %d cells for %d columns", got, want)
|
||||
}
|
||||
if alRows[0][1] != "—" {
|
||||
t.Errorf("a missing team name should show an em dash, got %v", alRows[0])
|
||||
}
|
||||
if got, want := len(incidentRows([]api.Incident{{}}, false)[0]), len(incidentColumns(120, false)); got != want {
|
||||
t.Errorf("incident row has %d cells for %d columns without teams", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRows_ShowIncidentLink(t *testing.T) {
|
||||
id := int64(7)
|
||||
rows := alertRows([]api.Alert{
|
||||
{Name: "DiskFull", Status: "firing", IncidentID: &id},
|
||||
{Name: "Orphan", Status: "resolved"},
|
||||
})
|
||||
}, false)
|
||||
if rows[0][4] != "#7" {
|
||||
t.Errorf("expected #7, got %q", rows[0][4])
|
||||
}
|
||||
@@ -204,31 +227,31 @@ func TestUserManageRows_ShowMissingTopic(t *testing.T) {
|
||||
// the window width.
|
||||
func TestColumnWidthsFitTheTerminal(t *testing.T) {
|
||||
for _, width := range []int{100, 110, 140, 200} {
|
||||
for name, cols := range map[string][]int{
|
||||
"incident": widths(incidentColumns(width)),
|
||||
"alert": widths(alertColumns(width)),
|
||||
// Five cells each, or six once a Team column is added. userManageColumns
|
||||
// is five cells as well: username, email, topic, flags, created.
|
||||
for name, tc := range map[string]struct {
|
||||
cols []table.Column
|
||||
cells int
|
||||
}{
|
||||
"incident": {incidentColumns(width, false), 5},
|
||||
"incident with team": {incidentColumns(width, true), 6},
|
||||
"alert": {alertColumns(width, false), 5},
|
||||
"alert with team": {alertColumns(width, true), 6},
|
||||
"user": {userManageColumns(width), 5},
|
||||
} {
|
||||
sum := 0
|
||||
for _, w := range cols {
|
||||
for _, w := range widths(tc.cols) {
|
||||
sum += w
|
||||
}
|
||||
const padding = 10 // bubbles applies Padding(0, 1) to each of five cells
|
||||
padding := 2 * tc.cells // bubbles applies Padding(0, 1) to each cell
|
||||
if len(tc.cols) != tc.cells {
|
||||
t.Errorf("%s has %d columns, expected %d", name, len(tc.cols), tc.cells)
|
||||
}
|
||||
if sum+padding != width {
|
||||
t.Errorf("%s columns at width %d sum to %d+%d = %d",
|
||||
name, width, sum, padding, sum+padding)
|
||||
}
|
||||
}
|
||||
|
||||
// The users table is four cells, so its padding budget differs.
|
||||
sum := 0
|
||||
for _, w := range widths(userManageColumns(width)) {
|
||||
sum += w
|
||||
}
|
||||
const userPadding = 8
|
||||
if sum+userPadding != width {
|
||||
t.Errorf("user columns at width %d sum to %d+%d = %d",
|
||||
width, sum, userPadding, sum+userPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +259,7 @@ func TestColumnWidthsFitTheTerminal(t *testing.T) {
|
||||
// what must not happen is a negative or zero column.
|
||||
func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) {
|
||||
for _, width := range []int{20, 40, 60} {
|
||||
cols := append(widths(incidentColumns(width)), widths(alertColumns(width))...)
|
||||
cols := append(widths(incidentColumns(width, true)), widths(alertColumns(width, true))...)
|
||||
cols = append(cols, widths(userManageColumns(width))...)
|
||||
for _, w := range cols {
|
||||
if w < 1 {
|
||||
@@ -287,6 +310,7 @@ func scheduledWeek(entries []api.ScheduleEntry) Model {
|
||||
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
|
||||
m.width, m.height = 120, 40
|
||||
m.connected = true
|
||||
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleOwner}}
|
||||
m.activeSection = sectionSchedule
|
||||
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
|
||||
m.scheduleEntries = entries
|
||||
|
||||
+177
-24
@@ -34,12 +34,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// ── Dashboard messages ────────────────────────────────────────────────
|
||||
|
||||
case connectedMsg:
|
||||
firstConnect := len(m.teams) == 0
|
||||
m.connected = true
|
||||
m.err = nil
|
||||
m.teams = msg.teams
|
||||
m.meID = msg.me.User.ID
|
||||
m.isAdmin = msg.me.User.IsAdmin
|
||||
var statusCmd tea.Cmd
|
||||
if firstConnect && m.activeTeamID == 0 && m.defaultTeam != "" {
|
||||
if t, ok := resolveTeam(m.teams, m.defaultTeam); ok {
|
||||
m.activeTeamID = t.ID
|
||||
} else {
|
||||
m.statusMsg = fmt.Sprintf("team %q not found -- showing all teams", m.defaultTeam)
|
||||
statusCmd = clearStatusCmd()
|
||||
}
|
||||
}
|
||||
m.rebuildIncidentTable()
|
||||
m.rebuildTable()
|
||||
m.rebuildArchivedTable()
|
||||
return m, tea.Batch(
|
||||
tickCmd(m.refreshInterval),
|
||||
fetchIncidentsCmd(m.client, m.incidentFilter),
|
||||
fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter),
|
||||
fetchStatsCmd(m.client),
|
||||
statusCmd,
|
||||
)
|
||||
|
||||
case connectErrMsg:
|
||||
@@ -159,6 +176,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.rebuildUserManageTable()
|
||||
return m, nil
|
||||
|
||||
case pickerReadyMsg:
|
||||
if m.mode != modeUserPicker {
|
||||
return m, nil // the picker was closed before the lookup came back
|
||||
}
|
||||
m.users = msg.users
|
||||
m.pickerMembers = msg.members
|
||||
m.usersLoading = false
|
||||
m.rebuildUserPickerTable()
|
||||
m.rebuildUserManageTable()
|
||||
return m, nil
|
||||
|
||||
case apiKeyCreatedMsg:
|
||||
m.revealedAPIKey = msg.key
|
||||
m.mode = modeAPIKeyReveal
|
||||
@@ -226,22 +254,32 @@ func (m Model) refreshActiveSection() tea.Cmd {
|
||||
|
||||
switch m.activeSection {
|
||||
case sectionIncidents:
|
||||
return tea.Batch(fetchIncidentsCmd(m.client, m.incidentFilter), fetchStatsCmd(m.client))
|
||||
return tea.Batch(fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter), fetchStatsCmd(m.client))
|
||||
case sectionAlerts:
|
||||
return tea.Batch(fetchAlertsCmd(m.client, m.alertFilter), fetchStatsCmd(m.client))
|
||||
return tea.Batch(fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter), fetchStatsCmd(m.client))
|
||||
case sectionStats:
|
||||
// Both: fetchStatsCmd feeds the Incident Response block, the other the charts.
|
||||
return tea.Batch(fetchStatsCmd(m.client), fetchDetailStatsCmd(m.client))
|
||||
case sectionArchived:
|
||||
return fetchArchivedIncidentsCmd(m.client)
|
||||
return fetchArchivedIncidentsCmd(m.client, m.activeTeamID)
|
||||
case sectionSchedule:
|
||||
return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||
return m.fetchScheduleWindowCmd()
|
||||
case sectionUsers:
|
||||
return fetchUsersCmd(m.client)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchScheduleWindowCmd reloads the schedule window for the schedule's team, or
|
||||
// does nothing when the caller belongs to none.
|
||||
func (m Model) fetchScheduleWindowCmd() tea.Cmd {
|
||||
team, ok := m.scheduleTeam()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return fetchScheduleCmd(m.client, team.ID, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||
}
|
||||
|
||||
// routeKey passes the key to the active component then to our handler.
|
||||
func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
switch m.mode {
|
||||
@@ -410,14 +448,20 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
case sectionIncidents:
|
||||
m.incidentFilter = nextFilter(incidentFilters, m.incidentFilter)
|
||||
m.loading = true
|
||||
return m, fetchIncidentsCmd(m.client, m.incidentFilter)
|
||||
return m, fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter)
|
||||
case sectionAlerts:
|
||||
m.alertFilter = nextFilter(alertFilters, m.alertFilter)
|
||||
m.loading = true
|
||||
return m, fetchAlertsCmd(m.client, m.alertFilter)
|
||||
return m, fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "T":
|
||||
if !m.connected || len(m.teams) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
return m.switchTeam()
|
||||
|
||||
case "enter":
|
||||
switch m.activeSection {
|
||||
case sectionIncidents:
|
||||
@@ -450,14 +494,14 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
m.statusMsg = "resolve the incident before archiving it"
|
||||
return m, clearStatusCmd()
|
||||
}
|
||||
return m, archiveIncidentCmd(m.client, inc.ID, m.incidentFilter)
|
||||
return m, archiveIncidentCmd(m.client, inc.ID, m.activeTeamID, m.incidentFilter)
|
||||
case sectionArchived:
|
||||
i := m.archivedTable.Cursor()
|
||||
if i < 0 || i >= len(m.archivedIncidents) {
|
||||
return m, nil
|
||||
}
|
||||
m.archivedLoading = true
|
||||
return m, unarchiveIncidentCmd(m.client, m.archivedIncidents[i].ID)
|
||||
return m, unarchiveIncidentCmd(m.client, m.archivedIncidents[i].ID, m.activeTeamID)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
@@ -465,16 +509,18 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
case "left", "h":
|
||||
if m.activeSection == sectionSchedule {
|
||||
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, -7)
|
||||
m.scheduleLoading = true
|
||||
return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||
cmd := m.fetchScheduleWindowCmd()
|
||||
m.scheduleLoading = cmd != nil
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "right", "l":
|
||||
if m.activeSection == sectionSchedule {
|
||||
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, 7)
|
||||
m.scheduleLoading = true
|
||||
return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||
cmd := m.fetchScheduleWindowCmd()
|
||||
m.scheduleLoading = cmd != nil
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
|
||||
@@ -482,6 +528,9 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
if m.activeSection != sectionSchedule || !m.connected {
|
||||
return m, nil
|
||||
}
|
||||
if !m.checkScheduleEditable() {
|
||||
return m, clearStatusCmd()
|
||||
}
|
||||
m.pickerAssignWeek = false
|
||||
return m.openUserPicker(pickerSchedule)
|
||||
|
||||
@@ -489,6 +538,9 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
if m.activeSection != sectionSchedule || !m.connected {
|
||||
return m, nil
|
||||
}
|
||||
if !m.checkScheduleEditable() {
|
||||
return m, clearStatusCmd()
|
||||
}
|
||||
m.pickerAssignWeek = true
|
||||
return m.openUserPicker(pickerSchedule)
|
||||
|
||||
@@ -518,6 +570,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
if cursor < 0 || cursor >= len(m.users) {
|
||||
return m, nil
|
||||
}
|
||||
if !m.isAdmin {
|
||||
m.statusMsg = "only administrators can delete users"
|
||||
return m, clearStatusCmd()
|
||||
}
|
||||
m.selectedUser = m.users[cursor]
|
||||
m.confirmTarget = confirmDeleteUser
|
||||
m.mode = modeConfirm
|
||||
@@ -528,6 +584,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
if m.activeSection != sectionUsers || !m.connected {
|
||||
return m, nil
|
||||
}
|
||||
if !m.isAdmin {
|
||||
m.statusMsg = "only administrators can create users"
|
||||
return m, clearStatusCmd()
|
||||
}
|
||||
m.userFormInputs[0].Reset()
|
||||
m.userFormInputs[1].Reset()
|
||||
m.userFormFocus = 0
|
||||
@@ -545,6 +605,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedUser = m.users[cursor]
|
||||
if !m.canManageUser(m.selectedUser) {
|
||||
cmd := m.refuseUserAction("notification topic")
|
||||
return m, cmd
|
||||
}
|
||||
// Prefilled with what they have, so editing a topic does not mean
|
||||
// retyping it, and clearing one is a deliberate wipe.
|
||||
m.ntfyTopicInput.SetValue(m.selectedUser.Topic())
|
||||
@@ -562,6 +626,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedUser = m.users[cursor]
|
||||
if !m.canManageUser(m.selectedUser) {
|
||||
cmd := m.refuseUserAction("API keys")
|
||||
return m, cmd
|
||||
}
|
||||
m.mode = modeAPIKeyMenu
|
||||
return m, nil
|
||||
|
||||
@@ -574,6 +642,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.selectedUser = m.users[cursor]
|
||||
if !m.canManageUser(m.selectedUser) {
|
||||
cmd := m.refuseUserAction("password")
|
||||
return m, cmd
|
||||
}
|
||||
for i := range m.pwInputs {
|
||||
m.pwInputs[i].Reset()
|
||||
m.pwInputs[i].Blur()
|
||||
@@ -589,13 +661,20 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// refuseUserAction explains a self-or-admin action declined up front, and is what
|
||||
// the status bar clears afterwards. The server refuses these with a 403 anyway.
|
||||
func (m *Model) refuseUserAction(what string) tea.Cmd {
|
||||
m.statusMsg = "only administrators can change another user's " + what
|
||||
return clearStatusCmd()
|
||||
}
|
||||
|
||||
// loadSectionIfEmpty fetches a section's data the first time it is opened.
|
||||
func (m *Model) loadSectionIfEmpty() tea.Cmd {
|
||||
switch m.activeSection {
|
||||
case sectionAlerts:
|
||||
if len(m.alerts) == 0 {
|
||||
m.loading = true
|
||||
return fetchAlertsCmd(m.client, m.alertFilter)
|
||||
return fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter)
|
||||
}
|
||||
case sectionStats:
|
||||
if !m.statsLoaded {
|
||||
@@ -605,12 +684,13 @@ func (m *Model) loadSectionIfEmpty() tea.Cmd {
|
||||
case sectionArchived:
|
||||
if len(m.archivedIncidents) == 0 {
|
||||
m.archivedLoading = true
|
||||
return fetchArchivedIncidentsCmd(m.client)
|
||||
return fetchArchivedIncidentsCmd(m.client, m.activeTeamID)
|
||||
}
|
||||
case sectionSchedule:
|
||||
if len(m.scheduleDays) == 0 {
|
||||
m.scheduleLoading = true
|
||||
return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||
cmd := m.fetchScheduleWindowCmd()
|
||||
m.scheduleLoading = cmd != nil
|
||||
return cmd
|
||||
}
|
||||
case sectionUsers:
|
||||
if len(m.users) == 0 {
|
||||
@@ -641,6 +721,18 @@ func (m Model) openIncident(inc api.Incident) (Model, tea.Cmd) {
|
||||
func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
|
||||
m.pickerTarget = target
|
||||
m.mode = modeUserPicker
|
||||
if target == pickerSchedule {
|
||||
// Only the team's own members can go on its rota, so who they are has to
|
||||
// be known before anybody is offered.
|
||||
team, ok := m.scheduleTeam()
|
||||
if !ok {
|
||||
m.mode = modeDashboard
|
||||
return m, nil
|
||||
}
|
||||
m.pickerMembers = nil
|
||||
m.usersLoading = true
|
||||
return m, fetchPickerCmd(m.client, team.ID)
|
||||
}
|
||||
if len(m.users) == 0 {
|
||||
m.usersLoading = true
|
||||
return m, fetchUsersCmd(m.client)
|
||||
@@ -649,6 +741,63 @@ func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// switchTeam steps the active team through all teams, then each of the caller's
|
||||
// teams in turn, and reloads what depends on it. Sections that are not on screen
|
||||
// are emptied rather than fetched, so they load when next opened; the incident
|
||||
// queue is the exception because it is what the caller returns to.
|
||||
func (m Model) switchTeam() (Model, tea.Cmd) {
|
||||
next := int64(0)
|
||||
if m.activeTeamID == 0 {
|
||||
next = m.teams[0].ID
|
||||
} else {
|
||||
for i, t := range m.teams {
|
||||
if t.ID == m.activeTeamID && i+1 < len(m.teams) {
|
||||
next = m.teams[i+1].ID
|
||||
}
|
||||
}
|
||||
}
|
||||
m.activeTeamID = next
|
||||
|
||||
m.incidents, m.alerts, m.archivedIncidents = nil, nil, nil
|
||||
m.scheduleEntries, m.scheduleDays, m.currentOnCall = nil, nil, nil
|
||||
m.loading = true
|
||||
m.rebuildIncidentTable()
|
||||
m.rebuildTable()
|
||||
m.rebuildArchivedTable()
|
||||
m.rebuildScheduleTable()
|
||||
|
||||
label := "all teams"
|
||||
if t, ok := m.activeTeam(); ok {
|
||||
label = t.Name
|
||||
}
|
||||
m.statusMsg = "Team: " + label
|
||||
|
||||
cmds := []tea.Cmd{clearStatusCmd()}
|
||||
if m.activeSection != sectionIncidents {
|
||||
cmds = append(cmds, fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter))
|
||||
}
|
||||
cmds = append(cmds, m.refreshActiveSection())
|
||||
if m.activeSection == sectionSchedule {
|
||||
m.scheduleLoading = true
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// checkScheduleEditable says why a schedule change is refused, in the status
|
||||
// bar, and reports whether it may go ahead. The server enforces the same rule.
|
||||
func (m *Model) checkScheduleEditable() bool {
|
||||
team, ok := m.scheduleTeam()
|
||||
switch {
|
||||
case !ok:
|
||||
m.statusMsg = "you are not in any team"
|
||||
case !m.canEditSchedule(team):
|
||||
m.statusMsg = "only owners of " + team.Name + " can change its schedule"
|
||||
default:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// nextFilter advances a filter cycle, wrapping at the end.
|
||||
func nextFilter(cycle []string, current string) string {
|
||||
for i, f := range cycle {
|
||||
@@ -732,10 +881,10 @@ func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
if inc.ArchivedAt != nil {
|
||||
m.archivedLoading = true
|
||||
m.mode = modeDashboard
|
||||
return m, unarchiveIncidentCmd(m.client, inc.ID)
|
||||
return m, unarchiveIncidentCmd(m.client, inc.ID, m.activeTeamID)
|
||||
}
|
||||
m.mode = modeDashboard
|
||||
return m, archiveIncidentCmd(m.client, inc.ID, m.incidentFilter)
|
||||
return m, archiveIncidentCmd(m.client, inc.ID, m.activeTeamID, m.incidentFilter)
|
||||
|
||||
case "c":
|
||||
m.mode = modeNote
|
||||
@@ -895,7 +1044,8 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
m.mode = modeDashboard
|
||||
m.pendingDeleteEntry = nil
|
||||
m.scheduleLoading = true
|
||||
return m, deleteScheduleEntryCmd(m.client, entry.ID,
|
||||
team, _ := m.scheduleTeam()
|
||||
return m, deleteScheduleEntryCmd(m.client, team.ID, entry.ID,
|
||||
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||
|
||||
case confirmDeleteUser:
|
||||
@@ -912,7 +1062,8 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.scheduleLoading = true
|
||||
return m, assignScheduleCmd(m.client, p.userID, p.dates, true,
|
||||
team, _ := m.scheduleTeam()
|
||||
return m, assignScheduleCmd(m.client, team.ID, p.userID, p.dates, true,
|
||||
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||
}
|
||||
|
||||
@@ -933,10 +1084,11 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
|
||||
case "enter":
|
||||
cursor := m.userPickerTable.Cursor()
|
||||
if cursor < 0 || cursor >= len(m.users) {
|
||||
pickable := m.pickerUsers()
|
||||
if cursor < 0 || cursor >= len(pickable) {
|
||||
return m, nil
|
||||
}
|
||||
user := m.users[cursor]
|
||||
user := pickable[cursor]
|
||||
|
||||
if m.pickerTarget == pickerIncidentAssignee {
|
||||
m.mode = modeIncidentDetail
|
||||
@@ -986,7 +1138,8 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
// Nobody else loses anything, but the server rejects any date that
|
||||
// already exists — including days this same person already holds, which
|
||||
// is a no-op worth letting through silently.
|
||||
return m, assignScheduleCmd(m.client, user.ID, dates, m.scheduleOccupied(dates),
|
||||
team, _ := m.scheduleTeam()
|
||||
return m, assignScheduleCmd(m.client, team.ID, user.ID, dates, m.scheduleOccupied(dates),
|
||||
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ func sized() Model {
|
||||
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
|
||||
m.width, m.height = 120, 40
|
||||
m.connected = true
|
||||
m.isAdmin = true // most handlers are being tested for what they do, not who may
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -320,6 +321,8 @@ func pickingOnCall(entries []api.ScheduleEntry, dayIndex int, week bool) Model {
|
||||
{ID: 1, Username: "niklas", Email: "n@example.com"},
|
||||
{ID: 2, Username: "alex", Email: "a@example.com"},
|
||||
}
|
||||
m.pickerTarget = pickerSchedule
|
||||
m.pickerMembers = map[int64]bool{1: true, 2: true}
|
||||
m.rebuildUserPickerTable()
|
||||
m.scheduleTable.SetCursor(dayIndex)
|
||||
m.pickerAssignWeek = week
|
||||
@@ -693,6 +696,8 @@ func containsAll(s string, subs ...string) bool {
|
||||
func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
|
||||
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
|
||||
m.connected = true
|
||||
m.isAdmin = true
|
||||
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleOwner}}
|
||||
m.activeSection = sectionSchedule
|
||||
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -718,9 +723,253 @@ func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
|
||||
if m.mode != modeUserPicker {
|
||||
t.Fatalf("W did not open the user picker, got mode %v", m.mode)
|
||||
}
|
||||
// The picker waits for the team's members before offering anybody.
|
||||
next, _ = m.Update(pickerReadyMsg{
|
||||
users: []api.User{{ID: 1, Username: "niklas", Email: "n@example.com"}},
|
||||
members: map[int64]bool{1: true},
|
||||
})
|
||||
m = next.(Model)
|
||||
m, _ = press(t, m, "enter") // panicked here
|
||||
|
||||
if m.mode == modeUserPicker {
|
||||
t.Fatal("enter left the picker open; the assignment never went anywhere")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Teams ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func twoTeams() []api.Team {
|
||||
return []api.Team{
|
||||
{ID: 1, Name: "Ops", Role: api.RoleOwner},
|
||||
{ID: 2, Name: "Dev", Role: api.RoleMember},
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnected_LoadsTeamsAndWhoIAm(t *testing.T) {
|
||||
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
|
||||
m.width, m.height = 120, 40
|
||||
next, _ := m.Update(connectedMsg{
|
||||
teams: twoTeams(),
|
||||
me: api.Me{User: api.User{ID: 7, IsAdmin: true}},
|
||||
})
|
||||
m = next.(Model)
|
||||
if len(m.teams) != 2 || m.meID != 7 || !m.isAdmin {
|
||||
t.Errorf("expected teams, id and admin flag to be kept, got %+v %d %v", m.teams, m.meID, m.isAdmin)
|
||||
}
|
||||
if m.activeTeamID != 0 {
|
||||
t.Errorf("with no default team every team shows, got active %d", m.activeTeamID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnected_DefaultTeamFromConfig(t *testing.T) {
|
||||
for _, want := range []string{"dev", "2"} { // by name, any case, or by id
|
||||
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark).WithDefaultTeam(want)
|
||||
next, _ := m.Update(connectedMsg{teams: twoTeams()})
|
||||
if got := next.(Model).activeTeamID; got != 2 {
|
||||
t.Errorf("default team %q: expected team 2, got %d", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark).WithDefaultTeam("nope")
|
||||
next, cmd := m.Update(connectedMsg{teams: twoTeams()})
|
||||
m = next.(Model)
|
||||
if m.activeTeamID != 0 || !strings.Contains(m.statusMsg, "nope") {
|
||||
t.Errorf("an unknown default should fall back to all teams and say so, got %d %q",
|
||||
m.activeTeamID, m.statusMsg)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("expected the initial fetches to still be issued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchTeam_CyclesAllThenEachTeam(t *testing.T) {
|
||||
m := sized()
|
||||
m.teams = twoTeams()
|
||||
var seen []int64
|
||||
for i := 0; i < 4; i++ {
|
||||
var cmd tea.Cmd
|
||||
m, cmd = press(t, m, "T")
|
||||
if cmd == nil {
|
||||
t.Fatal("switching team should reload")
|
||||
}
|
||||
seen = append(seen, m.activeTeamID)
|
||||
}
|
||||
want := []int64{1, 2, 0, 1}
|
||||
for i := range want {
|
||||
if seen[i] != want[i] {
|
||||
t.Fatalf("expected the cycle %v, got %v", want, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchTeam_ClearsRowsFromTheOtherTeam(t *testing.T) {
|
||||
m := sized()
|
||||
m.teams = twoTeams()
|
||||
m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}}
|
||||
m.rebuildIncidentTable()
|
||||
m, _ = press(t, m, "T")
|
||||
if len(m.incidents) != 0 {
|
||||
t.Errorf("the previous team's incidents must not linger, got %d", len(m.incidents))
|
||||
}
|
||||
if !strings.Contains(m.statusMsg, "Ops") {
|
||||
t.Errorf("expected the status bar to name the team, got %q", m.statusMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchTeam_NoTeamsDoesNothing(t *testing.T) {
|
||||
m, cmd := press(t, sized(), "T")
|
||||
if cmd != nil || m.activeTeamID != 0 {
|
||||
t.Errorf("without teams T has nothing to switch to")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleTeam(t *testing.T) {
|
||||
m := sized()
|
||||
if _, ok := m.scheduleTeam(); ok {
|
||||
t.Error("no teams means no schedule")
|
||||
}
|
||||
m.teams = []api.Team{{ID: 2, Name: "Dev", Role: api.RoleMember}, {ID: 1, Name: "Ops", Role: api.RoleOwner}}
|
||||
if tm, _ := m.scheduleTeam(); tm.ID != 1 {
|
||||
t.Errorf("with all teams showing the one the caller owns is used, got %d", tm.ID)
|
||||
}
|
||||
m.activeTeamID = 2
|
||||
if tm, _ := m.scheduleTeam(); tm.ID != 2 {
|
||||
t.Errorf("the active team wins, got %d", tm.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedule_OnlyOwnersAndAdminsEdit(t *testing.T) {
|
||||
m := scheduledWeek(nil)
|
||||
m.isAdmin = false
|
||||
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleMember}}
|
||||
m, cmd := press(t, m, "+")
|
||||
if m.mode == modeUserPicker {
|
||||
t.Fatal("a plain member must not get as far as the picker")
|
||||
}
|
||||
if !strings.Contains(m.statusMsg, "owners of Ops") {
|
||||
t.Errorf("expected the reason in the status bar, got %q", m.statusMsg)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("the message should clear itself")
|
||||
}
|
||||
|
||||
m.isAdmin = true // administrators may edit any team's rota
|
||||
m.statusMsg = ""
|
||||
m, _ = press(t, m, "+")
|
||||
if m.mode != modeUserPicker {
|
||||
t.Errorf("an administrator should reach the picker, got mode %v", m.mode)
|
||||
}
|
||||
}
|
||||
|
||||
// The picker fetches the team's members, and offers nobody until they arrive:
|
||||
// the server answers 404 for anyone else.
|
||||
func TestSchedulePicker_OffersOnlyTeamMembers(t *testing.T) {
|
||||
m := scheduledWeek(nil)
|
||||
m, cmd := press(t, m, "+")
|
||||
if cmd == nil || !m.usersLoading {
|
||||
t.Fatal("opening the picker should start loading the members")
|
||||
}
|
||||
if got := len(m.pickerUsers()); got != 0 {
|
||||
t.Errorf("nobody should be offered before the members are known, got %d", got)
|
||||
}
|
||||
|
||||
disabled := time.Now()
|
||||
next, _ := m.Update(pickerReadyMsg{
|
||||
users: []api.User{
|
||||
{ID: 1, Username: "niklas"},
|
||||
{ID: 2, Username: "outsider"},
|
||||
{ID: 3, Username: "gone", DisabledAt: &disabled},
|
||||
},
|
||||
members: map[int64]bool{1: true, 3: true},
|
||||
})
|
||||
m = next.(Model)
|
||||
got := m.pickerUsers()
|
||||
if len(got) != 1 || got[0].Username != "niklas" {
|
||||
t.Errorf("expected only the enabled member, got %+v", got)
|
||||
}
|
||||
if rows := m.userPickerTable.Rows(); len(rows) != 1 {
|
||||
t.Errorf("the table should match, got %d rows", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsers_NonAdminsCannotCreateOrDelete(t *testing.T) {
|
||||
m := threeUsers()
|
||||
m.isAdmin = false
|
||||
m.meID = 1
|
||||
m, _ = press(t, m, "n")
|
||||
if m.mode != modeDashboard || !strings.Contains(m.statusMsg, "administrators") {
|
||||
t.Errorf("n should be refused with a reason, got mode %v %q", m.mode, m.statusMsg)
|
||||
}
|
||||
m, _ = press(t, m, "d")
|
||||
if m.mode != modeDashboard {
|
||||
t.Errorf("d should not ask to delete for a non-admin, got mode %v", m.mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsers_NonAdminManagesOnlyThemselves(t *testing.T) {
|
||||
m := threeUsers() // cursor on erik, id 3
|
||||
m.isAdmin = false
|
||||
m.meID = 1
|
||||
for _, key := range []string{"t", "k", "p"} {
|
||||
next, _ := press(t, m, key)
|
||||
if next.mode != modeDashboard {
|
||||
t.Errorf("%s on somebody else's row should be refused, got mode %v", key, next.mode)
|
||||
}
|
||||
if !strings.Contains(next.statusMsg, "another user's") {
|
||||
t.Errorf("%s: expected the reason, got %q", key, next.statusMsg)
|
||||
}
|
||||
}
|
||||
|
||||
m.userManageTable.SetCursor(0) // niklas, id 1: themselves
|
||||
if next, _ := press(t, m, "k"); next.mode != modeAPIKeyMenu {
|
||||
t.Errorf("a user may manage their own keys, got mode %v", next.mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserFlags(t *testing.T) {
|
||||
now := time.Now()
|
||||
cases := []struct {
|
||||
u api.User
|
||||
want string
|
||||
}{
|
||||
{api.User{}, "—"},
|
||||
{api.User{IsAdmin: true}, "admin"},
|
||||
{api.User{DisabledAt: &now}, "disabled"},
|
||||
{api.User{IsAdmin: true, DisabledAt: &now}, "admin,disabled"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := userFlags(c.u); got != c.want {
|
||||
t.Errorf("userFlags(%+v) = %q, want %q", c.u, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuilding a list on every refresh must not send the cursor back to the top.
|
||||
func TestRefresh_KeepsTheCursor(t *testing.T) {
|
||||
m := sized()
|
||||
m.incidents = []api.Incident{{ID: 1}, {ID: 2}, {ID: 3}}
|
||||
m.rebuildIncidentTable()
|
||||
m.incidentTable.SetCursor(2)
|
||||
next, _ := m.Update(incidentsFetchedMsg{incidents: m.incidents})
|
||||
if got := next.(Model).incidentTable.Cursor(); got != 2 {
|
||||
t.Errorf("expected the cursor to stay on row 2, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The Team column comes and goes as the team switches, and the table must
|
||||
// survive its column count changing under rows that are already loaded.
|
||||
func TestTeamColumn_AppearsWithoutPanicking(t *testing.T) {
|
||||
m := sized()
|
||||
m.incidents = []api.Incident{{ID: 1, Title: "a", TeamName: "Ops"}}
|
||||
m.rebuildIncidentTable()
|
||||
m.teams = twoTeams()
|
||||
m.rebuildIncidentTable() // five columns become six over a five-cell row
|
||||
if got := len(m.incidentTable.Columns()); got != 6 {
|
||||
t.Errorf("expected a Team column across two teams, got %d columns", got)
|
||||
}
|
||||
m.activeTeamID = 1
|
||||
m.rebuildIncidentTable()
|
||||
if got := len(m.incidentTable.Columns()); got != 5 {
|
||||
t.Errorf("expected the Team column to go when one team is chosen, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
+55
-9
@@ -27,10 +27,21 @@ func (m Model) View() string {
|
||||
|
||||
func (m Model) renderHeader() string {
|
||||
title := m.styles.Header.Render("terdut-tui")
|
||||
if len(m.teams) > 0 {
|
||||
title += m.styles.Muted.Render(" team: " + m.activeTeamLabel())
|
||||
}
|
||||
right := m.styles.Muted.Render(m.serverURL)
|
||||
return spread(title, right, m.width)
|
||||
}
|
||||
|
||||
// activeTeamLabel names what the lists are narrowed to.
|
||||
func (m Model) activeTeamLabel() string {
|
||||
if t, ok := m.activeTeam(); ok {
|
||||
return t.Name
|
||||
}
|
||||
return "all"
|
||||
}
|
||||
|
||||
func (m Model) renderTabs() string {
|
||||
var tabs []string
|
||||
for i, name := range sectionNames {
|
||||
@@ -152,15 +163,15 @@ func (m Model) renderFooter() string {
|
||||
default:
|
||||
switch m.activeSection {
|
||||
case sectionIncidents:
|
||||
return withStatus(" enter·detail x·archive f·filter r·refresh tab·section q·quit")
|
||||
return withStatus(" enter·detail x·archive f·filter " + m.teamHint() + "r·refresh tab·section q·quit")
|
||||
case sectionAlerts:
|
||||
return withStatus(" enter·detail f·filter r·refresh tab·section q·quit")
|
||||
return withStatus(" enter·detail f·filter " + m.teamHint() + "r·refresh tab·section q·quit")
|
||||
case sectionStats:
|
||||
return withStatus(" ↑/↓·scroll r·refresh tab·section q·quit")
|
||||
case sectionArchived:
|
||||
return withStatus(" enter·detail x·unarchive r·refresh tab·section q·quit")
|
||||
return withStatus(" enter·detail x·unarchive " + m.teamHint() + "r·refresh tab·section q·quit")
|
||||
case sectionSchedule:
|
||||
return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit")
|
||||
return withStatus(" +·assign day W·assign week d·del ←/→·shift week " + m.teamHint() + "tab·section r·refresh q·quit")
|
||||
case sectionUsers:
|
||||
return withStatus(" n·new user t·topic d·delete k·API keys p·password r·refresh tab·section q·quit")
|
||||
}
|
||||
@@ -168,6 +179,14 @@ func (m Model) renderFooter() string {
|
||||
}
|
||||
}
|
||||
|
||||
// teamHint is the footer's team-switch key, shown only when there is a choice.
|
||||
func (m Model) teamHint() string {
|
||||
if len(m.teams) > 1 {
|
||||
return "T·team "
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m Model) confirmPrompt() string {
|
||||
switch m.confirmTarget {
|
||||
case confirmDeleteNote:
|
||||
@@ -332,23 +351,41 @@ func (m Model) renderSchedule() string {
|
||||
return "\n" + m.styles.Muted.Render(" Loading schedule…")
|
||||
}
|
||||
|
||||
team, ok := m.scheduleTeam()
|
||||
if !ok {
|
||||
return "\n" + m.styles.Muted.Render(" You are not in any team, so there is no schedule to show.")
|
||||
}
|
||||
|
||||
var onCallLine string
|
||||
if m.currentOnCall != nil {
|
||||
onCallLine = fmt.Sprintf(" On-call today: %s",
|
||||
m.styles.AlertName.Render(m.currentOnCall.Username))
|
||||
if len(m.currentOnCall) > 0 {
|
||||
onCallLine = " On-call today: " + m.styles.AlertName.Render(m.onCallNames())
|
||||
} else {
|
||||
onCallLine = m.styles.Muted.Render(" On-call today: nobody scheduled")
|
||||
}
|
||||
|
||||
from := m.scheduleWindow
|
||||
to := m.scheduleWindow.AddDate(0, 0, 6)
|
||||
windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s — %s",
|
||||
from.Format("Jan 02"), to.Format("Jan 02, 2006")))
|
||||
windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s: %s — %s",
|
||||
team.Name, from.Format("Jan 02"), to.Format("Jan 02, 2006")))
|
||||
|
||||
header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n"
|
||||
return header + m.scheduleTable.View()
|
||||
}
|
||||
|
||||
// onCallNames lists who is on call today. With several teams each name carries
|
||||
// its team, since one person per team is on call and "alice, bob" alone would
|
||||
// not say whose.
|
||||
func (m Model) onCallNames() string {
|
||||
parts := make([]string, len(m.currentOnCall))
|
||||
for i, e := range m.currentOnCall {
|
||||
parts[i] = e.Username
|
||||
if len(m.teams) > 1 && e.TeamName != "" {
|
||||
parts[i] += " (" + e.TeamName + ")"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func (m Model) renderUserPicker() string {
|
||||
if m.usersLoading {
|
||||
return "\n" + m.styles.Muted.Render(" Loading users…")
|
||||
@@ -441,6 +478,9 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
|
||||
b.WriteString("\n " + title + strings.Repeat(" ", gap) + status + "\n\n")
|
||||
|
||||
// Timing and ownership
|
||||
if inc.TeamName != "" {
|
||||
b.WriteString(fmt.Sprintf(" Team: %s\n", inc.TeamName))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" Triggered: %s (%s)\n",
|
||||
inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt)))
|
||||
|
||||
@@ -461,6 +501,10 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
|
||||
b.WriteString(line(s.Muted, " Acked: not acknowledged"))
|
||||
}
|
||||
|
||||
if inc.EscalationLevel > 0 {
|
||||
b.WriteString(line(s.Snoozed, fmt.Sprintf(" Escalation: level %d", inc.EscalationLevel)))
|
||||
}
|
||||
|
||||
if inc.IsSnoozed() {
|
||||
b.WriteString(line(s.Snoozed, fmt.Sprintf(" Snoozed: until %s (%s)",
|
||||
inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil))))
|
||||
@@ -603,6 +647,8 @@ func eventLabel(e api.IncidentEvent) string {
|
||||
// the only thing that says why nobody's phone rang.
|
||||
return truncate(fmt.Sprintf(" Notification to %s failed · %s",
|
||||
notifiedTarget(who), e.Detail), 52)
|
||||
case api.EventDeadmanSilent:
|
||||
return " Dead man's switch went silent"
|
||||
default:
|
||||
label := " " + e.Type
|
||||
if e.Detail != "" {
|
||||
|
||||
Reference in New Issue
Block a user