Follow terdut-server into teams: switch team, per-team schedule
CI / test (push) Successful in 12s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 14s

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:
Niklas Ye
2026-09-23 21:57:28 +02:00
parent e0c5a5cba3
commit 27008086b0
13 changed files with 1114 additions and 186 deletions
+177 -24
View File
@@ -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))
}