feat: ntfy topics per user, and notifications on the timeline
Release / test (push) Failing after 6s
Release / release (push) Has been skipped
Release / build (amd64, darwin) (push) Has been skipped
Release / build (amd64, linux) (push) Has been skipped
Release / build (arm64, darwin) (push) Has been skipped
Release / build (arm64, linux) (push) Has been skipped
Release / test (push) Failing after 6s
Release / release (push) Has been skipped
Release / build (amd64, darwin) (push) Has been skipped
Release / build (amd64, linux) (push) Has been skipped
Release / build (arm64, darwin) (push) Has been skipped
Release / build (arm64, linux) (push) Has been skipped
terdut-server pages the on-call person through ntfy, but none of it was reachable from here. A user's topic could only be set with curl, so a new user silently got no pages and quietly fell back to the shared fallback topic — which carries no Acknowledge button. And nothing said whether anybody had been paged at all. The Users section grows an Ntfy Topic column and t to edit it, prefilled with the current value. Submitting an empty field clears the topic rather than being rejected as a mistake: clearing is how somebody is taken off their own topic, and it is what the server means by an empty string. Nil and empty arrive as the same thing, because the server stores a blank topic as NULL, so User.Topic flattens the two instead of leaving every caller to. The incident timeline renders the server's notified and notify_failed events. No new fetch — the timeline endpoint already carried them, and unknown types already fell through to a generic label; this is about saying something useful. An event with no user means the fallback topic, not "the server acted", which is the difference between somebody having been paged and the rota having been empty. Both need terdut-server v0.6.0 or later, and the timeline entries a server newer than that. Against an older one the column stays empty and editing a topic reports the server's 404, which is the honest answer.
This commit is contained in:
@@ -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 queue** — open incidents with severity, status, assignee and age, auto-refreshing
|
||||||
- **Incident actions** — acknowledge, assign, snooze, note, resolve and archive
|
- **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
|
- **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
|
- **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
|
- **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;
|
> Requires terdut-server **v0.4.0 or later**. Earlier servers have no incidents API;
|
||||||
> use terdut-tui v0.3.x with those.
|
> 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
|
- **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
|
||||||
|
|
||||||
|
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 <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.
|
||||||
|
|
||||||
|
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
|
## Installation
|
||||||
|
|
||||||
Download the latest release binary for your platform from the [releases page](https://github.com/yeniklas/terdut-tui/releases), or build from source:
|
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 |
|
| Key | Action |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
| `n` | Create a user |
|
| `n` | Create a user |
|
||||||
|
| `t` | Edit the user's ntfy topic — submit empty to clear it |
|
||||||
| `d` | Delete a user |
|
| `d` | Delete a user |
|
||||||
| `k` | API keys for the selected user |
|
| `k` | API keys for the selected user |
|
||||||
|
|||||||
@@ -401,6 +401,23 @@ 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.
|
||||||
|
//
|
||||||
|
// 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 {
|
func (c *Client) DeleteUser(id int64) error {
|
||||||
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d", id))
|
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d", id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ 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 },
|
||||||
|
http.MethodPut, "/api/users/7/notify", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -163,6 +165,43 @@ func TestClient_RequestBodies(t *testing.T) {
|
|||||||
t.Errorf("expected duration 90m, got %q", body.Duration)
|
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,
|
// The 409 on re-resolving is the server telling the user why nothing happened,
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ const (
|
|||||||
EventUnsnoozed = "unsnoozed"
|
EventUnsnoozed = "unsnoozed"
|
||||||
EventResolved = "resolved"
|
EventResolved = "resolved"
|
||||||
EventNote = "note"
|
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
|
// IncidentEvent is one entry in an incident's timeline. An empty Username means
|
||||||
@@ -158,6 +165,21 @@ type User struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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 {
|
type APIKey struct {
|
||||||
|
|||||||
+32
-2
@@ -40,6 +40,7 @@ const (
|
|||||||
modeConfirm
|
modeConfirm
|
||||||
modeUserPicker
|
modeUserPicker
|
||||||
modeUserCreate
|
modeUserCreate
|
||||||
|
modeUserNotifyEdit
|
||||||
modeAPIKeyMenu
|
modeAPIKeyMenu
|
||||||
modeAPIKeyCreate
|
modeAPIKeyCreate
|
||||||
modeAPIKeyReveal
|
modeAPIKeyReveal
|
||||||
@@ -224,6 +225,7 @@ type Model struct {
|
|||||||
selectedUser api.User
|
selectedUser api.User
|
||||||
userFormInputs [2]textinput.Model
|
userFormInputs [2]textinput.Model
|
||||||
userFormFocus int
|
userFormFocus int
|
||||||
|
ntfyTopicInput textinput.Model
|
||||||
apiKeyNameInput textinput.Model
|
apiKeyNameInput textinput.Model
|
||||||
apiKeyRevokeInput textinput.Model
|
apiKeyRevokeInput textinput.Model
|
||||||
revealedAPIKey api.APIKey
|
revealedAPIKey api.APIKey
|
||||||
@@ -273,6 +275,10 @@ 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()
|
||||||
|
topicIn.Placeholder = "ntfy topic — empty clears it"
|
||||||
|
topicIn.CharLimit = 128
|
||||||
|
|
||||||
keyNameIn := textinput.New()
|
keyNameIn := textinput.New()
|
||||||
keyNameIn.Placeholder = "key name (e.g. laptop)"
|
keyNameIn.Placeholder = "key name (e.g. laptop)"
|
||||||
keyNameIn.CharLimit = 64
|
keyNameIn.CharLimit = 64
|
||||||
@@ -310,6 +316,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
|
|||||||
userPickerTable: pickerT,
|
userPickerTable: pickerT,
|
||||||
userManageTable: manageT,
|
userManageTable: manageT,
|
||||||
userFormInputs: [2]textinput.Model{usernameIn, emailIn},
|
userFormInputs: [2]textinput.Model{usernameIn, emailIn},
|
||||||
|
ntfyTopicInput: topicIn,
|
||||||
apiKeyNameInput: keyNameIn,
|
apiKeyNameInput: keyNameIn,
|
||||||
apiKeyRevokeInput: revokeIn,
|
apiKeyRevokeInput: revokeIn,
|
||||||
help: help.New(),
|
help: help.New(),
|
||||||
@@ -371,7 +378,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 {
|
||||||
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.SetRows(rows)
|
||||||
m.userManageTable.SetHeight(tableHeight(m.height, 10))
|
m.userManageTable.SetHeight(tableHeight(m.height, 10))
|
||||||
@@ -502,13 +513,16 @@ func userPickerColumns(width int) []table.Column {
|
|||||||
func userManageColumns(width int) []table.Column {
|
func userManageColumns(width int) []table.Column {
|
||||||
createdW := 12
|
createdW := 12
|
||||||
usernameW := 25
|
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 {
|
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: "Created", Width: createdW},
|
{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 {
|
func deleteUserCmd(client *api.Client, userID int64) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if err := client.DeleteUser(userID); err != nil {
|
if err := client.DeleteUser(userID); err != nil {
|
||||||
|
|||||||
@@ -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
|
// 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
|
// budget was wrong. Columns plus bubbles' per-cell padding must land exactly on
|
||||||
// the window width.
|
// the window width.
|
||||||
@@ -191,6 +217,17 @@ func TestColumnWidthsFitTheTerminal(t *testing.T) {
|
|||||||
name, width, sum, padding, sum+padding)
|
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.
|
// what must not happen is a negative or zero column.
|
||||||
func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) {
|
func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) {
|
||||||
for _, width := range []int{20, 40, 60} {
|
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 {
|
if w < 1 {
|
||||||
t.Errorf("width %d produced a non-positive column %d", width, w)
|
t.Errorf("width %d produced a non-positive column %d", width, w)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,6 +258,12 @@ 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:
|
||||||
|
var inputCmd tea.Cmd
|
||||||
|
m.ntfyTopicInput, inputCmd = m.ntfyTopicInput.Update(msg)
|
||||||
|
m2, ourCmd := m.handleKey(msg)
|
||||||
|
return m2, tea.Batch(inputCmd, ourCmd)
|
||||||
|
|
||||||
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)
|
||||||
@@ -328,6 +334,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
return m.handleUserPickerKey(msg)
|
return m.handleUserPickerKey(msg)
|
||||||
case modeUserCreate:
|
case modeUserCreate:
|
||||||
return m.handleUserCreateKey(msg)
|
return m.handleUserCreateKey(msg)
|
||||||
|
case modeUserNotifyEdit:
|
||||||
|
return m.handleUserNotifyEditKey(msg)
|
||||||
case modeAPIKeyMenu:
|
case modeAPIKeyMenu:
|
||||||
return m.handleAPIKeyMenuKey(msg)
|
return m.handleAPIKeyMenuKey(msg)
|
||||||
case modeAPIKeyCreate:
|
case modeAPIKeyCreate:
|
||||||
@@ -497,6 +505,23 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
m.mode = modeUserCreate
|
m.mode = modeUserCreate
|
||||||
return m, nil
|
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":
|
case "k":
|
||||||
if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 {
|
if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 {
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -916,6 +941,28 @@ func (m Model) handleUserCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
|||||||
return m, nil
|
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) {
|
func (m Model) handleAPIKeyMenuKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "esc":
|
case "esc":
|
||||||
|
|||||||
@@ -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) {
|
func TestTab_CyclesEverySection(t *testing.T) {
|
||||||
m := sized()
|
m := sized()
|
||||||
if m.activeSection != sectionIncidents {
|
if m.activeSection != sectionIncidents {
|
||||||
|
|||||||
+44
-1
@@ -73,6 +73,8 @@ func (m Model) renderBody() string {
|
|||||||
return m.renderUserPicker()
|
return m.renderUserPicker()
|
||||||
case modeUserCreate:
|
case modeUserCreate:
|
||||||
return m.renderUserCreate()
|
return m.renderUserCreate()
|
||||||
|
case modeUserNotifyEdit:
|
||||||
|
return m.renderUserNotifyEdit()
|
||||||
case modeAPIKeyMenu:
|
case modeAPIKeyMenu:
|
||||||
return m.renderAPIKeyMenu()
|
return m.renderAPIKeyMenu()
|
||||||
case modeAPIKeyCreate:
|
case modeAPIKeyCreate:
|
||||||
@@ -127,6 +129,9 @@ 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:
|
||||||
|
return withStatus(" enter·save esc·cancel (empty clears the topic)")
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
@@ -152,7 +157,7 @@ func (m Model) renderFooter() string {
|
|||||||
case sectionSchedule:
|
case sectionSchedule:
|
||||||
return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit")
|
return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit")
|
||||||
case sectionUsers:
|
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()))
|
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 by " + who
|
||||||
}
|
}
|
||||||
return " Resolved (all alerts stopped firing)"
|
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 "<kind>: <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)
|
||||||
default:
|
default:
|
||||||
label := " " + e.Type
|
label := " " + e.Type
|
||||||
if e.Detail != "" {
|
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 {
|
func buildAlertDetailContent(alert api.Alert, width int) string {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -733,6 +766,16 @@ func (m Model) renderUserCreate() string {
|
|||||||
emailLabel + m.userFormInputs[1].View() + "\n"
|
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 {
|
func (m Model) renderAPIKeyMenu() string {
|
||||||
header := fmt.Sprintf("\n API keys for %s\n", styleBold.Render(m.selectedUser.Username))
|
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.")
|
warning := line(styleMuted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.")
|
||||||
|
|||||||
@@ -175,6 +175,18 @@ func TestEventLabel_KnownTypes(t *testing.T) {
|
|||||||
{api.IncidentEvent{Type: api.EventResolved, Username: "bo"}, "Resolved by bo"},
|
{api.IncidentEvent{Type: api.EventResolved, Username: "bo"}, "Resolved by bo"},
|
||||||
// No user means the server closed it via the alert cascade.
|
// No user means the server closed it via the alert cascade.
|
||||||
{api.IncidentEvent{Type: api.EventResolved}, "all alerts stopped firing"},
|
{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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.event.Type, func(t *testing.T) {
|
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) {
|
func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
id := int64(7)
|
id := int64(7)
|
||||||
|
|||||||
Reference in New Issue
Block a user