diff --git a/README.md b/README.md index d79d06e..4bf920e 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea). - **Incident queue** — open incidents with severity, status, assignee and age, auto-refreshing - **Incident actions** — acknowledge, assign, snooze, note, resolve and archive -- **Timeline** — the full history of an incident, system events and notes together +- **Timeline** — the full history of an incident, system events, pages and notes together - **Alert feed** — the raw read-only alerts underneath, each linked to its incident - **On-call schedule** — visual calendar of who is on duty, 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 +- **User management** — add and remove users, manage API keys, set each user's ntfy topic > Requires terdut-server **v0.4.0 or later**. Earlier servers have no incidents API; > use terdut-tui v0.3.x with those. @@ -37,6 +37,22 @@ 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 + +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. + +Every delivery lands on the incident's timeline: `Notified (triggered)` +when ntfy accepted the page, and `Notification to failed` when it ran out +of retries. That second one is the one to look for when nobody's phone rang. + +Editing topics needs terdut-server **v0.6.0 or later**; the timeline entries need +**v0.7.0 or later**. Against an older server the topic column stays empty and +editing one reports the server's 404. + ## Installation Download the latest release binary for your platform from the [releases page](https://github.com/yeniklas/terdut-tui/releases), or build from source: @@ -128,5 +144,6 @@ Users section: | Key | Action | |-----|--------| | `n` | Create a user | +| `t` | Edit the user's ntfy topic — submit empty to clear it | | `d` | Delete a user | | `k` | API keys for the selected user | diff --git a/internal/api/client.go b/internal/api/client.go index 8322072..4a3bbad 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -401,6 +401,23 @@ 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. +// +// 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) { + body := struct { + NtfyTopic string `json:"ntfy_topic"` + }{NtfyTopic: topic} + req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/notify", userID), body) + if err != nil { + return nil, err + } + var user User + return &user, c.do(req, &user) +} + func (c *Client) DeleteUser(id int64) error { req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d", id)) if err != nil { diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 54e0dbb..4675f85 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -83,6 +83,8 @@ 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", ""}, } for _, tt := range tests { @@ -163,6 +165,43 @@ func TestClient_RequestBodies(t *testing.T) { t.Errorf("expected duration 90m, got %q", body.Duration) } }) + + 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) + } + if got.body != `{"ntfy_topic":"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) + } + }) +} + +func TestUser_TopicFlattensNilAndEmpty(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 { + t.Fatalf("decode: %v", err) + } + want := []string{"", "", "terdut-c"} + 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) + } + } } // The 409 on re-resolving is the server telling the user why nothing happened, diff --git a/internal/api/types.go b/internal/api/types.go index afa3a89..54d6acf 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -95,6 +95,13 @@ const ( EventUnsnoozed = "unsnoozed" EventResolved = "resolved" EventNote = "note" + + // 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. + EventNotified = "notified" + EventNotifyFailed = "notify_failed" ) // IncidentEvent is one entry in an incident's timeline. An empty Username means @@ -158,6 +165,21 @@ type User struct { Username string `json:"username"` Email string `json:"email"` CreatedAt time.Time `json:"created_at"` + + // 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"` +} + +// 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 "" + } + return *u.NtfyTopic } type APIKey struct { diff --git a/internal/tui/model.go b/internal/tui/model.go index 790763b..29fed15 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -40,6 +40,7 @@ const ( modeConfirm modeUserPicker modeUserCreate + modeUserNotifyEdit modeAPIKeyMenu modeAPIKeyCreate modeAPIKeyReveal @@ -224,6 +225,7 @@ type Model struct { selectedUser api.User userFormInputs [2]textinput.Model userFormFocus int + ntfyTopicInput textinput.Model apiKeyNameInput textinput.Model apiKeyRevokeInput textinput.Model revealedAPIKey api.APIKey @@ -273,6 +275,10 @@ 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 + keyNameIn := textinput.New() keyNameIn.Placeholder = "key name (e.g. laptop)" keyNameIn.CharLimit = 64 @@ -310,6 +316,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio userPickerTable: pickerT, userManageTable: manageT, userFormInputs: [2]textinput.Model{usernameIn, emailIn}, + ntfyTopicInput: topicIn, apiKeyNameInput: keyNameIn, apiKeyRevokeInput: revokeIn, help: help.New(), @@ -371,7 +378,11 @@ func (m *Model) rebuildUserManageTable() { m.userManageTable.SetColumns(userManageColumns(m.width)) rows := make([]table.Row, len(m.users)) for i, u := range m.users { - rows[i] = table.Row{u.Username, u.Email, u.CreatedAt.UTC().Format("2006-01-02")} + topic := u.Topic() + if topic == "" { + topic = "—" + } + rows[i] = table.Row{u.Username, u.Email, topic, u.CreatedAt.UTC().Format("2006-01-02")} } m.userManageTable.SetRows(rows) m.userManageTable.SetHeight(tableHeight(m.height, 10)) @@ -502,13 +513,16 @@ func userPickerColumns(width int) []table.Column { func userManageColumns(width int) []table.Column { createdW := 12 usernameW := 25 - emailW := width - usernameW - createdW - 8 + topicW := 22 + // 8 = bubbles' Padding(0, 1) on each of the four cells. + emailW := width - usernameW - topicW - createdW - 8 if emailW < 15 { emailW = 15 } return []table.Column{ {Title: "Username", Width: usernameW}, {Title: "Email", Width: emailW}, + {Title: "Ntfy Topic", Width: topicW}, {Title: "Created", Width: createdW}, } } @@ -909,6 +923,22 @@ 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 { + return func() tea.Msg { + if _, err := client.SetUserNotifyTarget(userID, topic); err != nil { + return userActionErrMsg{err} + } + users, err := client.ListUsers() + if err != nil { + return userActionErrMsg{err} + } + return usersFetchedMsg{users: users} + } +} + func deleteUserCmd(client *api.Client, userID int64) tea.Cmd { return func() tea.Msg { if err := client.DeleteUser(userID); err != nil { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 72df7a8..0b8bad5 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -172,6 +172,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 +// rather than leaving a blank that reads as "not loaded yet". +func TestUserManageRows_ShowMissingTopic(t *testing.T) { + topic := "terdut-niklas" + empty := "" + m := NewModel(nil, "http://test", time.Minute) + m.width, m.height = 120, 40 + m.users = []api.User{ + {ID: 1, Username: "niklas", NtfyTopic: &topic}, + {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}, + } + 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[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]) + } +} + // A previous release overflowed the terminal by two columns because the padding // budget was wrong. Columns plus bubbles' per-cell padding must land exactly on // the window width. @@ -191,6 +217,17 @@ func TestColumnWidthsFitTheTerminal(t *testing.T) { name, width, sum, padding, sum+padding) } } + + // The users table is four cells, so its padding budget differs. + sum := 0 + for _, w := range widths(userManageColumns(width)) { + sum += w + } + const userPadding = 8 + if sum+userPadding != width { + t.Errorf("user columns at width %d sum to %d+%d = %d", + width, sum, userPadding, sum+userPadding) + } } } @@ -198,7 +235,9 @@ func TestColumnWidthsFitTheTerminal(t *testing.T) { // what must not happen is a negative or zero column. func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) { for _, width := range []int{20, 40, 60} { - for _, w := range append(widths(incidentColumns(width)), widths(alertColumns(width))...) { + cols := append(widths(incidentColumns(width)), widths(alertColumns(width))...) + cols = append(cols, widths(userManageColumns(width))...) + for _, w := range cols { if w < 1 { t.Errorf("width %d produced a non-positive column %d", width, w) } diff --git a/internal/tui/update.go b/internal/tui/update.go index 898c923..8f75d69 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -258,6 +258,12 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) { m2, ourCmd := m.handleKey(msg) return m2, tea.Batch(inputCmd, ourCmd) + case modeUserNotifyEdit: + var inputCmd tea.Cmd + m.ntfyTopicInput, inputCmd = m.ntfyTopicInput.Update(msg) + m2, ourCmd := m.handleKey(msg) + return m2, tea.Batch(inputCmd, ourCmd) + case modeAPIKeyCreate: var inputCmd tea.Cmd m.apiKeyNameInput, inputCmd = m.apiKeyNameInput.Update(msg) @@ -328,6 +334,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m.handleUserPickerKey(msg) case modeUserCreate: return m.handleUserCreateKey(msg) + case modeUserNotifyEdit: + return m.handleUserNotifyEditKey(msg) case modeAPIKeyMenu: return m.handleAPIKeyMenuKey(msg) case modeAPIKeyCreate: @@ -497,6 +505,23 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { m.mode = modeUserCreate return m, nil + case "t": + if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 { + return m, nil + } + cursor := m.userManageTable.Cursor() + if cursor >= len(m.users) { + return m, nil + } + m.selectedUser = m.users[cursor] + // 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 + return m, nil + case "k": if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 { return m, nil @@ -916,6 +941,28 @@ 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) { + switch msg.String() { + case "esc": + m.ntfyTopicInput.Blur() + m.mode = modeDashboard + return m, nil + + case "enter": + topic := strings.TrimSpace(m.ntfyTopicInput.Value()) + m.ntfyTopicInput.Blur() + m.mode = modeDashboard + m.usersLoading = true + return m, setUserNotifyTargetCmd(m.client, m.selectedUser.ID, topic) + } + + return m, nil +} + func (m Model) handleAPIKeyMenuKey(msg tea.KeyMsg) (Model, tea.Cmd) { switch msg.String() { case "esc": diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 75313b0..3436fa8 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -309,6 +309,89 @@ func TestDeleteNote_ConfirmsThenActs(t *testing.T) { } } +// ── Ntfy topic ──────────────────────────────────────────────────────────── + +// onUsers puts the model in the Users section with a loaded table. +func onUsers(users []api.User) Model { + m := sized() + m.activeSection = sectionUsers + m.users = users + m.rebuildUserManageTable() + return m +} + +func userFixtures() []api.User { + topic := "terdut-niklas" + return []api.User{ + {ID: 1, Username: "niklas", Email: "niklas@example.com", NtfyTopic: &topic}, + {ID: 2, Username: "alex", Email: "alex@example.com"}, + } +} + +func TestNotifyTopic_EditPrefillsTheCurrentTopic(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.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") + m, _ = press(t, m, "esc") + m.userManageTable.SetCursor(1) + + 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) + } +} + +func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) { + m, _ := press(t, onUsers(userFixtures()), "t") + m, cmd := press(t, m, "esc") + + if cmd != nil { + t.Error("expected escape to save nothing") + } + if m.mode != modeDashboard { + t.Errorf("expected a return to the dashboard, got mode %v", m.mode) + } +} + +// 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) { + m := sized() + m.activeSection = sectionIncidents + if next, cmd := press(t, m, "t"); cmd != nil || next.mode != modeDashboard { + t.Error("expected t to do nothing outside the Users section") + } +} + func TestTab_CyclesEverySection(t *testing.T) { m := sized() if m.activeSection != sectionIncidents { diff --git a/internal/tui/view.go b/internal/tui/view.go index b916eb2..1f244a6 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -73,6 +73,8 @@ func (m Model) renderBody() string { return m.renderUserPicker() case modeUserCreate: return m.renderUserCreate() + case modeUserNotifyEdit: + return m.renderUserNotifyEdit() case modeAPIKeyMenu: return m.renderAPIKeyMenu() case modeAPIKeyCreate: @@ -127,6 +129,9 @@ 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 modeAPIKeyMenu: return withStatus(" n·new key r·revoke by ID esc·back") @@ -152,7 +157,7 @@ func (m Model) renderFooter() string { case sectionSchedule: return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit") case sectionUsers: - return withStatus(" n·new user d·delete k·API keys r·refresh tab·section q·quit") + return withStatus(" n·new user t·topic d·delete k·API keys r·refresh tab·section q·quit") } return "\n" + styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp())) } @@ -550,6 +555,15 @@ func eventLabel(e api.IncidentEvent) string { return " Resolved by " + who } 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. + return fmt.Sprintf(" Notified %s%s", notifiedTarget(who), notifyKind(e.Detail)) + case api.EventNotifyFailed: + // The detail is ": ", 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) default: label := " " + e.Type if e.Detail != "" { @@ -559,6 +573,25 @@ 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 +// between "somebody was paged" and "the on-call rota was empty". +func notifiedTarget(username string) string { + if username == "" { + return "the fallback topic" + } + return username +} + +// notifyKind renders the notification kind the server puts in Detail. It is an +// open set, so anything unrecognised is shown rather than dropped. +func notifyKind(detail string) string { + if detail == "" { + return "" + } + return " (" + detail + ")" +} + func buildAlertDetailContent(alert api.Alert, width int) string { now := time.Now() var b strings.Builder @@ -733,6 +766,16 @@ 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", styleBold.Render(m.selectedUser.Username)) + hint := line(styleMuted, + " 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 := styleSelected.Render(" Topic: ") + return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n" +} + func (m Model) renderAPIKeyMenu() string { header := fmt.Sprintf("\n API keys for %s\n", styleBold.Render(m.selectedUser.Username)) warning := line(styleMuted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.") diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index 28ca092..1f84646 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -175,6 +175,18 @@ func TestEventLabel_KnownTypes(t *testing.T) { {api.IncidentEvent{Type: api.EventResolved, Username: "bo"}, "Resolved by bo"}, // No user means the server closed it via the alert cascade. {api.IncidentEvent{Type: api.EventResolved}, "all alerts stopped firing"}, + {api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "triggered"}, + "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 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"}, + "Notification to bo failed"}, + {api.IncidentEvent{Type: api.EventNotifyFailed, Detail: "triggered: no route to host"}, + "Notification to the fallback topic failed"}, } for _, tt := range tests { t.Run(tt.event.Type, func(t *testing.T) { @@ -183,6 +195,32 @@ func TestEventLabel_KnownTypes(t *testing.T) { } } +// The timeline is where a page that never landed becomes visible, so both +// outcomes have to survive into the rendered pane. +func TestIncidentDetail_RendersNotifications(t *testing.T) { + now := time.Now() + inc := api.Incident{ID: 1, Title: "DiskFull", Status: api.StatusTriggered, TriggeredAt: now} + timeline := []api.IncidentEvent{ + {Type: api.EventTriggered, CreatedAt: now}, + {Type: api.EventNotified, Username: "niklas", Detail: "triggered", CreatedAt: now}, + {Type: api.EventNotifyFailed, Username: "niklas", + Detail: "reminder: ntfy returned 502", CreatedAt: now}, + } + + got := buildIncidentDetailContent(inc, timeline, -1, 120) + mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed") +} + +func TestUserNotifyEdit_SaysWhatAnEmptyValueDoes(t *testing.T) { + m := sized() + m.mode = modeUserNotifyEdit + 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") +} + func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) { now := time.Now() id := int64(7)