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

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:
Niklas Ye
2026-08-07 13:32:11 +02:00
parent f75ae60e74
commit 85ad2d65ee
10 changed files with 381 additions and 6 deletions
+38
View File
@@ -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)