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}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user