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
- **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
- **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
> 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.
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
@@ -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
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
on call. Each user has their own topic, shown as a column in the Users section
and edited with `t`. A user with no topic falls back to the server's shared
fallback topic, which carries **no Acknowledge button** — the topic is shared, so
a button on it would let any subscriber acknowledge as somebody else.
When the server has a notification provider configured (ntfy today), an incident
that opens pages whoever is on call, on every notification channel they have
switched on. A channel is a provider type and an address (an ntfy topic); each
user's kinds of channel are shown as a column in the Users section and managed
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)`
when ntfy accepted the page, and `Notification to <user> failed` when it ran out
of retries. That second one is the one to look for when nobody's phone rang.
Every delivery lands on the incident's timeline: `Notified <user> (triggered via
ntfy)` when the provider accepted the page, and `Notification to <user> failed`
when it ran out of retries. That second one is the one to look for when nobody's
phone rang.
## Installation
@@ -229,7 +235,7 @@ Users section:
| Key | Action |
|-----|--------|
| `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 |
| `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 |
+21 -10
View File
@@ -623,21 +623,32 @@ func (c *Client) CreateUser(username, email string) (*User, error) {
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
// page the shared fallback topic instead — which carries no Acknowledge button,
// because anyone subscribed to it could otherwise acknowledge as somebody else.
func (c *Client) SetUserNotifyTarget(userID int64, topic string) (*User, error) {
// With no channel a user's incidents page the shared fallback instead, which
// carries no Acknowledge button, because anyone subscribed to it could
// otherwise acknowledge as somebody else.
func (c *Client) AddChannel(userID int64, typ, address string) (*NotificationChannel, error) {
body := struct {
NtfyTopic string `json:"ntfy_topic"`
}{NtfyTopic: topic}
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/notify", userID), body)
Type string `json:"type"`
Address string `json:"address"`
}{Type: typ, Address: address}
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/users/%d/channels", userID), body)
if err != nil {
return nil, err
}
var user User
return &user, c.do(req, &user)
var ch NotificationChannel
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 {
+20 -24
View File
@@ -173,8 +173,10 @@ func TestClient_IncidentEndpoints(t *testing.T) {
http.MethodDelete, "/api/incidents/7/notes/12", ""},
{"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err },
http.MethodGet, "/api/stats/incidents", ""},
{"set notify target", func(c *Client) error { _, err := c.SetUserNotifyTarget(7, "t"); return err },
http.MethodPut, "/api/users/7/notify", ""},
{"add channel", func(c *Client) error { _, err := c.AddChannel(7, "ntfy", "t"); return err },
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 {
@@ -284,40 +286,34 @@ func TestClient_RequestBodies(t *testing.T) {
}
})
t.Run("set notify target", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.SetUserNotifyTarget(3, "terdut-niklas"); err != nil {
t.Fatalf("set notify target: %v", err)
t.Run("add channel", func(t *testing.T) {
c, got := stub(t, http.StatusCreated, `{"id":4,"type":"ntfy","enabled":true}`)
ch, err := c.AddChannel(3, "ntfy", "terdut-niklas")
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)
}
})
// 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)
if ch.ID != 4 || ch.Type != "ntfy" || !ch.Enabled {
t.Errorf("unexpected channel %+v", ch)
}
})
}
func TestUser_TopicFlattensNilAndEmpty(t *testing.T) {
func TestUser_ChannelSummaryNamesKindsNeverAddresses(t *testing.T) {
var users []User
if err := json.Unmarshal([]byte(
`[{"id":1,"username":"a"},{"id":2,"username":"b","ntfy_topic":""},
{"id":3,"username":"c","ntfy_topic":"terdut-c"}]`), &users); err != nil {
`[{"id":1,"username":"a"},
{"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)
}
want := []string{"", "", "terdut-c"}
want := []string{"", "ntfy", "ntfy, mail (off)"}
for i, u := range users {
if got := u.Topic(); got != want[i] {
t.Errorf("user %d: expected topic %q, got %q", u.ID, want[i], got)
if got := u.ChannelSummary(); got != want[i] {
t.Errorf("user %d: expected %q, got %q", u.ID, want[i], got)
}
}
}
+32 -13
View File
@@ -1,6 +1,9 @@
package api
import "time"
import (
"strings"
"time"
)
// 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
@@ -147,7 +150,7 @@ const (
// Written by the server's notifier from the delivery result, not at enqueue.
// Detail carries the notification kind ("triggered", "reminder", "resolved"),
// 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"
EventNotifyFailed = "notify_failed"
)
@@ -224,20 +227,36 @@ type User struct {
// disabled user cannot sign in or use their keys.
DisabledAt *time.Time `json:"disabled_at,omitempty"`
// NtfyTopic is where this user's push notifications go. Nil and empty mean
// the same thing — no topic of their own — because the server stores a blank
// string as NULL. Their incidents fall back to the server's shared fallback
// topic, which carries no Acknowledge button.
NtfyTopic *string `json:"ntfy_topic,omitempty"`
// Channels are where this user's notifications go: a provider type and an
// address each. Only filled in for the caller's own row and, without the
// addresses, for an administrator's view of others. A user with none has
// their incidents page the server's shared fallback, which carries no
// Acknowledge button.
Channels []NotificationChannel `json:"channels,omitempty"`
}
// Topic reads the user's ntfy topic, flattening the nil and empty cases the
// server treats alike.
func (u User) Topic() string {
if u.NtfyTopic == nil {
return ""
// NotificationChannel is one way of reaching a user.
type NotificationChannel struct {
ID int64 `json:"id"`
Type string `json:"type"`
// 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.
+41 -20
View File
@@ -48,7 +48,9 @@ const (
modeUserPicker
modeTeamPicker
modeUserCreate
modeUserNotifyEdit
modeChannelMenu
modeChannelAdd
modeChannelRemoveByID
modeAPIKeyMenu
modeAPIKeyCreate
modeAPIKeyReveal
@@ -356,7 +358,8 @@ type Model struct {
selectedUser api.User
userFormInputs [2]textinput.Model
userFormFocus int
ntfyTopicInput textinput.Model
channelAddInput textinput.Model
channelRemoveIn textinput.Model
apiKeyNameInput textinput.Model
apiKeyRevokeInput textinput.Model
revealedAPIKey api.APIKey
@@ -424,9 +427,13 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
emailIn.Placeholder = "email"
emailIn.CharLimit = 128
topicIn := textinput.New()
topicIn.Placeholder = "ntfy topic — empty clears it"
topicIn.CharLimit = 128
channelIn := textinput.New()
channelIn.Placeholder = "type:address, e.g. ntfy:my-topic"
channelIn.CharLimit = 160
channelRemoveIn := textinput.New()
channelRemoveIn.Placeholder = "integer channel ID"
channelRemoveIn.CharLimit = 20
keyNameIn := textinput.New()
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{
&noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn,
&noteIn, &snoozeIn, &usernameIn, &emailIn, &channelIn, &channelRemoveIn, &keyNameIn, &revokeIn,
} {
*in = st.Input(*in)
}
@@ -507,7 +514,8 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
teamPickerTable: teamPickerT,
userManageTable: manageT,
userFormInputs: [2]textinput.Model{usernameIn, emailIn},
ntfyTopicInput: topicIn,
channelAddInput: channelIn,
channelRemoveIn: channelRemoveIn,
apiKeyNameInput: keyNameIn,
apiKeyRevokeInput: revokeIn,
pwInputs: pwIn,
@@ -668,11 +676,11 @@ func (m *Model) rebuildUserManageTable() {
m.userManageTable.SetColumns(userManageColumns(m.width))
rows := make([]table.Row, len(m.users))
for i, u := range m.users {
topic := u.Topic()
if topic == "" {
topic = "—"
channels := u.ChannelSummary()
if channels == "" {
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)
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,
// ntfy topic and API keys.
// notification channels and API keys.
func (m Model) canManageUser(u api.User) bool {
return m.isAdmin || u.ID == m.meID
}
@@ -940,17 +948,17 @@ func teamPickerColumns(width int) []table.Column {
func userManageColumns(width int) []table.Column {
createdW := 12
usernameW := 25
topicW := 22
channelsW := 22
flagsW := 14
// 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 {
emailW = 15
}
return []table.Column{
{Title: "Username", Width: usernameW},
{Title: "Email", Width: emailW},
{Title: "Ntfy Topic", Width: topicW},
{Title: "Channels", Width: channelsW},
{Title: "Flags", Width: flagsW},
{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
// topic is empty. It re-lists afterwards so the table shows what the server
// stored rather than what was typed.
func setUserNotifyTargetCmd(client *api.Client, userID int64, topic string) tea.Cmd {
// addChannelCmd gives a user a notification channel. It re-lists afterwards so
// the table shows what the server stored rather than what was typed.
func addChannelCmd(client *api.Client, userID int64, typ, address string) tea.Cmd {
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}
}
users, err := client.ListUsers()
+18 -14
View File
@@ -1,6 +1,7 @@
package tui
import (
"strings"
"testing"
"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".
func TestUserManageRows_ShowMissingTopic(t *testing.T) {
topic := "terdut-niklas"
empty := ""
func TestUserManageRows_ShowMissingChannels(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40
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"},
// The server stores a blank topic as NULL, but a stale client or an older
// server can still hand one back; it means the same thing.
{ID: 3, Username: "sam", NtfyTopic: &empty},
{ID: 3, Username: "sam", Channels: []api.NotificationChannel{
{ID: 2, Type: "ntfy", Enabled: false}}},
}
m.rebuildUserManageTable()
rows := m.userManageTable.Rows()
if rows[0][2] != "terdut-niklas" {
t.Errorf("expected the topic in the row, got %q", rows[0][2])
if rows[0][2] != "ntfy" {
t.Errorf("expected the channel kind in the row, got %q", rows[0][2])
}
if rows[1][2] != "—" || rows[2][2] != "—" {
t.Errorf("expected an em dash for nil and empty topics, got %q and %q",
rows[1][2], rows[2][2])
if strings.Contains(rows[0][2], "terdut-niklas") {
t.Errorf("the row must not carry an address, got %q", rows[0][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) {
for _, width := range []int{100, 110, 140, 200} {
// 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 {
cols []table.Column
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)
return m2, tea.Batch(inputCmd, ourCmd)
case modeUserNotifyEdit:
case modeChannelAdd:
var inputCmd tea.Cmd
m.ntfyTopicInput, inputCmd = m.ntfyTopicInput.Update(msg)
m.channelAddInput, inputCmd = m.channelAddInput.Update(msg)
m2, ourCmd := m.handleKey(msg)
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:
var inputCmd tea.Cmd
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)
case modeUserCreate:
return m.handleUserCreateKey(msg)
case modeUserNotifyEdit:
return m.handleUserNotifyEditKey(msg)
case modeChannelMenu:
return m.handleChannelMenuKey(msg)
case modeChannelAdd:
return m.handleChannelAddKey(msg)
case modeChannelRemoveByID:
return m.handleChannelRemoveKey(msg)
case modeAPIKeyMenu:
return m.handleAPIKeyMenuKey(msg)
case modePasswordSet:
@@ -721,15 +734,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
}
m.selectedUser = m.users[cursor]
if !m.canManageUser(m.selectedUser) {
cmd := m.refuseUserAction("notification topic")
cmd := m.refuseUserAction("notification channels")
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())
m.ntfyTopicInput.CursorEnd()
m.ntfyTopicInput.Focus()
m.mode = modeUserNotifyEdit
m.mode = modeChannelMenu
return m, nil
case "k":
@@ -1369,23 +1377,77 @@ func (m Model) handleUserCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil
}
// handleUserNotifyEditKey edits one user's ntfy topic.
//
// Unlike the other forms here, an empty value is not a mistake to reject: it is
// how a topic is cleared, which the server accepts and treats as NULL.
func (m Model) handleUserNotifyEditKey(msg tea.KeyMsg) (Model, tea.Cmd) {
// 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.
func (m Model) handleChannelMenuKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.ntfyTopicInput.Blur()
m.mode = modeDashboard
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":
topic := strings.TrimSpace(m.ntfyTopicInput.Value())
m.ntfyTopicInput.Blur()
typ, address, ok := parseChannelSpec(m.channelAddInput.Value())
if !ok {
m.statusMsg = "use type:address, e.g. ntfy:my-topic"
return m, clearStatusCmd()
}
m.channelAddInput.Blur()
m.mode = modeDashboard
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
+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")
}
// ── Ntfy topic ────────────────────────────────────────────────────────────
// ── Notification channels ─────────────────────────────────────────────────
// onUsers puts the model in the Users section with a loaded table.
func onUsers(users []api.User) Model {
@@ -428,42 +428,96 @@ func onUsers(users []api.User) Model {
}
func userFixtures() []api.User {
topic := "terdut-niklas"
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"},
}
}
func TestNotifyTopic_EditPrefillsTheCurrentTopic(t *testing.T) {
func TestChannels_TOpensTheMenuForTheUserUnderTheCursor(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
if m.mode != modeUserNotifyEdit {
t.Fatalf("expected the topic editor, got mode %v", m.mode)
if m.mode != modeChannelMenu {
t.Fatalf("expected the channel menu, got mode %v", m.mode)
}
if m.selectedUser.ID != 1 {
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 TestNotifyTopic_EditStartsEmptyWhenUnset(t *testing.T) {
m := onUsers(userFixtures())
m, _ = press(t, m, "t")
func TestChannels_AddStartsEmptyEveryTime(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "a")
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.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")
if got := m.ntfyTopicInput.Value(); got != "" {
t.Errorf("expected an empty field for a user with no topic, got %q", got)
m, _ = press(t, m, "a")
if got := m.channelAddInput.Value(); 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, 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
// 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) {
func TestChannels_IsUsersSectionOnly(t *testing.T) {
m := sized()
m.activeSection = sectionIncidents
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()
case modeUserCreate:
return m.renderUserCreate()
case modeUserNotifyEdit:
return m.renderUserNotifyEdit()
case modeChannelMenu:
return m.renderChannelMenu()
case modeChannelAdd:
return m.renderChannelAdd()
case modeChannelRemoveByID:
return m.renderChannelRemoveByID()
case modeAPIKeyMenu:
return m.renderAPIKeyMenu()
case modeAPIKeyCreate:
@@ -222,8 +226,14 @@ func (m Model) renderFooter() string {
case modeUserCreate:
return withStatus(" tab·next field enter·create esc·cancel")
case modeUserNotifyEdit:
return withStatus(" enter·save esc·cancel (empty clears the topic)")
case modeChannelMenu:
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:
return withStatus(" n·new key r·revoke by ID esc·back")
@@ -256,7 +266,7 @@ func (m Model) renderFooter() string {
case sectionSchedule:
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 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()))
}
@@ -748,10 +758,10 @@ func eventLabel(e api.IncidentEvent) string {
return " Resolved (all alerts stopped firing)"
case api.EventNotified:
// 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))
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.
return truncate(fmt.Sprintf(" Notification to %s failed · %s",
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
// 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".
func notifiedTarget(username string) string {
if username == "" {
return "the fallback topic"
return "the fallback"
}
return username
}
@@ -959,14 +969,44 @@ func (m Model) renderUserCreate() string {
emailLabel + m.userFormInputs[1].View() + "\n"
}
func (m Model) renderUserNotifyEdit() string {
header := fmt.Sprintf("\n Push notifications for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
func (m Model) renderChannelMenu() string {
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,
" The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+
" their incidents then page the server's shared fallback topic, which\n"+
" carries no Acknowledge button.")
label := m.styles.Selected.Render(" Topic: ")
return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n"
" The kind of channel, a colon, then where it goes — for ntfy, the topic.\n"+
" Only kinds the server has configured are accepted.")
label := m.styles.Selected.Render(" Channel: ")
return header + hint + "\n" + label + m.channelAddInput.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 {
+43 -9
View File
@@ -184,14 +184,17 @@ func TestEventLabel_KnownTypes(t *testing.T) {
"Notified bo (triggered)"},
{api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "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.
{api.IncidentEvent{Type: api.EventNotified, Detail: "triggered"},
"Notified the fallback topic (triggered)"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered: ntfy returned 502"},
"Notified the fallback (triggered)"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered via ntfy: ntfy returned 502"},
"Notification to bo failed"},
{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 {
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")
}
func TestUserNotifyEdit_SaysWhatAnEmptyValueDoes(t *testing.T) {
func TestChannelMenu_ListsKindsAndSaysWhenThereAreNone(t *testing.T) {
m := sized()
m.mode = modeUserNotifyEdit
m.mode = modeChannelMenu
m.selectedUser = api.User{ID: 1, Username: "niklas"}
mustContain(t, m.View(), "niklas", "empty to clear it", "fallback topic")
// The footer has to repeat it: that is where the reader looks for what a key does.
mustContain(t, m.renderFooter(), "empty clears the topic")
mustContain(t, m.View(), "niklas", "None", "fallback", "add a channel", "remove a channel")
mustContain(t, m.renderFooter(), "add a channel", "remove by ID")
// 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) {