Manage notification channels instead of an ntfy topic

Mirrors the server's channels API: add/remove channels per user from the Users
section. Requires the matching server release.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-12 21:00:40 +02:00
parent 77f2d6775b
commit ec5df0449b
10 changed files with 405 additions and 174 deletions
+18 -12
View File
@@ -13,7 +13,7 @@ Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea).
- **Teams** — switch between your teams, or see all of them at once - **Teams** — switch between your teams, or see all of them at once
- **On-call schedule** — visual calendar of who is on duty in a team, assign and remove entries - **On-call schedule** — visual calendar of who is on duty in a team, assign and remove entries
- **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day - **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day
- **User management** — add and remove users, manage API keys, set each user's ntfy topic - **User management** — add and remove users, manage API keys, manage each user's notification channels
> Requires terdut-server **v0.20.0 or later**. The server became team-scoped in > Requires terdut-server **v0.20.0 or later**. The server became team-scoped in
> v0.12 and this client follows it; earlier servers answer 404 for `/api/teams` > v0.12 and this client follows it; earlier servers answer 404 for `/api/teams`
@@ -36,7 +36,7 @@ server refuses anybody else. Stats always cover all your teams; the server
cannot narrow them to one. cannot narrow them to one.
Administrators are the only users who can create or delete users, or act on Administrators are the only users who can create or delete users, or act on
someone else's password, topic or API keys. Everyone can manage their own. someone else's password, notification channels or API keys. Everyone can manage their own.
## Alerts and incidents ## Alerts and incidents
@@ -58,17 +58,23 @@ Two behaviours worth knowing before you press a key:
- **Snooze is the "not now" button.** It hides an incident from the default queue - **Snooze is the "not now" button.** It hides an incident from the default queue
without closing it, and expires on its own. without closing it, and expires on its own.
## Push notifications ## Notifications
When the server is configured for ntfy, an incident that opens pages whoever is When the server has a notification provider configured (ntfy today), an incident
on call. Each user has their own topic, shown as a column in the Users section that opens pages whoever is on call, on every notification channel they have
and edited with `t`. A user with no topic falls back to the server's shared switched on. A channel is a provider type and an address (an ntfy topic); each
fallback topic, which carries **no Acknowledge button** — the topic is shared, so user's kinds of channel are shown as a column in the Users section and managed
a button on it would let any subscriber acknowledge as somebody else. with `t`: `a` adds one as `type:address` (for example `ntfy:my-topic`), `r`
removes one by its ID. Addresses are only sent to their owner, so an
administrator sees the kind, not where it goes. Turning a channel off is done in
the web UI. A user with no channel falls back to the server's shared fallback,
which carries **no Acknowledge button** — the fallback is shared, so a button on
it would let any recipient acknowledge as somebody else.
Every delivery lands on the incident's timeline: `Notified <user> (triggered)` Every delivery lands on the incident's timeline: `Notified <user> (triggered via
when ntfy accepted the page, and `Notification to <user> failed` when it ran out ntfy)` when the provider accepted the page, and `Notification to <user> failed`
of retries. That second one is the one to look for when nobody's phone rang. when it ran out of retries. That second one is the one to look for when nobody's
phone rang.
## Installation ## Installation
@@ -229,7 +235,7 @@ Users section:
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| `n` | Create a user | | `n` | Create a user |
| `t` | Edit the user's ntfy topic — submit empty to clear it | | `t` | Manage the user's notification channels — `a` add (`type:address`), `r` remove by ID |
| `d` | Delete a user | | `d` | Delete a user |
| `k` | API keys for the selected user | | `k` | API keys for the selected user |
| `p` | Set the selected user's web UI password — asks for the current one when it is your own | | `p` | Set the selected user's web UI password — asks for the current one when it is your own |
+21 -10
View File
@@ -623,21 +623,32 @@ func (c *Client) CreateUser(username, email string) (*User, error) {
return &user, c.do(req, &user) return &user, c.do(req, &user)
} }
// SetUserNotifyTarget points a user's push notifications at an ntfy topic. // AddChannel gives a user a notification channel: a provider type the server
// has configured ("ntfy") and an address that provider understands (a topic).
// //
// An empty topic clears it: the server stores NULL, and that user's incidents // With no channel a user's incidents page the shared fallback instead, which
// page the shared fallback topic instead — which carries no Acknowledge button, // carries no Acknowledge button, because anyone subscribed to it could
// because anyone subscribed to it could otherwise acknowledge as somebody else. // otherwise acknowledge as somebody else.
func (c *Client) SetUserNotifyTarget(userID int64, topic string) (*User, error) { func (c *Client) AddChannel(userID int64, typ, address string) (*NotificationChannel, error) {
body := struct { body := struct {
NtfyTopic string `json:"ntfy_topic"` Type string `json:"type"`
}{NtfyTopic: topic} Address string `json:"address"`
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/notify", userID), body) }{Type: typ, Address: address}
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/users/%d/channels", userID), body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var user User var ch NotificationChannel
return &user, c.do(req, &user) return &ch, c.do(req, &ch)
}
// RemoveChannel deletes one of a user's notification channels.
func (c *Client) RemoveChannel(userID, channelID int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d/channels/%d", userID, channelID))
if err != nil {
return err
}
return c.do(req, nil)
} }
func (c *Client) DeleteUser(id int64) error { func (c *Client) DeleteUser(id int64) error {
+20 -24
View File
@@ -173,8 +173,10 @@ func TestClient_IncidentEndpoints(t *testing.T) {
http.MethodDelete, "/api/incidents/7/notes/12", ""}, http.MethodDelete, "/api/incidents/7/notes/12", ""},
{"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err }, {"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err },
http.MethodGet, "/api/stats/incidents", ""}, http.MethodGet, "/api/stats/incidents", ""},
{"set notify target", func(c *Client) error { _, err := c.SetUserNotifyTarget(7, "t"); return err }, {"add channel", func(c *Client) error { _, err := c.AddChannel(7, "ntfy", "t"); return err },
http.MethodPut, "/api/users/7/notify", ""}, http.MethodPost, "/api/users/7/channels", `{}`},
{"remove channel", func(c *Client) error { return c.RemoveChannel(7, 2) },
http.MethodDelete, "/api/users/7/channels/2", ""},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -284,40 +286,34 @@ func TestClient_RequestBodies(t *testing.T) {
} }
}) })
t.Run("set notify target", func(t *testing.T) { t.Run("add channel", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`) c, got := stub(t, http.StatusCreated, `{"id":4,"type":"ntfy","enabled":true}`)
if _, err := c.SetUserNotifyTarget(3, "terdut-niklas"); err != nil { ch, err := c.AddChannel(3, "ntfy", "terdut-niklas")
t.Fatalf("set notify target: %v", err) if err != nil {
t.Fatalf("add channel: %v", err)
} }
if got.body != `{"ntfy_topic":"terdut-niklas"}` { if got.body != `{"type":"ntfy","address":"terdut-niklas"}` {
t.Errorf("unexpected body %q", got.body) t.Errorf("unexpected body %q", got.body)
} }
}) if ch.ID != 4 || ch.Type != "ntfy" || !ch.Enabled {
t.Errorf("unexpected channel %+v", ch)
// Clearing has to put an explicit empty string on the wire: omitting the
// field would leave the topic untouched instead of removing it.
t.Run("clear notify target", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.SetUserNotifyTarget(3, ""); err != nil {
t.Fatalf("clear notify target: %v", err)
}
if got.body != `{"ntfy_topic":""}` {
t.Errorf("expected an explicit empty topic, got %q", got.body)
} }
}) })
} }
func TestUser_TopicFlattensNilAndEmpty(t *testing.T) { func TestUser_ChannelSummaryNamesKindsNeverAddresses(t *testing.T) {
var users []User var users []User
if err := json.Unmarshal([]byte( if err := json.Unmarshal([]byte(
`[{"id":1,"username":"a"},{"id":2,"username":"b","ntfy_topic":""}, `[{"id":1,"username":"a"},
{"id":3,"username":"c","ntfy_topic":"terdut-c"}]`), &users); err != nil { {"id":2,"username":"b","channels":[{"id":1,"type":"ntfy","enabled":true}]},
{"id":3,"username":"c","channels":[{"id":2,"type":"ntfy","address":"secret","enabled":true},
{"id":3,"type":"mail","enabled":false}]}]`), &users); err != nil {
t.Fatalf("decode: %v", err) t.Fatalf("decode: %v", err)
} }
want := []string{"", "", "terdut-c"} want := []string{"", "ntfy", "ntfy, mail (off)"}
for i, u := range users { for i, u := range users {
if got := u.Topic(); got != want[i] { if got := u.ChannelSummary(); got != want[i] {
t.Errorf("user %d: expected topic %q, got %q", u.ID, want[i], got) t.Errorf("user %d: expected %q, got %q", u.ID, want[i], got)
} }
} }
} }
+32 -13
View File
@@ -1,6 +1,9 @@
package api package api
import "time" import (
"strings"
"time"
)
// Alert is the server's record of what Alertmanager said. It is read-only: // Alert is the server's record of what Alertmanager said. It is read-only:
// acknowledging, assigning, noting and resolving all happen on the Incident an // acknowledging, assigning, noting and resolving all happen on the Incident an
@@ -147,7 +150,7 @@ const (
// Written by the server's notifier from the delivery result, not at enqueue. // Written by the server's notifier from the delivery result, not at enqueue.
// Detail carries the notification kind ("triggered", "reminder", "resolved"), // Detail carries the notification kind ("triggered", "reminder", "resolved"),
// and on a failure the reason after it. An absent user means the page went to // and on a failure the reason after it. An absent user means the page went to
// the shared fallback topic rather than to a person. // the shared fallback rather than to a person.
EventNotified = "notified" EventNotified = "notified"
EventNotifyFailed = "notify_failed" EventNotifyFailed = "notify_failed"
) )
@@ -224,20 +227,36 @@ type User struct {
// disabled user cannot sign in or use their keys. // disabled user cannot sign in or use their keys.
DisabledAt *time.Time `json:"disabled_at,omitempty"` DisabledAt *time.Time `json:"disabled_at,omitempty"`
// NtfyTopic is where this user's push notifications go. Nil and empty mean // Channels are where this user's notifications go: a provider type and an
// the same thing — no topic of their own — because the server stores a blank // address each. Only filled in for the caller's own row and, without the
// string as NULL. Their incidents fall back to the server's shared fallback // addresses, for an administrator's view of others. A user with none has
// topic, which carries no Acknowledge button. // their incidents page the server's shared fallback, which carries no
NtfyTopic *string `json:"ntfy_topic,omitempty"` // Acknowledge button.
Channels []NotificationChannel `json:"channels,omitempty"`
} }
// Topic reads the user's ntfy topic, flattening the nil and empty cases the // NotificationChannel is one way of reaching a user.
// server treats alike. type NotificationChannel struct {
func (u User) Topic() string { ID int64 `json:"id"`
if u.NtfyTopic == nil { Type string `json:"type"`
return ""
// Address is blank unless the caller owns the channel: it can be a secret.
Address string `json:"address,omitempty"`
Enabled bool `json:"enabled"`
}
// ChannelSummary lists the kinds of channel the user has, "ntfy, mail (off)",
// or "" for none. Never an address.
func (u User) ChannelSummary() string {
parts := make([]string, 0, len(u.Channels))
for _, c := range u.Channels {
if c.Enabled {
parts = append(parts, c.Type)
} else {
parts = append(parts, c.Type+" (off)")
}
} }
return *u.NtfyTopic return strings.Join(parts, ", ")
} }
// IsDisabled reports whether the account has been disabled. // IsDisabled reports whether the account has been disabled.
+41 -20
View File
@@ -48,7 +48,9 @@ const (
modeUserPicker modeUserPicker
modeTeamPicker modeTeamPicker
modeUserCreate modeUserCreate
modeUserNotifyEdit modeChannelMenu
modeChannelAdd
modeChannelRemoveByID
modeAPIKeyMenu modeAPIKeyMenu
modeAPIKeyCreate modeAPIKeyCreate
modeAPIKeyReveal modeAPIKeyReveal
@@ -356,7 +358,8 @@ type Model struct {
selectedUser api.User selectedUser api.User
userFormInputs [2]textinput.Model userFormInputs [2]textinput.Model
userFormFocus int userFormFocus int
ntfyTopicInput textinput.Model channelAddInput textinput.Model
channelRemoveIn textinput.Model
apiKeyNameInput textinput.Model apiKeyNameInput textinput.Model
apiKeyRevokeInput textinput.Model apiKeyRevokeInput textinput.Model
revealedAPIKey api.APIKey revealedAPIKey api.APIKey
@@ -424,9 +427,13 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
emailIn.Placeholder = "email" emailIn.Placeholder = "email"
emailIn.CharLimit = 128 emailIn.CharLimit = 128
topicIn := textinput.New() channelIn := textinput.New()
topicIn.Placeholder = "ntfy topic — empty clears it" channelIn.Placeholder = "type:address, e.g. ntfy:my-topic"
topicIn.CharLimit = 128 channelIn.CharLimit = 160
channelRemoveIn := textinput.New()
channelRemoveIn.Placeholder = "integer channel ID"
channelRemoveIn.CharLimit = 20
keyNameIn := textinput.New() keyNameIn := textinput.New()
keyNameIn.Placeholder = "key name (e.g. laptop)" keyNameIn.Placeholder = "key name (e.g. laptop)"
@@ -458,7 +465,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
} }
for _, in := range []*textinput.Model{ for _, in := range []*textinput.Model{
&noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn, &noteIn, &snoozeIn, &usernameIn, &emailIn, &channelIn, &channelRemoveIn, &keyNameIn, &revokeIn,
} { } {
*in = st.Input(*in) *in = st.Input(*in)
} }
@@ -507,7 +514,8 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
teamPickerTable: teamPickerT, teamPickerTable: teamPickerT,
userManageTable: manageT, userManageTable: manageT,
userFormInputs: [2]textinput.Model{usernameIn, emailIn}, userFormInputs: [2]textinput.Model{usernameIn, emailIn},
ntfyTopicInput: topicIn, channelAddInput: channelIn,
channelRemoveIn: channelRemoveIn,
apiKeyNameInput: keyNameIn, apiKeyNameInput: keyNameIn,
apiKeyRevokeInput: revokeIn, apiKeyRevokeInput: revokeIn,
pwInputs: pwIn, pwInputs: pwIn,
@@ -668,11 +676,11 @@ func (m *Model) rebuildUserManageTable() {
m.userManageTable.SetColumns(userManageColumns(m.width)) m.userManageTable.SetColumns(userManageColumns(m.width))
rows := make([]table.Row, len(m.users)) rows := make([]table.Row, len(m.users))
for i, u := range m.users { for i, u := range m.users {
topic := u.Topic() channels := u.ChannelSummary()
if topic == "" { if channels == "" {
topic = "—" channels = "—"
} }
rows[i] = table.Row{u.Username, u.Email, topic, userFlags(u), u.CreatedAt.UTC().Format("2006-01-02")} rows[i] = table.Row{u.Username, u.Email, channels, userFlags(u), u.CreatedAt.UTC().Format("2006-01-02")}
} }
setRows(&m.userManageTable, rows) setRows(&m.userManageTable, rows)
m.userManageTable.SetHeight(tableHeight(m.height, 10)) m.userManageTable.SetHeight(tableHeight(m.height, 10))
@@ -768,7 +776,7 @@ func (m Model) canEditSchedule(t api.Team) bool {
} }
// canManageUser mirrors the server's self-or-admin rule for a user's password, // canManageUser mirrors the server's self-or-admin rule for a user's password,
// ntfy topic and API keys. // notification channels and API keys.
func (m Model) canManageUser(u api.User) bool { func (m Model) canManageUser(u api.User) bool {
return m.isAdmin || u.ID == m.meID return m.isAdmin || u.ID == m.meID
} }
@@ -940,17 +948,17 @@ func teamPickerColumns(width int) []table.Column {
func userManageColumns(width int) []table.Column { func userManageColumns(width int) []table.Column {
createdW := 12 createdW := 12
usernameW := 25 usernameW := 25
topicW := 22 channelsW := 22
flagsW := 14 flagsW := 14
// 10 = bubbles' Padding(0, 1) on each of the five cells. // 10 = bubbles' Padding(0, 1) on each of the five cells.
emailW := width - usernameW - topicW - flagsW - createdW - 10 emailW := width - usernameW - channelsW - flagsW - createdW - 10
if emailW < 15 { if emailW < 15 {
emailW = 15 emailW = 15
} }
return []table.Column{ return []table.Column{
{Title: "Username", Width: usernameW}, {Title: "Username", Width: usernameW},
{Title: "Email", Width: emailW}, {Title: "Email", Width: emailW},
{Title: "Ntfy Topic", Width: topicW}, {Title: "Channels", Width: channelsW},
{Title: "Flags", Width: flagsW}, {Title: "Flags", Width: flagsW},
{Title: "Created", Width: createdW}, {Title: "Created", Width: createdW},
} }
@@ -1496,12 +1504,25 @@ func createUserCmd(client *api.Client, username, email string) tea.Cmd {
} }
} }
// setUserNotifyTargetCmd points a user's pages at a topic, or clears it when // addChannelCmd gives a user a notification channel. It re-lists afterwards so
// topic is empty. It re-lists afterwards so the table shows what the server // the table shows what the server stored rather than what was typed.
// stored rather than what was typed. func addChannelCmd(client *api.Client, userID int64, typ, address string) tea.Cmd {
func setUserNotifyTargetCmd(client *api.Client, userID int64, topic string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if _, err := client.SetUserNotifyTarget(userID, topic); err != nil { if _, err := client.AddChannel(userID, typ, address); err != nil {
return userActionErrMsg{err}
}
users, err := client.ListUsers()
if err != nil {
return userActionErrMsg{err}
}
return usersFetchedMsg{users: users}
}
}
// removeChannelCmd deletes one of a user's channels and re-lists.
func removeChannelCmd(client *api.Client, userID, channelID int64) tea.Cmd {
return func() tea.Msg {
if err := client.RemoveChannel(userID, channelID); err != nil {
return userActionErrMsg{err} return userActionErrMsg{err}
} }
users, err := client.ListUsers() users, err := client.ListUsers()
+18 -14
View File
@@ -1,6 +1,7 @@
package tui package tui
import ( import (
"strings"
"testing" "testing"
"time" "time"
@@ -196,29 +197,32 @@ func TestAlertRows_ShowIncidentLink(t *testing.T) {
} }
} }
// A user with no ntfy topic gets no pages of their own — the row has to say so // A user with no channel gets no pages of their own — the row has to say so
// rather than leaving a blank that reads as "not loaded yet". // rather than leaving a blank that reads as "not loaded yet".
func TestUserManageRows_ShowMissingTopic(t *testing.T) { func TestUserManageRows_ShowMissingChannels(t *testing.T) {
topic := "terdut-niklas"
empty := ""
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40 m.width, m.height = 120, 40
m.users = []api.User{ m.users = []api.User{
{ID: 1, Username: "niklas", NtfyTopic: &topic}, {ID: 1, Username: "niklas", Channels: []api.NotificationChannel{
{ID: 1, Type: "ntfy", Address: "terdut-niklas", Enabled: true}}},
{ID: 2, Username: "alex"}, {ID: 2, Username: "alex"},
// The server stores a blank topic as NULL, but a stale client or an older {ID: 3, Username: "sam", Channels: []api.NotificationChannel{
// server can still hand one back; it means the same thing. {ID: 2, Type: "ntfy", Enabled: false}}},
{ID: 3, Username: "sam", NtfyTopic: &empty},
} }
m.rebuildUserManageTable() m.rebuildUserManageTable()
rows := m.userManageTable.Rows() rows := m.userManageTable.Rows()
if rows[0][2] != "terdut-niklas" { if rows[0][2] != "ntfy" {
t.Errorf("expected the topic in the row, got %q", rows[0][2]) t.Errorf("expected the channel kind in the row, got %q", rows[0][2])
} }
if rows[1][2] != "—" || rows[2][2] != "—" { if strings.Contains(rows[0][2], "terdut-niklas") {
t.Errorf("expected an em dash for nil and empty topics, got %q and %q", t.Errorf("the row must not carry an address, got %q", rows[0][2])
rows[1][2], rows[2][2]) }
if rows[1][2] != "—" {
t.Errorf("expected an em dash for a user with no channel, got %q", rows[1][2])
}
if rows[2][2] != "ntfy (off)" {
t.Errorf("expected a switched-off channel to say so, got %q", rows[2][2])
} }
} }
@@ -228,7 +232,7 @@ func TestUserManageRows_ShowMissingTopic(t *testing.T) {
func TestColumnWidthsFitTheTerminal(t *testing.T) { func TestColumnWidthsFitTheTerminal(t *testing.T) {
for _, width := range []int{100, 110, 140, 200} { for _, width := range []int{100, 110, 140, 200} {
// Five cells each, or six once a Team column is added. userManageColumns // Five cells each, or six once a Team column is added. userManageColumns
// is five cells as well: username, email, topic, flags, created. // is five cells as well: username, email, channels, flags, created.
for name, tc := range map[string]struct { for name, tc := range map[string]struct {
cols []table.Column cols []table.Column
cells int cells int
+82 -20
View File
@@ -413,12 +413,21 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m2, ourCmd := m.handleKey(msg) m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd) return m2, tea.Batch(inputCmd, ourCmd)
case modeUserNotifyEdit: case modeChannelAdd:
var inputCmd tea.Cmd var inputCmd tea.Cmd
m.ntfyTopicInput, inputCmd = m.ntfyTopicInput.Update(msg) m.channelAddInput, inputCmd = m.channelAddInput.Update(msg)
m2, ourCmd := m.handleKey(msg) m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd) return m2, tea.Batch(inputCmd, ourCmd)
case modeChannelRemoveByID:
var inputCmd tea.Cmd
m.channelRemoveIn, inputCmd = m.channelRemoveIn.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeChannelMenu:
return m.handleKey(msg)
case modeAPIKeyCreate: case modeAPIKeyCreate:
var inputCmd tea.Cmd var inputCmd tea.Cmd
m.apiKeyNameInput, inputCmd = m.apiKeyNameInput.Update(msg) m.apiKeyNameInput, inputCmd = m.apiKeyNameInput.Update(msg)
@@ -507,8 +516,12 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m.handleTeamPickerKey(msg) return m.handleTeamPickerKey(msg)
case modeUserCreate: case modeUserCreate:
return m.handleUserCreateKey(msg) return m.handleUserCreateKey(msg)
case modeUserNotifyEdit: case modeChannelMenu:
return m.handleUserNotifyEditKey(msg) return m.handleChannelMenuKey(msg)
case modeChannelAdd:
return m.handleChannelAddKey(msg)
case modeChannelRemoveByID:
return m.handleChannelRemoveKey(msg)
case modeAPIKeyMenu: case modeAPIKeyMenu:
return m.handleAPIKeyMenuKey(msg) return m.handleAPIKeyMenuKey(msg)
case modePasswordSet: case modePasswordSet:
@@ -721,15 +734,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
} }
m.selectedUser = m.users[cursor] m.selectedUser = m.users[cursor]
if !m.canManageUser(m.selectedUser) { if !m.canManageUser(m.selectedUser) {
cmd := m.refuseUserAction("notification topic") cmd := m.refuseUserAction("notification channels")
return m, cmd return m, cmd
} }
// Prefilled with what they have, so editing a topic does not mean m.mode = modeChannelMenu
// retyping it, and clearing one is a deliberate wipe.
m.ntfyTopicInput.SetValue(m.selectedUser.Topic())
m.ntfyTopicInput.CursorEnd()
m.ntfyTopicInput.Focus()
m.mode = modeUserNotifyEdit
return m, nil return m, nil
case "k": case "k":
@@ -1369,23 +1377,77 @@ func (m Model) handleUserCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
// handleUserNotifyEditKey edits one user's ntfy topic. // handleChannelMenuKey is the menu for one user's notification channels: add
// // one, or remove one by its ID. The same shape as the API key menu.
// Unlike the other forms here, an empty value is not a mistake to reject: it is func (m Model) handleChannelMenuKey(msg tea.KeyMsg) (Model, tea.Cmd) {
// how a topic is cleared, which the server accepts and treats as NULL.
func (m Model) handleUserNotifyEditKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "esc": case "esc":
m.ntfyTopicInput.Blur()
m.mode = modeDashboard m.mode = modeDashboard
return m, nil return m, nil
case "a":
m.channelAddInput.Reset()
m.channelAddInput.Focus()
m.mode = modeChannelAdd
return m, nil
case "r":
m.channelRemoveIn.Reset()
m.channelRemoveIn.Focus()
m.mode = modeChannelRemoveByID
return m, nil
}
return m, nil
}
// parseChannelSpec splits "type:address" at the first colon. The address may
// itself contain colons (a URL-ish topic, say), the type never does.
func parseChannelSpec(spec string) (typ, address string, ok bool) {
typ, address, found := strings.Cut(strings.TrimSpace(spec), ":")
typ, address = strings.TrimSpace(typ), strings.TrimSpace(address)
return typ, address, found && typ != "" && address != ""
}
func (m Model) handleChannelAddKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.channelAddInput.Blur()
m.mode = modeChannelMenu
return m, nil
case "enter": case "enter":
topic := strings.TrimSpace(m.ntfyTopicInput.Value()) typ, address, ok := parseChannelSpec(m.channelAddInput.Value())
m.ntfyTopicInput.Blur() if !ok {
m.statusMsg = "use type:address, e.g. ntfy:my-topic"
return m, clearStatusCmd()
}
m.channelAddInput.Blur()
m.mode = modeDashboard m.mode = modeDashboard
m.usersLoading = true m.usersLoading = true
return m, setUserNotifyTargetCmd(m.client, m.selectedUser.ID, topic) return m, addChannelCmd(m.client, m.selectedUser.ID, typ, address)
}
return m, nil
}
func (m Model) handleChannelRemoveKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.channelRemoveIn.Blur()
m.mode = modeChannelMenu
return m, nil
case "enter":
channelID, err := strconv.ParseInt(strings.TrimSpace(m.channelRemoveIn.Value()), 10, 64)
if err != nil || channelID <= 0 {
m.statusMsg = "invalid channel ID — must be a positive integer"
return m, clearStatusCmd()
}
m.channelRemoveIn.Blur()
m.mode = modeDashboard
m.usersLoading = true
return m, removeChannelCmd(m.client, m.selectedUser.ID, channelID)
} }
return m, nil return m, nil
+74 -36
View File
@@ -416,7 +416,7 @@ func TestSchedule_ReassigningAPartlyTakenWeekAsksOnce(t *testing.T) {
mustContain(t, m.confirmPrompt(), "2 of 7 days are assigned to niklas and sam") mustContain(t, m.confirmPrompt(), "2 of 7 days are assigned to niklas and sam")
} }
// ── Ntfy topic ──────────────────────────────────────────────────────────── // ── Notification channels ─────────────────────────────────────────────────
// onUsers puts the model in the Users section with a loaded table. // onUsers puts the model in the Users section with a loaded table.
func onUsers(users []api.User) Model { func onUsers(users []api.User) Model {
@@ -428,42 +428,96 @@ func onUsers(users []api.User) Model {
} }
func userFixtures() []api.User { func userFixtures() []api.User {
topic := "terdut-niklas"
return []api.User{ return []api.User{
{ID: 1, Username: "niklas", Email: "niklas@example.com", NtfyTopic: &topic}, {ID: 1, Username: "niklas", Email: "niklas@example.com",
Channels: []api.NotificationChannel{{ID: 3, Type: "ntfy", Address: "terdut-niklas", Enabled: true}}},
{ID: 2, Username: "alex", Email: "alex@example.com"}, {ID: 2, Username: "alex", Email: "alex@example.com"},
} }
} }
func TestNotifyTopic_EditPrefillsTheCurrentTopic(t *testing.T) { func TestChannels_TOpensTheMenuForTheUserUnderTheCursor(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t") m, _ := press(t, onUsers(userFixtures()), "t")
if m.mode != modeUserNotifyEdit { if m.mode != modeChannelMenu {
t.Fatalf("expected the topic editor, got mode %v", m.mode) t.Fatalf("expected the channel menu, got mode %v", m.mode)
} }
if m.selectedUser.ID != 1 { if m.selectedUser.ID != 1 {
t.Errorf("expected the user under the cursor, got %d", m.selectedUser.ID) t.Errorf("expected the user under the cursor, got %d", m.selectedUser.ID)
} }
// Prefilled, so editing a topic does not mean retyping it from scratch.
if got := m.ntfyTopicInput.Value(); got != "terdut-niklas" {
t.Errorf("expected the current topic prefilled, got %q", got)
}
} }
// A user with no topic opens an empty field rather than the previous user's. func TestChannels_AddStartsEmptyEveryTime(t *testing.T) {
func TestNotifyTopic_EditStartsEmptyWhenUnset(t *testing.T) { m, _ := press(t, onUsers(userFixtures()), "t")
m := onUsers(userFixtures()) m, _ = press(t, m, "a")
m, _ = press(t, m, "t") if m.mode != modeChannelAdd {
t.Fatalf("expected the add form, got mode %v", m.mode)
}
m = typeInto(t, m, "ntfy:abandoned")
m, _ = press(t, m, "esc") m, _ = press(t, m, "esc")
m.userManageTable.SetCursor(1) if m.mode != modeChannelMenu {
t.Fatalf("escape should go back to the menu, got mode %v", m.mode)
}
m, _ = press(t, m, "t") m, _ = press(t, m, "a")
if got := m.ntfyTopicInput.Value(); got != "" { if got := m.channelAddInput.Value(); got != "" {
t.Errorf("expected an empty field for a user with no topic, got %q", got) t.Errorf("expected an empty field, got %q", got)
} }
} }
func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) { func TestChannels_AddRejectsASpecWithoutTypeAndAddress(t *testing.T) {
for _, spec := range []string{"", "ntfy", "ntfy:", ":topic"} {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "a")
m.channelAddInput.SetValue(spec)
m, cmd := press(t, m, "enter")
if m.mode != modeChannelAdd {
t.Errorf("%q: expected to stay on the form, got mode %v", spec, m.mode)
}
if m.statusMsg == "" {
t.Errorf("%q: expected an explanation", spec)
}
_ = cmd // only the status-clearing tick
}
}
func TestChannels_AddSubmitsAndReturnsToTheDashboard(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "a")
m.channelAddInput.SetValue("ntfy:my-topic")
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("expected adding a channel to call the server")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
}
func TestChannels_RemoveNeedsAPositiveID(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "r")
if m.mode != modeChannelRemoveByID {
t.Fatalf("expected the remove form, got mode %v", m.mode)
}
for _, bad := range []string{"", "abc", "0", "-3"} {
m.channelRemoveIn.SetValue(bad)
next, _ := press(t, m, "enter")
if next.mode != modeChannelRemoveByID {
t.Errorf("%q: expected to stay on the form, got mode %v", bad, next.mode)
}
}
m.channelRemoveIn.SetValue("3")
m, cmd := press(t, m, "enter")
if cmd == nil || m.mode != modeDashboard {
t.Errorf("expected a valid ID to call the server and return, mode %v", m.mode)
}
}
func TestChannels_EscapeAbandonsWithoutSaving(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t") m, _ := press(t, onUsers(userFixtures()), "t")
m, cmd := press(t, m, "esc") m, cmd := press(t, m, "esc")
@@ -475,23 +529,7 @@ func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) {
} }
} }
// Clearing a topic is a real action, not a no-op: it is how a user is taken off func TestChannels_IsUsersSectionOnly(t *testing.T) {
// their own topic and back onto the shared fallback. Contrast the snooze prompt,
// where an empty value means "I changed my mind".
func TestNotifyTopic_EmptyInputStillSubmits(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m.ntfyTopicInput.SetValue("")
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("expected clearing the topic to call the server")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
}
func TestNotifyTopic_IsUsersSectionOnly(t *testing.T) {
m := sized() m := sized()
m.activeSection = sectionIncidents m.activeSection = sectionIncidents
if next, cmd := press(t, m, "t"); cmd != nil || next.mode != modeDashboard { if next, cmd := press(t, m, "t"); cmd != nil || next.mode != modeDashboard {
+56 -16
View File
@@ -161,8 +161,12 @@ func (m Model) renderBody() string {
return m.renderTeamPicker() return m.renderTeamPicker()
case modeUserCreate: case modeUserCreate:
return m.renderUserCreate() return m.renderUserCreate()
case modeUserNotifyEdit: case modeChannelMenu:
return m.renderUserNotifyEdit() return m.renderChannelMenu()
case modeChannelAdd:
return m.renderChannelAdd()
case modeChannelRemoveByID:
return m.renderChannelRemoveByID()
case modeAPIKeyMenu: case modeAPIKeyMenu:
return m.renderAPIKeyMenu() return m.renderAPIKeyMenu()
case modeAPIKeyCreate: case modeAPIKeyCreate:
@@ -222,8 +226,14 @@ func (m Model) renderFooter() string {
case modeUserCreate: case modeUserCreate:
return withStatus(" tab·next field enter·create esc·cancel") return withStatus(" tab·next field enter·create esc·cancel")
case modeUserNotifyEdit: case modeChannelMenu:
return withStatus(" enter·save esc·cancel (empty clears the topic)") return withStatus(" a·add a channel r·remove by ID esc·back")
case modeChannelAdd:
return withStatus(" enter·add esc·back (type:address)")
case modeChannelRemoveByID:
return withStatus(" enter·remove esc·back")
case modeAPIKeyMenu: case modeAPIKeyMenu:
return withStatus(" n·new key r·revoke by ID esc·back") return withStatus(" n·new key r·revoke by ID esc·back")
@@ -256,7 +266,7 @@ func (m Model) renderFooter() string {
case sectionSchedule: case sectionSchedule:
return withStatus(" +·assign day W·assign week d·del ←/→·shift week " + m.teamHint() + "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: case sectionUsers:
return withStatus(" n·new user t·topic d·delete k·API keys p·password r·refresh L·sign out q·quit") return withStatus(" n·new user t·channels d·delete k·API keys p·password r·refresh L·sign out q·quit")
} }
return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp())) return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
} }
@@ -748,10 +758,10 @@ func eventLabel(e api.IncidentEvent) string {
return " Resolved (all alerts stopped firing)" return " Resolved (all alerts stopped firing)"
case api.EventNotified: case api.EventNotified:
// An empty username here is not "the server acted": it means the page // An empty username here is not "the server acted": it means the page
// went to the shared fallback topic, so it belongs to nobody. // went to the shared fallback, so it belongs to nobody.
return fmt.Sprintf(" Notified %s%s", notifiedTarget(who), notifyKind(e.Detail)) return fmt.Sprintf(" Notified %s%s", notifiedTarget(who), notifyKind(e.Detail))
case api.EventNotifyFailed: case api.EventNotifyFailed:
// The detail is "<kind>: <reason>", and the reason is the point — it is // The detail is "<kind> via <channel>: <reason>", and the reason is the point — it is
// the only thing that says why nobody's phone rang. // the only thing that says why nobody's phone rang.
return truncate(fmt.Sprintf(" Notification to %s failed · %s", return truncate(fmt.Sprintf(" Notification to %s failed · %s",
notifiedTarget(who), e.Detail), 52) notifiedTarget(who), e.Detail), 52)
@@ -767,11 +777,11 @@ func eventLabel(e api.IncidentEvent) string {
} }
// notifiedTarget names who a page reached. The server attaches no user when it // notifiedTarget names who a page reached. The server attaches no user when it
// published to the shared fallback topic, and saying so is the difference // published to the shared fallback, and saying so is the difference
// between "somebody was paged" and "the on-call rota was empty". // between "somebody was paged" and "the on-call rota was empty".
func notifiedTarget(username string) string { func notifiedTarget(username string) string {
if username == "" { if username == "" {
return "the fallback topic" return "the fallback"
} }
return username return username
} }
@@ -959,14 +969,44 @@ func (m Model) renderUserCreate() string {
emailLabel + m.userFormInputs[1].View() + "\n" emailLabel + m.userFormInputs[1].View() + "\n"
} }
func (m Model) renderUserNotifyEdit() string { func (m Model) renderChannelMenu() string {
header := fmt.Sprintf("\n Push notifications for %s\n", m.styles.Bold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n Notification channels for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
var list string
if len(m.selectedUser.Channels) == 0 {
list = line(m.styles.Muted, " None — their incidents page the server's shared fallback,\n"+
" which carries no Acknowledge button.")
}
for _, c := range m.selectedUser.Channels {
state := ""
if !c.Enabled {
state = " (off)"
}
// The address is only sent to its owner; an administrator sees the kind.
address := c.Address
if address == "" {
address = "(address hidden)"
}
list += fmt.Sprintf(" %s %s %s%s\n", m.styles.Accent.Render(fmt.Sprintf("#%d", c.ID)), c.Type, address, state)
}
options := "\n" +
m.styles.Accent.Render(" a") + " · add a channel (type:address, e.g. ntfy:my-topic)\n" +
m.styles.Accent.Render(" r") + " · remove a channel by ID\n"
return header + list + options
}
func (m Model) renderChannelAdd() string {
header := fmt.Sprintf("\n New channel for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
hint := line(m.styles.Muted, hint := line(m.styles.Muted,
" The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+ " The kind of channel, a colon, then where it goes — for ntfy, the topic.\n"+
" their incidents then page the server's shared fallback topic, which\n"+ " Only kinds the server has configured are accepted.")
" carries no Acknowledge button.") label := m.styles.Selected.Render(" Channel: ")
label := m.styles.Selected.Render(" Topic: ") return header + hint + "\n" + label + m.channelAddInput.View() + "\n"
return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n" }
func (m Model) renderChannelRemoveByID() string {
header := fmt.Sprintf("\n Remove a channel from %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
label := m.styles.Selected.Render(" Channel ID: ")
return header + label + m.channelRemoveIn.View() + "\n"
} }
func (m Model) renderPasswordSet() string { func (m Model) renderPasswordSet() string {
+43 -9
View File
@@ -184,14 +184,17 @@ func TestEventLabel_KnownTypes(t *testing.T) {
"Notified bo (triggered)"}, "Notified bo (triggered)"},
{api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "reminder"}, {api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "reminder"},
"Notified bo (reminder)"}, "Notified bo (reminder)"},
// On a notification, no user means the shared fallback topic — not that // The server names the channel type after the kind.
{api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "triggered via ntfy"},
"Notified bo (triggered via ntfy)"},
// On a notification, no user means the shared fallback — not that
// the server acted on its own. // the server acted on its own.
{api.IncidentEvent{Type: api.EventNotified, Detail: "triggered"}, {api.IncidentEvent{Type: api.EventNotified, Detail: "triggered"},
"Notified the fallback topic (triggered)"}, "Notified the fallback (triggered)"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered: ntfy returned 502"}, {api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered via ntfy: ntfy returned 502"},
"Notification to bo failed"}, "Notification to bo failed"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Detail: "triggered: no route to host"}, {api.IncidentEvent{Type: api.EventNotifyFailed, Detail: "triggered: no route to host"},
"Notification to the fallback topic failed"}, "Notification to the fallback failed"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.event.Type, func(t *testing.T) { t.Run(tt.event.Type, func(t *testing.T) {
@@ -216,14 +219,45 @@ func TestIncidentDetail_RendersNotifications(t *testing.T) {
mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed") mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed")
} }
func TestUserNotifyEdit_SaysWhatAnEmptyValueDoes(t *testing.T) { func TestChannelMenu_ListsKindsAndSaysWhenThereAreNone(t *testing.T) {
m := sized() m := sized()
m.mode = modeUserNotifyEdit m.mode = modeChannelMenu
m.selectedUser = api.User{ID: 1, Username: "niklas"} m.selectedUser = api.User{ID: 1, Username: "niklas"}
mustContain(t, m.View(), "niklas", "empty to clear it", "fallback topic") mustContain(t, m.View(), "niklas", "None", "fallback", "add a channel", "remove a channel")
// The footer has to repeat it: that is where the reader looks for what a key does. mustContain(t, m.renderFooter(), "add a channel", "remove by ID")
mustContain(t, m.renderFooter(), "empty clears the topic")
// An administrator sees the kind and ID of somebody else's channel, never where it goes.
m.selectedUser.Channels = []api.NotificationChannel{{ID: 7, Type: "ntfy", Enabled: false}}
mustContain(t, m.View(), "#7", "ntfy", "(address hidden)", "(off)")
}
func TestChannelAdd_ExplainsTheSpec(t *testing.T) {
m := sized()
m.mode = modeChannelAdd
m.selectedUser = api.User{ID: 1, Username: "niklas"}
mustContain(t, m.View(), "niklas", "type", "colon")
mustContain(t, m.renderFooter(), "type:address")
}
func TestParseChannelSpec(t *testing.T) {
for spec, want := range map[string][2]string{
"ntfy:my-topic": {"ntfy", "my-topic"},
" ntfy : my-topic ": {"ntfy", "my-topic"},
"mail:a@b.example": {"mail", "a@b.example"},
"ntfy:a:b": {"ntfy", "a:b"},
} {
typ, address, ok := parseChannelSpec(spec)
if !ok || typ != want[0] || address != want[1] {
t.Errorf("%q: got %q, %q, %v", spec, typ, address, ok)
}
}
for _, bad := range []string{"", "ntfy", "ntfy:", ":topic", " "} {
if _, _, ok := parseChannelSpec(bad); ok {
t.Errorf("%q should not parse", bad)
}
}
} }
func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) { func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {