4 Commits

Author SHA1 Message Date
Niklas Ye 25c9b6e150 State that the TUI needs server v0.46.0 for notification channels
CI / test (push) Successful in 1m33s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 1m21s
The Users section now calls the channels endpoints that v0.46.0 introduced
and the old PUT /api/users/{id}/notify is gone, so the v0.20.0 floor in the
README and CLAUDE.md was no longer true.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-12 21:38:22 +02:00
Niklas Ye ec5df0449b 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
2026-10-12 21:00:40 +02:00
Niklas Ye 77f2d6775b Mirror terdut-server's new API key expires_at field
terdut-server's api_keys can now optionally expire (expires_in_days on
creation). The JSON shape change is additive and the TUI doesn't create
or list keys with an expiry yet, so this is a pure decode-compatibility
mirror, not a feature: APIKey gains ExpiresAt, nil for a key that never
expires or when talking to an older server that never sends the field.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 22:31:27 +02:00
Niklas Ye 024dc095a5 Show a colour-coded team picker instead of cycling with T
CI / test (push) Successful in 15s
Release / test (push) Successful in 4s
Release / binaries (push) Successful in 28s
T used to silently cycle Model.activeTeamID through the caller's teams with
no visible list of choices. It now opens a full picker (modelled on the
existing user picker) listing every team plus "All teams", each with a
stable identity colour from a new six-colour theme palette. The same colour
now also shows as a bullet next to "team: <name>" in the header, so the
active team stays visible without opening the picker.

Adds an Identity palette to the theme package (six hues, skipping the ones
that already mean firing/critical), Styles.TeamColor to pick one by team id,
and identity_1..6 as theme-file tokens alongside the existing twelve so a
fully custom theme can still set every token.
2026-09-27 18:14:45 +02:00
17 changed files with 680 additions and 240 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# terdut-tui # terdut-tui
TUI client for [terdut-server](https://git.ryuvia.com/niklas/terdut-server), a Prometheus Alertmanager receiver and incident manager. Requires server **v0.20.0+** (team-scoped API). TUI client for [terdut-server](https://git.ryuvia.com/niklas/terdut-server), a Prometheus Alertmanager receiver and incident manager. Requires server **v0.46.0+** (notification channels; v0.20+ team-scoped API for terdut-tui v0.13.x).
## Domain model ## Domain model
+25 -16
View File
@@ -13,11 +13,13 @@ Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea).
- **Teams** — switch between your teams, or see all of them at once - **Teams** — switch between your teams, or see all of them at once
- **On-call schedule** — visual calendar of who is on duty in a team, assign and remove entries - **On-call schedule** — visual calendar of who is on duty in a team, assign and remove entries
- **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day - **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day
- **User management** — add and remove users, manage API keys, set each user's ntfy topic - **User management** — add and remove users, manage API keys, manage each user's notification channels
> Requires terdut-server **v0.20.0 or later**. The server became team-scoped in > Requires terdut-server **v0.46.0 or later** (v0.14+ of this client). The server
> v0.12 and this client follows it; earlier servers answer 404 for `/api/teams` > became team-scoped in v0.12, so servers before v0.20 answer 404 for
> and the TUI says so on start. Use terdut-tui v0.9.x with servers before v0.12. > `/api/teams` and the TUI says so on start. v0.46.0 replaced the per-user ntfy
> topic with notification channels, which the Users section now manages; use
> terdut-tui v0.13.x with servers from v0.20 up to v0.45.
> Escalation ladders, invites, integrations and the admin settings stay in the > Escalation ladders, invites, integrations and the admin settings stay in the
> server's web UI. > server's web UI.
@@ -36,7 +38,7 @@ server refuses anybody else. Stats always cover all your teams; the server
cannot narrow them to one. cannot narrow them to one.
Administrators are the only users who can create or delete users, or act on Administrators are the only users who can create or delete users, or act on
someone else's password, topic or API keys. Everyone can manage their own. someone else's password, notification channels or API keys. Everyone can manage their own.
## Alerts and incidents ## Alerts and incidents
@@ -58,17 +60,23 @@ Two behaviours worth knowing before you press a key:
- **Snooze is the "not now" button.** It hides an incident from the default queue - **Snooze is the "not now" button.** It hides an incident from the default queue
without closing it, and expires on its own. without closing it, and expires on its own.
## Push notifications ## Notifications
When the server is configured for ntfy, an incident that opens pages whoever is When the server has a notification provider configured (ntfy today), an incident
on call. Each user has their own topic, shown as a column in the Users section that opens pages whoever is on call, on every notification channel they have
and edited with `t`. A user with no topic falls back to the server's shared switched on. A channel is a provider type and an address (an ntfy topic); each
fallback topic, which carries **no Acknowledge button** — the topic is shared, so user's kinds of channel are shown as a column in the Users section and managed
a button on it would let any subscriber acknowledge as somebody else. with `t`: `a` adds one as `type:address` (for example `ntfy:my-topic`), `r`
removes one by its ID. Addresses are only sent to their owner, so an
administrator sees the kind, not where it goes. Turning a channel off is done in
the web UI. A user with no channel falls back to the server's shared fallback,
which carries **no Acknowledge button** — the fallback is shared, so a button on
it would let any recipient acknowledge as somebody else.
Every delivery lands on the incident's timeline: `Notified <user> (triggered)` Every delivery lands on the incident's timeline: `Notified <user> (triggered via
when ntfy accepted the page, and `Notification to <user> failed` when it ran out ntfy)` when the provider accepted the page, and `Notification to <user> failed`
of retries. That second one is the one to look for when nobody's phone rang. when it ran out of retries. That second one is the one to look for when nobody's
phone rang.
## Installation ## Installation
@@ -131,7 +139,7 @@ accent: "#fabd2f"
A file may shadow a built-in name — `themes/gruvbox-dark.yaml` is how you tweak A file may shadow a built-in name — `themes/gruvbox-dark.yaml` is how you tweak
the default without renaming it. the default without renaming it.
Without `extends`, every token must be set. The twelve are: Without `extends`, every token must be set. The eighteen are:
| Token | Where it shows | | Token | Where it shows |
|---|---| |---|---|
@@ -144,6 +152,7 @@ Without `extends`, every token must be set. The twelve are:
| `resolved` | resolved alerts and incidents, the top-alerts chart | | `resolved` | resolved alerts and incidents, the top-alerts chart |
| `error` | error banners | | `error` | error banners |
| `sev_critical`, `sev_error`, `sev_warning`, `sev_info` | the `severity` label | | `sev_critical`, `sev_error`, `sev_warning`, `sev_info` | the `severity` label |
| `identity_1` .. `identity_6` | a team's colour in the header and the `T` picker — carries no meaning of its own, so pick six colours that just read as distinct from one another (and from `firing`/`sev_critical`, which already mean something) |
Values are hex (`#83a598` or `#abc`) or an ANSI palette index (`0`–`255`) if you Values are hex (`#83a598` or `#abc`) or an ANSI palette index (`0`–`255`) if you
would rather follow your terminal's own colours. Colours are downsampled would rather follow your terminal's own colours. Colours are downsampled
@@ -228,7 +237,7 @@ Users section:
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| `n` | Create a user | | `n` | Create a user |
| `t` | Edit the user's ntfy topic — submit empty to clear it | | `t` | Manage the user's notification channels — `a` add (`type:address`), `r` remove by ID |
| `d` | Delete a user | | `d` | Delete a user |
| `k` | API keys for the selected user | | `k` | API keys for the selected user |
| `p` | Set the selected user's web UI password — asks for the current one when it is your own | | `p` | Set the selected user's web UI password — asks for the current one when it is your own |
+21 -10
View File
@@ -623,21 +623,32 @@ func (c *Client) CreateUser(username, email string) (*User, error) {
return &user, c.do(req, &user) return &user, c.do(req, &user)
} }
// SetUserNotifyTarget points a user's push notifications at an ntfy topic. // AddChannel gives a user a notification channel: a provider type the server
// has configured ("ntfy") and an address that provider understands (a topic).
// //
// An empty topic clears it: the server stores NULL, and that user's incidents // With no channel a user's incidents page the shared fallback instead, which
// page the shared fallback topic instead — which carries no Acknowledge button, // carries no Acknowledge button, because anyone subscribed to it could
// because anyone subscribed to it could otherwise acknowledge as somebody else. // otherwise acknowledge as somebody else.
func (c *Client) SetUserNotifyTarget(userID int64, topic string) (*User, error) { func (c *Client) AddChannel(userID int64, typ, address string) (*NotificationChannel, error) {
body := struct { body := struct {
NtfyTopic string `json:"ntfy_topic"` Type string `json:"type"`
}{NtfyTopic: topic} Address string `json:"address"`
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/notify", userID), body) }{Type: typ, Address: address}
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/users/%d/channels", userID), body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var user User var ch NotificationChannel
return &user, c.do(req, &user) return &ch, c.do(req, &ch)
}
// RemoveChannel deletes one of a user's notification channels.
func (c *Client) RemoveChannel(userID, channelID int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d/channels/%d", userID, channelID))
if err != nil {
return err
}
return c.do(req, nil)
} }
func (c *Client) DeleteUser(id int64) error { func (c *Client) DeleteUser(id int64) error {
+20 -24
View File
@@ -173,8 +173,10 @@ func TestClient_IncidentEndpoints(t *testing.T) {
http.MethodDelete, "/api/incidents/7/notes/12", ""}, http.MethodDelete, "/api/incidents/7/notes/12", ""},
{"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err }, {"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err },
http.MethodGet, "/api/stats/incidents", ""}, http.MethodGet, "/api/stats/incidents", ""},
{"set notify target", func(c *Client) error { _, err := c.SetUserNotifyTarget(7, "t"); return err }, {"add channel", func(c *Client) error { _, err := c.AddChannel(7, "ntfy", "t"); return err },
http.MethodPut, "/api/users/7/notify", ""}, http.MethodPost, "/api/users/7/channels", `{}`},
{"remove channel", func(c *Client) error { return c.RemoveChannel(7, 2) },
http.MethodDelete, "/api/users/7/channels/2", ""},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -284,40 +286,34 @@ func TestClient_RequestBodies(t *testing.T) {
} }
}) })
t.Run("set notify target", func(t *testing.T) { t.Run("add channel", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`) c, got := stub(t, http.StatusCreated, `{"id":4,"type":"ntfy","enabled":true}`)
if _, err := c.SetUserNotifyTarget(3, "terdut-niklas"); err != nil { ch, err := c.AddChannel(3, "ntfy", "terdut-niklas")
t.Fatalf("set notify target: %v", err) if err != nil {
t.Fatalf("add channel: %v", err)
} }
if got.body != `{"ntfy_topic":"terdut-niklas"}` { if got.body != `{"type":"ntfy","address":"terdut-niklas"}` {
t.Errorf("unexpected body %q", got.body) t.Errorf("unexpected body %q", got.body)
} }
}) if ch.ID != 4 || ch.Type != "ntfy" || !ch.Enabled {
t.Errorf("unexpected channel %+v", ch)
// Clearing has to put an explicit empty string on the wire: omitting the
// field would leave the topic untouched instead of removing it.
t.Run("clear notify target", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.SetUserNotifyTarget(3, ""); err != nil {
t.Fatalf("clear notify target: %v", err)
}
if got.body != `{"ntfy_topic":""}` {
t.Errorf("expected an explicit empty topic, got %q", got.body)
} }
}) })
} }
func TestUser_TopicFlattensNilAndEmpty(t *testing.T) { func TestUser_ChannelSummaryNamesKindsNeverAddresses(t *testing.T) {
var users []User var users []User
if err := json.Unmarshal([]byte( if err := json.Unmarshal([]byte(
`[{"id":1,"username":"a"},{"id":2,"username":"b","ntfy_topic":""}, `[{"id":1,"username":"a"},
{"id":3,"username":"c","ntfy_topic":"terdut-c"}]`), &users); err != nil { {"id":2,"username":"b","channels":[{"id":1,"type":"ntfy","enabled":true}]},
{"id":3,"username":"c","channels":[{"id":2,"type":"ntfy","address":"secret","enabled":true},
{"id":3,"type":"mail","enabled":false}]}]`), &users); err != nil {
t.Fatalf("decode: %v", err) t.Fatalf("decode: %v", err)
} }
want := []string{"", "", "terdut-c"} want := []string{"", "ntfy", "ntfy, mail (off)"}
for i, u := range users { for i, u := range users {
if got := u.Topic(); got != want[i] { if got := u.ChannelSummary(); got != want[i] {
t.Errorf("user %d: expected topic %q, got %q", u.ID, want[i], got) t.Errorf("user %d: expected %q, got %q", u.ID, want[i], got)
} }
} }
} }
+36 -13
View File
@@ -1,6 +1,9 @@
package api package api
import "time" import (
"strings"
"time"
)
// Alert is the server's record of what Alertmanager said. It is read-only: // Alert is the server's record of what Alertmanager said. It is read-only:
// acknowledging, assigning, noting and resolving all happen on the Incident an // acknowledging, assigning, noting and resolving all happen on the Incident an
@@ -147,7 +150,7 @@ const (
// Written by the server's notifier from the delivery result, not at enqueue. // Written by the server's notifier from the delivery result, not at enqueue.
// Detail carries the notification kind ("triggered", "reminder", "resolved"), // Detail carries the notification kind ("triggered", "reminder", "resolved"),
// and on a failure the reason after it. An absent user means the page went to // and on a failure the reason after it. An absent user means the page went to
// the shared fallback topic rather than to a person. // the shared fallback rather than to a person.
EventNotified = "notified" EventNotified = "notified"
EventNotifyFailed = "notify_failed" EventNotifyFailed = "notify_failed"
) )
@@ -224,20 +227,36 @@ type User struct {
// disabled user cannot sign in or use their keys. // disabled user cannot sign in or use their keys.
DisabledAt *time.Time `json:"disabled_at,omitempty"` DisabledAt *time.Time `json:"disabled_at,omitempty"`
// NtfyTopic is where this user's push notifications go. Nil and empty mean // Channels are where this user's notifications go: a provider type and an
// the same thing — no topic of their own — because the server stores a blank // address each. Only filled in for the caller's own row and, without the
// string as NULL. Their incidents fall back to the server's shared fallback // addresses, for an administrator's view of others. A user with none has
// topic, which carries no Acknowledge button. // their incidents page the server's shared fallback, which carries no
NtfyTopic *string `json:"ntfy_topic,omitempty"` // Acknowledge button.
Channels []NotificationChannel `json:"channels,omitempty"`
} }
// Topic reads the user's ntfy topic, flattening the nil and empty cases the // NotificationChannel is one way of reaching a user.
// server treats alike. type NotificationChannel struct {
func (u User) Topic() string { ID int64 `json:"id"`
if u.NtfyTopic == nil { Type string `json:"type"`
return ""
// Address is blank unless the caller owns the channel: it can be a secret.
Address string `json:"address,omitempty"`
Enabled bool `json:"enabled"`
}
// ChannelSummary lists the kinds of channel the user has, "ntfy, mail (off)",
// or "" for none. Never an address.
func (u User) ChannelSummary() string {
parts := make([]string, 0, len(u.Channels))
for _, c := range u.Channels {
if c.Enabled {
parts = append(parts, c.Type)
} else {
parts = append(parts, c.Type+" (off)")
}
} }
return *u.NtfyTopic return strings.Join(parts, ", ")
} }
// IsDisabled reports whether the account has been disabled. // IsDisabled reports whether the account has been disabled.
@@ -281,6 +300,10 @@ type APIKey struct {
Key string `json:"key,omitempty"` // only present in create response Key string `json:"key,omitempty"` // only present in create response
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"` LastUsedAt *time.Time `json:"last_used_at"`
// ExpiresAt is nil for a key that never expires, including on a server
// old enough to not send the field at all — same decoded result either
// way. Not yet surfaced anywhere in the TUI.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
} }
// SimilarIncident is an earlier, resolved incident with the same signature // SimilarIncident is an earlier, resolved incident with the same signature
+30 -19
View File
@@ -1,30 +1,36 @@
package theme package theme
import "sort" import (
"sort"
"github.com/charmbracelet/lipgloss"
)
// The gruvbox palettes, in the author's original names. Dark uses the bright // The gruvbox palettes, in the author's original names. Dark uses the bright
// variants and light the faded ones, which is what keeps each readable against // variants and light the faded ones, which is what keeps each readable against
// its own background. // its own background.
const ( const (
darkBg0 = "#282828" darkBg0 = "#282828"
darkFg1 = "#ebdbb2" darkFg1 = "#ebdbb2"
darkGray = "#928374" darkGray = "#928374"
darkRed = "#fb4934" darkRed = "#fb4934"
darkGrn = "#b8bb26" darkGrn = "#b8bb26"
darkYel = "#fabd2f" darkYel = "#fabd2f"
darkBlu = "#83a598" darkBlu = "#83a598"
darkAqua = "#8ec07c" darkAqua = "#8ec07c"
darkOrng = "#fe8019" darkOrng = "#fe8019"
darkPurple = "#d3869b"
lightBg0 = "#fbf1c7" lightBg0 = "#fbf1c7"
lightFg1 = "#3c3836" lightFg1 = "#3c3836"
lightFg4 = "#7c6f64" lightFg4 = "#7c6f64"
lightRed = "#9d0006" lightRed = "#9d0006"
lightGrn = "#79740e" lightGrn = "#79740e"
lightYel = "#b57614" lightYel = "#b57614"
lightBlu = "#076678" lightBlu = "#076678"
lightAqua = "#427b58" lightAqua = "#427b58"
lightOrng = "#af3a03" lightOrng = "#af3a03"
lightPurple = "#8f3f71"
) )
// GruvboxDark is the default scheme. It assumes a dark terminal background: // GruvboxDark is the default scheme. It assumes a dark terminal background:
@@ -46,6 +52,9 @@ var GruvboxDark = Theme{
SevError: darkOrng, SevError: darkOrng,
SevWarning: darkYel, SevWarning: darkYel,
SevInfo: darkAqua, SevInfo: darkAqua,
// Red already means an alarm, so it is the one gruvbox hue left out here.
Identity: [6]lipgloss.Color{darkBlu, darkAqua, darkYel, darkGrn, darkOrng, darkPurple},
} }
// GruvboxLight is the same scheme against a light terminal background. // GruvboxLight is the same scheme against a light terminal background.
@@ -66,6 +75,8 @@ var GruvboxLight = Theme{
SevError: lightOrng, SevError: lightOrng,
SevWarning: lightYel, SevWarning: lightYel,
SevInfo: lightAqua, SevInfo: lightAqua,
Identity: [6]lipgloss.Color{lightBlu, lightAqua, lightYel, lightGrn, lightOrng, lightPurple},
} }
// Default is the theme used when the config names none. // Default is the theme used when the config names none.
+13
View File
@@ -32,6 +32,13 @@ type rawTheme struct {
SevError *string `yaml:"sev_error"` SevError *string `yaml:"sev_error"`
SevWarning *string `yaml:"sev_warning"` SevWarning *string `yaml:"sev_warning"`
SevInfo *string `yaml:"sev_info"` SevInfo *string `yaml:"sev_info"`
Identity1 *string `yaml:"identity_1"`
Identity2 *string `yaml:"identity_2"`
Identity3 *string `yaml:"identity_3"`
Identity4 *string `yaml:"identity_4"`
Identity5 *string `yaml:"identity_5"`
Identity6 *string `yaml:"identity_6"`
} }
// binding ties a YAML key to its raw value and the field it fills, so parsing, // binding ties a YAML key to its raw value and the field it fills, so parsing,
@@ -56,6 +63,12 @@ func bindings(r *rawTheme, t *Theme) []binding {
{"sev_error", r.SevError, &t.SevError}, {"sev_error", r.SevError, &t.SevError},
{"sev_warning", r.SevWarning, &t.SevWarning}, {"sev_warning", r.SevWarning, &t.SevWarning},
{"sev_info", r.SevInfo, &t.SevInfo}, {"sev_info", r.SevInfo, &t.SevInfo},
{"identity_1", r.Identity1, &t.Identity[0]},
{"identity_2", r.Identity2, &t.Identity[1]},
{"identity_3", r.Identity3, &t.Identity[2]},
{"identity_4", r.Identity4, &t.Identity[3]},
{"identity_5", r.Identity5, &t.Identity[4]},
{"identity_6", r.Identity6, &t.Identity[5]},
} }
} }
+9
View File
@@ -106,6 +106,12 @@ sev_critical: "#000009"
sev_error: "#00000a" sev_error: "#00000a"
sev_warning: "#00000b" sev_warning: "#00000b"
sev_info: "#00000c" sev_info: "#00000c"
identity_1: "#00000d"
identity_2: "#00000e"
identity_3: "#00000f"
identity_4: "#000010"
identity_5: "#000011"
identity_6: "#000012"
`) `)
got, err := loadFrom(dir, "full") got, err := loadFrom(dir, "full")
@@ -115,6 +121,9 @@ sev_info: "#00000c"
if got.Primary != "#000001" || got.SevInfo != "#00000c" { if got.Primary != "#000001" || got.SevInfo != "#00000c" {
t.Errorf("tokens not applied: %+v", got) t.Errorf("tokens not applied: %+v", got)
} }
if got.Identity[0] != "#00000d" || got.Identity[5] != "#000012" {
t.Errorf("identity tokens not applied: %+v", got.Identity)
}
} }
func TestLoadFrom_Errors(t *testing.T) { func TestLoadFrom_Errors(t *testing.T) {
+4
View File
@@ -29,4 +29,8 @@ type Theme struct {
SevError lipgloss.Color SevError lipgloss.Color
SevWarning lipgloss.Color SevWarning lipgloss.Color
SevInfo lipgloss.Color SevInfo lipgloss.Color
// Identity tells things like teams apart from one another — not a status
// or a severity, so none of the six carries a meaning of its own.
Identity [6]lipgloss.Color
} }
+80 -25
View File
@@ -46,8 +46,11 @@ const (
modeSnooze modeSnooze
modeConfirm modeConfirm
modeUserPicker modeUserPicker
modeTeamPicker
modeUserCreate modeUserCreate
modeUserNotifyEdit modeChannelMenu
modeChannelAdd
modeChannelRemoveByID
modeAPIKeyMenu modeAPIKeyMenu
modeAPIKeyCreate modeAPIKeyCreate
modeAPIKeyReveal modeAPIKeyReveal
@@ -253,11 +256,12 @@ type Model struct {
// Teams. The server scopes everything to the caller's teams; activeTeamID // Teams. The server scopes everything to the caller's teams; activeTeamID
// narrows the incident and alert lists to one of them, 0 meaning all. The // narrows the incident and alert lists to one of them, 0 meaning all. The
// schedule is per team and always needs a concrete one, see scheduleTeam. // schedule is per team and always needs a concrete one, see scheduleTeam.
teams []api.Team teams []api.Team
activeTeamID int64 activeTeamID int64
defaultTeam string // config's `team`, resolved on connect defaultTeam string // config's `team`, resolved on connect
meID int64 meID int64
isAdmin bool isAdmin bool
teamPickerTable table.Model
// Sign-in. Until the server accepts a session the TUI is in modeLogin; // Sign-in. Until the server accepts a session the TUI is in modeLogin;
// loginNote is a line the form shows above the fields (why we are here), and // loginNote is a line the form shows above the fields (why we are here), and
@@ -354,7 +358,8 @@ type Model struct {
selectedUser api.User selectedUser api.User
userFormInputs [2]textinput.Model userFormInputs [2]textinput.Model
userFormFocus int userFormFocus int
ntfyTopicInput textinput.Model channelAddInput textinput.Model
channelRemoveIn textinput.Model
apiKeyNameInput textinput.Model apiKeyNameInput textinput.Model
apiKeyRevokeInput textinput.Model apiKeyRevokeInput textinput.Model
revealedAPIKey api.APIKey revealedAPIKey api.APIKey
@@ -396,6 +401,9 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap())) pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
pickerT.SetStyles(ts) pickerT.SetStyles(ts)
teamPickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
teamPickerT.SetStyles(ts)
manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k", "p"))) manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k", "p")))
manageT.SetStyles(ts) manageT.SetStyles(ts)
@@ -419,9 +427,13 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
emailIn.Placeholder = "email" emailIn.Placeholder = "email"
emailIn.CharLimit = 128 emailIn.CharLimit = 128
topicIn := textinput.New() channelIn := textinput.New()
topicIn.Placeholder = "ntfy topic — empty clears it" channelIn.Placeholder = "type:address, e.g. ntfy:my-topic"
topicIn.CharLimit = 128 channelIn.CharLimit = 160
channelRemoveIn := textinput.New()
channelRemoveIn.Placeholder = "integer channel ID"
channelRemoveIn.CharLimit = 20
keyNameIn := textinput.New() keyNameIn := textinput.New()
keyNameIn.Placeholder = "key name (e.g. laptop)" keyNameIn.Placeholder = "key name (e.g. laptop)"
@@ -453,7 +465,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
} }
for _, in := range []*textinput.Model{ for _, in := range []*textinput.Model{
&noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn, &noteIn, &snoozeIn, &usernameIn, &emailIn, &channelIn, &channelRemoveIn, &keyNameIn, &revokeIn,
} { } {
*in = st.Input(*in) *in = st.Input(*in)
} }
@@ -499,9 +511,11 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
scheduleWindow: window, scheduleWindow: window,
scheduleTable: schedT, scheduleTable: schedT,
userPickerTable: pickerT, userPickerTable: pickerT,
teamPickerTable: teamPickerT,
userManageTable: manageT, userManageTable: manageT,
userFormInputs: [2]textinput.Model{usernameIn, emailIn}, userFormInputs: [2]textinput.Model{usernameIn, emailIn},
ntfyTopicInput: topicIn, channelAddInput: channelIn,
channelRemoveIn: channelRemoveIn,
apiKeyNameInput: keyNameIn, apiKeyNameInput: keyNameIn,
apiKeyRevokeInput: revokeIn, apiKeyRevokeInput: revokeIn,
pwInputs: pwIn, pwInputs: pwIn,
@@ -644,15 +658,29 @@ func (m *Model) rebuildUserPickerTable() {
m.userPickerTable.SetHeight(tableHeight(m.height, 10)) m.userPickerTable.SetHeight(tableHeight(m.height, 10))
} }
// rebuildTeamPickerTable lists "All teams" first, then each of the caller's
// teams in the same order switchTeam / selectTeam use, so a row's position
// always matches its place in m.teams.
func (m *Model) rebuildTeamPickerTable() {
m.teamPickerTable.SetColumns(teamPickerColumns(m.width))
rows := make([]table.Row, 0, len(m.teams)+1)
rows = append(rows, table.Row{m.styles.Muted.Render("●"), "All teams", ""})
for _, t := range m.teams {
rows = append(rows, table.Row{m.styles.TeamColor(t.ID).Render("●"), t.Name, t.Role})
}
setRows(&m.teamPickerTable, rows)
m.teamPickerTable.SetHeight(tableHeight(m.height, 10))
}
func (m *Model) rebuildUserManageTable() { func (m *Model) rebuildUserManageTable() {
m.userManageTable.SetColumns(userManageColumns(m.width)) m.userManageTable.SetColumns(userManageColumns(m.width))
rows := make([]table.Row, len(m.users)) rows := make([]table.Row, len(m.users))
for i, u := range m.users { for i, u := range m.users {
topic := u.Topic() channels := u.ChannelSummary()
if topic == "" { if channels == "" {
topic = "—" channels = "—"
} }
rows[i] = table.Row{u.Username, u.Email, topic, userFlags(u), u.CreatedAt.UTC().Format("2006-01-02")} rows[i] = table.Row{u.Username, u.Email, channels, userFlags(u), u.CreatedAt.UTC().Format("2006-01-02")}
} }
setRows(&m.userManageTable, rows) setRows(&m.userManageTable, rows)
m.userManageTable.SetHeight(tableHeight(m.height, 10)) m.userManageTable.SetHeight(tableHeight(m.height, 10))
@@ -748,7 +776,7 @@ func (m Model) canEditSchedule(t api.Team) bool {
} }
// canManageUser mirrors the server's self-or-admin rule for a user's password, // canManageUser mirrors the server's self-or-admin rule for a user's password,
// ntfy topic and API keys. // notification channels and API keys.
func (m Model) canManageUser(u api.User) bool { func (m Model) canManageUser(u api.User) bool {
return m.isAdmin || u.ID == m.meID return m.isAdmin || u.ID == m.meID
} }
@@ -903,20 +931,34 @@ func userPickerColumns(width int) []table.Column {
} }
} }
func teamPickerColumns(width int) []table.Column {
dotW := 3
roleW := 10
nameW := width - dotW - roleW - 8
if nameW < 15 {
nameW = 15
}
return []table.Column{
{Title: "", Width: dotW},
{Title: "Team", Width: nameW},
{Title: "Role", Width: roleW},
}
}
func userManageColumns(width int) []table.Column { func userManageColumns(width int) []table.Column {
createdW := 12 createdW := 12
usernameW := 25 usernameW := 25
topicW := 22 channelsW := 22
flagsW := 14 flagsW := 14
// 10 = bubbles' Padding(0, 1) on each of the five cells. // 10 = bubbles' Padding(0, 1) on each of the five cells.
emailW := width - usernameW - topicW - flagsW - createdW - 10 emailW := width - usernameW - channelsW - flagsW - createdW - 10
if emailW < 15 { if emailW < 15 {
emailW = 15 emailW = 15
} }
return []table.Column{ return []table.Column{
{Title: "Username", Width: usernameW}, {Title: "Username", Width: usernameW},
{Title: "Email", Width: emailW}, {Title: "Email", Width: emailW},
{Title: "Ntfy Topic", Width: topicW}, {Title: "Channels", Width: channelsW},
{Title: "Flags", Width: flagsW}, {Title: "Flags", Width: flagsW},
{Title: "Created", Width: createdW}, {Title: "Created", Width: createdW},
} }
@@ -1462,12 +1504,25 @@ func createUserCmd(client *api.Client, username, email string) tea.Cmd {
} }
} }
// setUserNotifyTargetCmd points a user's pages at a topic, or clears it when // addChannelCmd gives a user a notification channel. It re-lists afterwards so
// topic is empty. It re-lists afterwards so the table shows what the server // the table shows what the server stored rather than what was typed.
// stored rather than what was typed. func addChannelCmd(client *api.Client, userID int64, typ, address string) tea.Cmd {
func setUserNotifyTargetCmd(client *api.Client, userID int64, topic string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if _, err := client.SetUserNotifyTarget(userID, topic); err != nil { if _, err := client.AddChannel(userID, typ, address); err != nil {
return userActionErrMsg{err}
}
users, err := client.ListUsers()
if err != nil {
return userActionErrMsg{err}
}
return usersFetchedMsg{users: users}
}
}
// removeChannelCmd deletes one of a user's channels and re-lists.
func removeChannelCmd(client *api.Client, userID, channelID int64) tea.Cmd {
return func() tea.Msg {
if err := client.RemoveChannel(userID, channelID); err != nil {
return userActionErrMsg{err} return userActionErrMsg{err}
} }
users, err := client.ListUsers() users, err := client.ListUsers()
+18 -14
View File
@@ -1,6 +1,7 @@
package tui package tui
import ( import (
"strings"
"testing" "testing"
"time" "time"
@@ -196,29 +197,32 @@ func TestAlertRows_ShowIncidentLink(t *testing.T) {
} }
} }
// A user with no ntfy topic gets no pages of their own — the row has to say so // A user with no channel gets no pages of their own — the row has to say so
// rather than leaving a blank that reads as "not loaded yet". // rather than leaving a blank that reads as "not loaded yet".
func TestUserManageRows_ShowMissingTopic(t *testing.T) { func TestUserManageRows_ShowMissingChannels(t *testing.T) {
topic := "terdut-niklas"
empty := ""
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40 m.width, m.height = 120, 40
m.users = []api.User{ m.users = []api.User{
{ID: 1, Username: "niklas", NtfyTopic: &topic}, {ID: 1, Username: "niklas", Channels: []api.NotificationChannel{
{ID: 1, Type: "ntfy", Address: "terdut-niklas", Enabled: true}}},
{ID: 2, Username: "alex"}, {ID: 2, Username: "alex"},
// The server stores a blank topic as NULL, but a stale client or an older {ID: 3, Username: "sam", Channels: []api.NotificationChannel{
// server can still hand one back; it means the same thing. {ID: 2, Type: "ntfy", Enabled: false}}},
{ID: 3, Username: "sam", NtfyTopic: &empty},
} }
m.rebuildUserManageTable() m.rebuildUserManageTable()
rows := m.userManageTable.Rows() rows := m.userManageTable.Rows()
if rows[0][2] != "terdut-niklas" { if rows[0][2] != "ntfy" {
t.Errorf("expected the topic in the row, got %q", rows[0][2]) t.Errorf("expected the channel kind in the row, got %q", rows[0][2])
} }
if rows[1][2] != "—" || rows[2][2] != "—" { if strings.Contains(rows[0][2], "terdut-niklas") {
t.Errorf("expected an em dash for nil and empty topics, got %q and %q", t.Errorf("the row must not carry an address, got %q", rows[0][2])
rows[1][2], rows[2][2]) }
if rows[1][2] != "—" {
t.Errorf("expected an em dash for a user with no channel, got %q", rows[1][2])
}
if rows[2][2] != "ntfy (off)" {
t.Errorf("expected a switched-off channel to say so, got %q", rows[2][2])
} }
} }
@@ -228,7 +232,7 @@ func TestUserManageRows_ShowMissingTopic(t *testing.T) {
func TestColumnWidthsFitTheTerminal(t *testing.T) { func TestColumnWidthsFitTheTerminal(t *testing.T) {
for _, width := range []int{100, 110, 140, 200} { for _, width := range []int{100, 110, 140, 200} {
// Five cells each, or six once a Team column is added. userManageColumns // Five cells each, or six once a Team column is added. userManageColumns
// is five cells as well: username, email, topic, flags, created. // is five cells as well: username, email, channels, flags, created.
for name, tc := range map[string]struct { for name, tc := range map[string]struct {
cols []table.Column cols []table.Column
cells int cells int
+11
View File
@@ -125,6 +125,17 @@ func (s Styles) IncidentStatus(status string) lipgloss.Style {
} }
} }
// TeamColor picks a stable identity colour for a team id, so the same team
// always reads the same colour without the server needing to store one.
func (s Styles) TeamColor(teamID int64) lipgloss.Style {
n := int64(len(s.theme.Identity))
i := teamID % n
if i < 0 { // ids are never negative in practice, but % can still return one
i += n
}
return lipgloss.NewStyle().Foreground(s.theme.Identity[i])
}
// ── Embedded bubbles components ──────────────────────────────────────────── // ── Embedded bubbles components ────────────────────────────────────────────
// //
// Each ships its own hardcoded palette, so a theme that stopped at this // Each ships its own hardcoded palette, so a theme that stopped at this
+30
View File
@@ -66,6 +66,36 @@ func TestStyles_EachBuiltinIsFullyPopulated(t *testing.T) {
t.Errorf("%s: %s has no foreground", th.Name, what) t.Errorf("%s: %s has no foreground", th.Name, what)
} }
} }
for i := range th.Identity {
if s.TeamColor(int64(i)).GetForeground() == (lipgloss.NoColor{}) {
t.Errorf("%s: identity %d has no foreground", th.Name, i)
}
}
}
}
// TestStyles_TeamColorIsStableAndDistinct checks the property the header badge
// and the team picker both rely on: the same id always reads the same colour,
// and ids that differ (up to the size of the palette) read as different
// colours rather than all collapsing to one.
func TestStyles_TeamColorIsStableAndDistinct(t *testing.T) {
s := newStyles(theme.GruvboxDark)
seen := make(map[lipgloss.Color]bool)
for id := int64(0); id < 6; id++ {
seen[s.TeamColor(id).GetForeground().(lipgloss.Color)] = true
}
if len(seen) != 6 {
t.Errorf("expected 6 distinct colours across 6 ids, got %d", len(seen))
}
// ids repeat past the palette size, and a negative id (never sent by the
// server, but cheap to guard) must not panic.
if s.TeamColor(6).GetForeground() != s.TeamColor(0).GetForeground() {
t.Error("the palette should wrap rather than index out of range")
}
if got := s.TeamColor(-1).GetForeground(); got == (lipgloss.NoColor{}) {
t.Error("a negative id should still resolve to a colour, not panic or fall back to none")
} }
} }
+135 -37
View File
@@ -34,6 +34,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.rebuildArchivedTable() m.rebuildArchivedTable()
m.rebuildScheduleTable() m.rebuildScheduleTable()
m.rebuildUserPickerTable() m.rebuildUserPickerTable()
m.rebuildTeamPickerTable()
m.rebuildUserManageTable() m.rebuildUserManageTable()
m.detailViewport.Width = m.width m.detailViewport.Width = m.width
m.detailViewport.Height = m.detailViewportHeight() m.detailViewport.Height = m.detailViewportHeight()
@@ -400,18 +401,33 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m2, ourCmd := m.handleKey(msg) m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd) return m2, tea.Batch(tableCmd, ourCmd)
case modeTeamPicker:
var tableCmd tea.Cmd
m.teamPickerTable, tableCmd = m.teamPickerTable.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
case modeUserCreate: case modeUserCreate:
var inputCmd tea.Cmd var inputCmd tea.Cmd
m.userFormInputs[m.userFormFocus], inputCmd = m.userFormInputs[m.userFormFocus].Update(msg) m.userFormInputs[m.userFormFocus], inputCmd = m.userFormInputs[m.userFormFocus].Update(msg)
m2, ourCmd := m.handleKey(msg) m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd) return m2, tea.Batch(inputCmd, ourCmd)
case modeUserNotifyEdit: case modeChannelAdd:
var inputCmd tea.Cmd var inputCmd tea.Cmd
m.ntfyTopicInput, inputCmd = m.ntfyTopicInput.Update(msg) m.channelAddInput, inputCmd = m.channelAddInput.Update(msg)
m2, ourCmd := m.handleKey(msg) m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd) return m2, tea.Batch(inputCmd, ourCmd)
case modeChannelRemoveByID:
var inputCmd tea.Cmd
m.channelRemoveIn, inputCmd = m.channelRemoveIn.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeChannelMenu:
return m.handleKey(msg)
case modeAPIKeyCreate: case modeAPIKeyCreate:
var inputCmd tea.Cmd var inputCmd tea.Cmd
m.apiKeyNameInput, inputCmd = m.apiKeyNameInput.Update(msg) m.apiKeyNameInput, inputCmd = m.apiKeyNameInput.Update(msg)
@@ -496,10 +512,16 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m.handleConfirmKey(msg) return m.handleConfirmKey(msg)
case modeUserPicker: case modeUserPicker:
return m.handleUserPickerKey(msg) return m.handleUserPickerKey(msg)
case modeTeamPicker:
return m.handleTeamPickerKey(msg)
case modeUserCreate: case modeUserCreate:
return m.handleUserCreateKey(msg) return m.handleUserCreateKey(msg)
case modeUserNotifyEdit: case modeChannelMenu:
return m.handleUserNotifyEditKey(msg) return m.handleChannelMenuKey(msg)
case modeChannelAdd:
return m.handleChannelAddKey(msg)
case modeChannelRemoveByID:
return m.handleChannelRemoveKey(msg)
case modeAPIKeyMenu: case modeAPIKeyMenu:
return m.handleAPIKeyMenuKey(msg) return m.handleAPIKeyMenuKey(msg)
case modePasswordSet: case modePasswordSet:
@@ -566,7 +588,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if !m.connected || len(m.teams) == 0 { if !m.connected || len(m.teams) == 0 {
return m, nil return m, nil
} }
return m.switchTeam() return m.openTeamPicker()
case "enter": case "enter":
switch m.activeSection { switch m.activeSection {
@@ -712,15 +734,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
} }
m.selectedUser = m.users[cursor] m.selectedUser = m.users[cursor]
if !m.canManageUser(m.selectedUser) { if !m.canManageUser(m.selectedUser) {
cmd := m.refuseUserAction("notification topic") cmd := m.refuseUserAction("notification channels")
return m, cmd return m, cmd
} }
// Prefilled with what they have, so editing a topic does not mean m.mode = modeChannelMenu
// retyping it, and clearing one is a deliberate wipe.
m.ntfyTopicInput.SetValue(m.selectedUser.Topic())
m.ntfyTopicInput.CursorEnd()
m.ntfyTopicInput.Focus()
m.mode = modeUserNotifyEdit
return m, nil return m, nil
case "k": case "k":
@@ -847,22 +864,21 @@ func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
return m, nil return m, nil
} }
// switchTeam steps the active team through all teams, then each of the caller's // openTeamPicker opens the full list of the caller's teams, plus "All teams",
// teams in turn, and reloads what depends on it. Sections that are not on screen // for the "T" key to choose among rather than blindly cycling through them.
// are emptied rather than fetched, so they load when next opened; the incident // Team data is already loaded at connect time, so no fetch is needed.
// queue is the exception because it is what the caller returns to. func (m Model) openTeamPicker() (Model, tea.Cmd) {
func (m Model) switchTeam() (Model, tea.Cmd) { m.mode = modeTeamPicker
next := int64(0) m.rebuildTeamPickerTable()
if m.activeTeamID == 0 { return m, nil
next = m.teams[0].ID }
} else {
for i, t := range m.teams { // selectTeam sets the active team to teamID (0 meaning all teams) and reloads
if t.ID == m.activeTeamID && i+1 < len(m.teams) { // what depends on it. Sections that are not on screen are emptied rather than
next = m.teams[i+1].ID // fetched, so they load when next opened; the incident queue is the exception
} // because it is what the caller returns to.
} func (m Model) selectTeam(teamID int64) (Model, tea.Cmd) {
} m.activeTeamID = teamID
m.activeTeamID = next
m.incidents, m.alerts, m.archivedIncidents = nil, nil, nil m.incidents, m.alerts, m.archivedIncidents = nil, nil, nil
m.scheduleEntries, m.scheduleDays, m.currentOnCall = nil, nil, nil m.scheduleEntries, m.scheduleDays, m.currentOnCall = nil, nil, nil
@@ -1253,6 +1269,34 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
// ── Team picker ───────────────────────────────────────────────────────────
// handleTeamPickerKey reads the cursor's team off the table built by
// rebuildTeamPickerTable, where row 0 is always "All teams" and row i (i>=1)
// is m.teams[i-1].
func (m Model) handleTeamPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.mode = modeDashboard
return m, nil
case "enter":
cursor := m.teamPickerTable.Cursor()
id := int64(0)
if cursor > 0 {
i := cursor - 1
if i < 0 || i >= len(m.teams) {
return m, nil
}
id = m.teams[i].ID
}
m, cmd := m.selectTeam(id)
m.mode = modeDashboard
return m, cmd
}
return m, nil
}
// scheduleConflicts reports which of dates are already held by somebody other // scheduleConflicts reports which of dates are already held by somebody other
// than newUserID, and the distinct names holding them. // than newUserID, and the distinct names holding them.
// //
@@ -1333,23 +1377,77 @@ func (m Model) handleUserCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
// handleUserNotifyEditKey edits one user's ntfy topic. // handleChannelMenuKey is the menu for one user's notification channels: add
// // one, or remove one by its ID. The same shape as the API key menu.
// Unlike the other forms here, an empty value is not a mistake to reject: it is func (m Model) handleChannelMenuKey(msg tea.KeyMsg) (Model, tea.Cmd) {
// how a topic is cleared, which the server accepts and treats as NULL.
func (m Model) handleUserNotifyEditKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "esc": case "esc":
m.ntfyTopicInput.Blur()
m.mode = modeDashboard m.mode = modeDashboard
return m, nil return m, nil
case "a":
m.channelAddInput.Reset()
m.channelAddInput.Focus()
m.mode = modeChannelAdd
return m, nil
case "r":
m.channelRemoveIn.Reset()
m.channelRemoveIn.Focus()
m.mode = modeChannelRemoveByID
return m, nil
}
return m, nil
}
// parseChannelSpec splits "type:address" at the first colon. The address may
// itself contain colons (a URL-ish topic, say), the type never does.
func parseChannelSpec(spec string) (typ, address string, ok bool) {
typ, address, found := strings.Cut(strings.TrimSpace(spec), ":")
typ, address = strings.TrimSpace(typ), strings.TrimSpace(address)
return typ, address, found && typ != "" && address != ""
}
func (m Model) handleChannelAddKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.channelAddInput.Blur()
m.mode = modeChannelMenu
return m, nil
case "enter": case "enter":
topic := strings.TrimSpace(m.ntfyTopicInput.Value()) typ, address, ok := parseChannelSpec(m.channelAddInput.Value())
m.ntfyTopicInput.Blur() if !ok {
m.statusMsg = "use type:address, e.g. ntfy:my-topic"
return m, clearStatusCmd()
}
m.channelAddInput.Blur()
m.mode = modeDashboard m.mode = modeDashboard
m.usersLoading = true m.usersLoading = true
return m, setUserNotifyTargetCmd(m.client, m.selectedUser.ID, topic) return m, addChannelCmd(m.client, m.selectedUser.ID, typ, address)
}
return m, nil
}
func (m Model) handleChannelRemoveKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.channelRemoveIn.Blur()
m.mode = modeChannelMenu
return m, nil
case "enter":
channelID, err := strconv.ParseInt(strings.TrimSpace(m.channelRemoveIn.Value()), 10, 64)
if err != nil || channelID <= 0 {
m.statusMsg = "invalid channel ID — must be a positive integer"
return m, clearStatusCmd()
}
m.channelRemoveIn.Blur()
m.mode = modeDashboard
m.usersLoading = true
return m, removeChannelCmd(m.client, m.selectedUser.ID, channelID)
} }
return m, nil return m, nil
+133 -55
View File
@@ -416,7 +416,7 @@ func TestSchedule_ReassigningAPartlyTakenWeekAsksOnce(t *testing.T) {
mustContain(t, m.confirmPrompt(), "2 of 7 days are assigned to niklas and sam") mustContain(t, m.confirmPrompt(), "2 of 7 days are assigned to niklas and sam")
} }
// ── Ntfy topic ──────────────────────────────────────────────────────────── // ── Notification channels ─────────────────────────────────────────────────
// onUsers puts the model in the Users section with a loaded table. // onUsers puts the model in the Users section with a loaded table.
func onUsers(users []api.User) Model { func onUsers(users []api.User) Model {
@@ -428,42 +428,96 @@ func onUsers(users []api.User) Model {
} }
func userFixtures() []api.User { func userFixtures() []api.User {
topic := "terdut-niklas"
return []api.User{ return []api.User{
{ID: 1, Username: "niklas", Email: "niklas@example.com", NtfyTopic: &topic}, {ID: 1, Username: "niklas", Email: "niklas@example.com",
Channels: []api.NotificationChannel{{ID: 3, Type: "ntfy", Address: "terdut-niklas", Enabled: true}}},
{ID: 2, Username: "alex", Email: "alex@example.com"}, {ID: 2, Username: "alex", Email: "alex@example.com"},
} }
} }
func TestNotifyTopic_EditPrefillsTheCurrentTopic(t *testing.T) { func TestChannels_TOpensTheMenuForTheUserUnderTheCursor(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t") m, _ := press(t, onUsers(userFixtures()), "t")
if m.mode != modeUserNotifyEdit { if m.mode != modeChannelMenu {
t.Fatalf("expected the topic editor, got mode %v", m.mode) t.Fatalf("expected the channel menu, got mode %v", m.mode)
} }
if m.selectedUser.ID != 1 { if m.selectedUser.ID != 1 {
t.Errorf("expected the user under the cursor, got %d", m.selectedUser.ID) t.Errorf("expected the user under the cursor, got %d", m.selectedUser.ID)
} }
// Prefilled, so editing a topic does not mean retyping it from scratch.
if got := m.ntfyTopicInput.Value(); got != "terdut-niklas" {
t.Errorf("expected the current topic prefilled, got %q", got)
}
} }
// A user with no topic opens an empty field rather than the previous user's. func TestChannels_AddStartsEmptyEveryTime(t *testing.T) {
func TestNotifyTopic_EditStartsEmptyWhenUnset(t *testing.T) { m, _ := press(t, onUsers(userFixtures()), "t")
m := onUsers(userFixtures()) m, _ = press(t, m, "a")
m, _ = press(t, m, "t") if m.mode != modeChannelAdd {
t.Fatalf("expected the add form, got mode %v", m.mode)
}
m = typeInto(t, m, "ntfy:abandoned")
m, _ = press(t, m, "esc") m, _ = press(t, m, "esc")
m.userManageTable.SetCursor(1) if m.mode != modeChannelMenu {
t.Fatalf("escape should go back to the menu, got mode %v", m.mode)
}
m, _ = press(t, m, "t") m, _ = press(t, m, "a")
if got := m.ntfyTopicInput.Value(); got != "" { if got := m.channelAddInput.Value(); got != "" {
t.Errorf("expected an empty field for a user with no topic, got %q", got) t.Errorf("expected an empty field, got %q", got)
} }
} }
func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) { func TestChannels_AddRejectsASpecWithoutTypeAndAddress(t *testing.T) {
for _, spec := range []string{"", "ntfy", "ntfy:", ":topic"} {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "a")
m.channelAddInput.SetValue(spec)
m, cmd := press(t, m, "enter")
if m.mode != modeChannelAdd {
t.Errorf("%q: expected to stay on the form, got mode %v", spec, m.mode)
}
if m.statusMsg == "" {
t.Errorf("%q: expected an explanation", spec)
}
_ = cmd // only the status-clearing tick
}
}
func TestChannels_AddSubmitsAndReturnsToTheDashboard(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "a")
m.channelAddInput.SetValue("ntfy:my-topic")
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("expected adding a channel to call the server")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
}
func TestChannels_RemoveNeedsAPositiveID(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "r")
if m.mode != modeChannelRemoveByID {
t.Fatalf("expected the remove form, got mode %v", m.mode)
}
for _, bad := range []string{"", "abc", "0", "-3"} {
m.channelRemoveIn.SetValue(bad)
next, _ := press(t, m, "enter")
if next.mode != modeChannelRemoveByID {
t.Errorf("%q: expected to stay on the form, got mode %v", bad, next.mode)
}
}
m.channelRemoveIn.SetValue("3")
m, cmd := press(t, m, "enter")
if cmd == nil || m.mode != modeDashboard {
t.Errorf("expected a valid ID to call the server and return, mode %v", m.mode)
}
}
func TestChannels_EscapeAbandonsWithoutSaving(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t") m, _ := press(t, onUsers(userFixtures()), "t")
m, cmd := press(t, m, "esc") m, cmd := press(t, m, "esc")
@@ -475,23 +529,7 @@ func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) {
} }
} }
// Clearing a topic is a real action, not a no-op: it is how a user is taken off func TestChannels_IsUsersSectionOnly(t *testing.T) {
// their own topic and back onto the shared fallback. Contrast the snooze prompt,
// where an empty value means "I changed my mind".
func TestNotifyTopic_EmptyInputStillSubmits(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m.ntfyTopicInput.SetValue("")
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("expected clearing the topic to call the server")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
}
func TestNotifyTopic_IsUsersSectionOnly(t *testing.T) {
m := sized() m := sized()
m.activeSection = sectionIncidents m.activeSection = sectionIncidents
if next, cmd := press(t, m, "t"); cmd != nil || next.mode != modeDashboard { if next, cmd := press(t, m, "t"); cmd != nil || next.mode != modeDashboard {
@@ -630,7 +668,7 @@ func TestAlertDetail_JumpToIncident(t *testing.T) {
// A refresh underneath a prompt would move the ground under the user. // A refresh underneath a prompt would move the ground under the user.
func TestRefreshTick_SkipsModalStates(t *testing.T) { func TestRefreshTick_SkipsModalStates(t *testing.T) {
modal := []mode{modeNote, modeSnooze, modeConfirm, modeUserPicker, modeUserCreate} modal := []mode{modeNote, modeSnooze, modeConfirm, modeUserPicker, modeTeamPicker, modeUserCreate}
for _, md := range modal { for _, md := range modal {
m := sized() m := sized()
m.mode = md m.mode = md
@@ -782,32 +820,38 @@ func TestConnected_DefaultTeamFromConfig(t *testing.T) {
} }
} }
func TestSwitchTeam_CyclesAllThenEachTeam(t *testing.T) { func TestTeamPicker_Opens(t *testing.T) {
m := sized() m := sized()
m.teams = twoTeams() m.teams = twoTeams()
var seen []int64 m, cmd := press(t, m, "T")
for i := 0; i < 4; i++ { if m.mode != modeTeamPicker {
var cmd tea.Cmd t.Fatalf("T should open the team picker, got mode %v", m.mode)
m, cmd = press(t, m, "T")
if cmd == nil {
t.Fatal("switching team should reload")
}
seen = append(seen, m.activeTeamID)
} }
want := []int64{1, 2, 0, 1} if cmd != nil {
for i := range want { t.Error("opening the picker should not itself trigger a reload")
if seen[i] != want[i] {
t.Fatalf("expected the cycle %v, got %v", want, seen)
}
} }
} }
func TestSwitchTeam_ClearsRowsFromTheOtherTeam(t *testing.T) { // Row 0 of the picker table is always "All teams"; row i (i>=1) is
// m.teams[i-1] — see rebuildTeamPickerTable.
func TestTeamPicker_SelectTeamClearsRowsFromTheOtherTeam(t *testing.T) {
m := sized() m := sized()
m.teams = twoTeams() m.teams = twoTeams()
m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}} m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}}
m.rebuildIncidentTable() m.rebuildIncidentTable()
m, _ = press(t, m, "T") m, _ = press(t, m, "T")
m.teamPickerTable.SetCursor(1) // twoTeams()[0] is Ops
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("selecting a team should reload")
}
if m.mode != modeDashboard {
t.Fatalf("enter should close the picker, got mode %v", m.mode)
}
if m.activeTeamID != 1 {
t.Fatalf("expected team 1 (Ops) active, got %d", m.activeTeamID)
}
if len(m.incidents) != 0 { if len(m.incidents) != 0 {
t.Errorf("the previous team's incidents must not linger, got %d", len(m.incidents)) t.Errorf("the previous team's incidents must not linger, got %d", len(m.incidents))
} }
@@ -816,10 +860,44 @@ func TestSwitchTeam_ClearsRowsFromTheOtherTeam(t *testing.T) {
} }
} }
func TestSwitchTeam_NoTeamsDoesNothing(t *testing.T) { func TestTeamPicker_SelectAllTeams(t *testing.T) {
m := sized()
m.teams = twoTeams()
m.activeTeamID = 1
m, _ = press(t, m, "T")
m.teamPickerTable.SetCursor(0) // "All teams"
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("selecting all teams should reload")
}
if m.activeTeamID != 0 {
t.Fatalf("expected all teams (0), got %d", m.activeTeamID)
}
}
func TestTeamPicker_EscCancelsWithoutChangingTeam(t *testing.T) {
m := sized()
m.teams = twoTeams()
m.activeTeamID = 1
m, _ = press(t, m, "T")
m, cmd := press(t, m, "esc")
if cmd != nil {
t.Error("cancelling should not reload")
}
if m.mode != modeDashboard {
t.Fatalf("esc should close the picker, got mode %v", m.mode)
}
if m.activeTeamID != 1 {
t.Fatalf("cancelling must not change the active team, got %d", m.activeTeamID)
}
}
func TestTeamPicker_NoTeamsDoesNothing(t *testing.T) {
m, cmd := press(t, sized(), "T") m, cmd := press(t, sized(), "T")
if cmd != nil || m.activeTeamID != 0 { if cmd != nil || m.mode == modeTeamPicker {
t.Errorf("without teams T has nothing to switch to") t.Errorf("without teams T has nothing to open")
} }
} }
+71 -17
View File
@@ -28,7 +28,11 @@ func (m Model) View() string {
func (m Model) renderHeader() string { func (m Model) renderHeader() string {
title := m.styles.Header.Render("terdut-tui") title := m.styles.Header.Render("terdut-tui")
if len(m.teams) > 0 { if len(m.teams) > 0 {
title += m.styles.Muted.Render(" team: " + m.activeTeamLabel()) dot := m.styles.Muted.Render("●")
if t, ok := m.activeTeam(); ok {
dot = m.styles.TeamColor(t.ID).Render("●")
}
title += " " + dot + m.styles.Muted.Render(" team: "+m.activeTeamLabel())
} }
right := m.styles.Muted.Render(m.serverURL) right := m.styles.Muted.Render(m.serverURL)
return spread(title, right, m.width) return spread(title, right, m.width)
@@ -153,10 +157,16 @@ func (m Model) renderBody() string {
} }
case modeUserPicker: case modeUserPicker:
return m.renderUserPicker() return m.renderUserPicker()
case modeTeamPicker:
return m.renderTeamPicker()
case modeUserCreate: case modeUserCreate:
return m.renderUserCreate() return m.renderUserCreate()
case modeUserNotifyEdit: case modeChannelMenu:
return m.renderUserNotifyEdit() return m.renderChannelMenu()
case modeChannelAdd:
return m.renderChannelAdd()
case modeChannelRemoveByID:
return m.renderChannelRemoveByID()
case modeAPIKeyMenu: case modeAPIKeyMenu:
return m.renderAPIKeyMenu() return m.renderAPIKeyMenu()
case modeAPIKeyCreate: case modeAPIKeyCreate:
@@ -210,11 +220,20 @@ func (m Model) renderFooter() string {
} }
return withStatus(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", scope)) return withStatus(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", scope))
case modeTeamPicker:
return withStatus(" j/k·navigate enter·select esc·cancel")
case modeUserCreate: case modeUserCreate:
return withStatus(" tab·next field enter·create esc·cancel") return withStatus(" tab·next field enter·create esc·cancel")
case modeUserNotifyEdit: case modeChannelMenu:
return withStatus(" enter·save esc·cancel (empty clears the topic)") return withStatus(" a·add a channel r·remove by ID esc·back")
case modeChannelAdd:
return withStatus(" enter·add esc·back (type:address)")
case modeChannelRemoveByID:
return withStatus(" enter·remove esc·back")
case modeAPIKeyMenu: case modeAPIKeyMenu:
return withStatus(" n·new key r·revoke by ID esc·back") return withStatus(" n·new key r·revoke by ID esc·back")
@@ -247,7 +266,7 @@ func (m Model) renderFooter() string {
case sectionSchedule: case sectionSchedule:
return withStatus(" +·assign day W·assign week d·del ←/→·shift week " + m.teamHint() + "tab·section r·refresh q·quit") return withStatus(" +·assign day W·assign week d·del ←/→·shift week " + m.teamHint() + "tab·section r·refresh q·quit")
case sectionUsers: case sectionUsers:
return withStatus(" n·new user t·topic d·delete k·API keys p·password r·refresh L·sign out q·quit") return withStatus(" n·new user t·channels d·delete k·API keys p·password r·refresh L·sign out q·quit")
} }
return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp())) return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
} }
@@ -496,6 +515,11 @@ func (m Model) renderUserPicker() string {
return header + m.userPickerTable.View() return header + m.userPickerTable.View()
} }
func (m Model) renderTeamPicker() string {
header := "\n " + m.styles.Bold.Render("Select a team:") + "\n\n"
return header + m.teamPickerTable.View()
}
// ── Detail ───────────────────────────────────────────────────────────────── // ── Detail ─────────────────────────────────────────────────────────────────
func (m Model) renderDetail() string { func (m Model) renderDetail() string {
@@ -734,10 +758,10 @@ func eventLabel(e api.IncidentEvent) string {
return " Resolved (all alerts stopped firing)" return " Resolved (all alerts stopped firing)"
case api.EventNotified: case api.EventNotified:
// An empty username here is not "the server acted": it means the page // An empty username here is not "the server acted": it means the page
// went to the shared fallback topic, so it belongs to nobody. // went to the shared fallback, so it belongs to nobody.
return fmt.Sprintf(" Notified %s%s", notifiedTarget(who), notifyKind(e.Detail)) return fmt.Sprintf(" Notified %s%s", notifiedTarget(who), notifyKind(e.Detail))
case api.EventNotifyFailed: case api.EventNotifyFailed:
// The detail is "<kind>: <reason>", and the reason is the point — it is // The detail is "<kind> via <channel>: <reason>", and the reason is the point — it is
// the only thing that says why nobody's phone rang. // the only thing that says why nobody's phone rang.
return truncate(fmt.Sprintf(" Notification to %s failed · %s", return truncate(fmt.Sprintf(" Notification to %s failed · %s",
notifiedTarget(who), e.Detail), 52) notifiedTarget(who), e.Detail), 52)
@@ -753,11 +777,11 @@ func eventLabel(e api.IncidentEvent) string {
} }
// notifiedTarget names who a page reached. The server attaches no user when it // notifiedTarget names who a page reached. The server attaches no user when it
// published to the shared fallback topic, and saying so is the difference // published to the shared fallback, and saying so is the difference
// between "somebody was paged" and "the on-call rota was empty". // between "somebody was paged" and "the on-call rota was empty".
func notifiedTarget(username string) string { func notifiedTarget(username string) string {
if username == "" { if username == "" {
return "the fallback topic" return "the fallback"
} }
return username return username
} }
@@ -945,14 +969,44 @@ func (m Model) renderUserCreate() string {
emailLabel + m.userFormInputs[1].View() + "\n" emailLabel + m.userFormInputs[1].View() + "\n"
} }
func (m Model) renderUserNotifyEdit() string { func (m Model) renderChannelMenu() string {
header := fmt.Sprintf("\n Push notifications for %s\n", m.styles.Bold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n Notification channels for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
var list string
if len(m.selectedUser.Channels) == 0 {
list = line(m.styles.Muted, " None — their incidents page the server's shared fallback,\n"+
" which carries no Acknowledge button.")
}
for _, c := range m.selectedUser.Channels {
state := ""
if !c.Enabled {
state = " (off)"
}
// The address is only sent to its owner; an administrator sees the kind.
address := c.Address
if address == "" {
address = "(address hidden)"
}
list += fmt.Sprintf(" %s %s %s%s\n", m.styles.Accent.Render(fmt.Sprintf("#%d", c.ID)), c.Type, address, state)
}
options := "\n" +
m.styles.Accent.Render(" a") + " · add a channel (type:address, e.g. ntfy:my-topic)\n" +
m.styles.Accent.Render(" r") + " · remove a channel by ID\n"
return header + list + options
}
func (m Model) renderChannelAdd() string {
header := fmt.Sprintf("\n New channel for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
hint := line(m.styles.Muted, hint := line(m.styles.Muted,
" The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+ " The kind of channel, a colon, then where it goes — for ntfy, the topic.\n"+
" their incidents then page the server's shared fallback topic, which\n"+ " Only kinds the server has configured are accepted.")
" carries no Acknowledge button.") label := m.styles.Selected.Render(" Channel: ")
label := m.styles.Selected.Render(" Topic: ") return header + hint + "\n" + label + m.channelAddInput.View() + "\n"
return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n" }
func (m Model) renderChannelRemoveByID() string {
header := fmt.Sprintf("\n Remove a channel from %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
label := m.styles.Selected.Render(" Channel ID: ")
return header + label + m.channelRemoveIn.View() + "\n"
} }
func (m Model) renderPasswordSet() string { func (m Model) renderPasswordSet() string {
+43 -9
View File
@@ -184,14 +184,17 @@ func TestEventLabel_KnownTypes(t *testing.T) {
"Notified bo (triggered)"}, "Notified bo (triggered)"},
{api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "reminder"}, {api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "reminder"},
"Notified bo (reminder)"}, "Notified bo (reminder)"},
// On a notification, no user means the shared fallback topic — not that // The server names the channel type after the kind.
{api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "triggered via ntfy"},
"Notified bo (triggered via ntfy)"},
// On a notification, no user means the shared fallback — not that
// the server acted on its own. // the server acted on its own.
{api.IncidentEvent{Type: api.EventNotified, Detail: "triggered"}, {api.IncidentEvent{Type: api.EventNotified, Detail: "triggered"},
"Notified the fallback topic (triggered)"}, "Notified the fallback (triggered)"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered: ntfy returned 502"}, {api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered via ntfy: ntfy returned 502"},
"Notification to bo failed"}, "Notification to bo failed"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Detail: "triggered: no route to host"}, {api.IncidentEvent{Type: api.EventNotifyFailed, Detail: "triggered: no route to host"},
"Notification to the fallback topic failed"}, "Notification to the fallback failed"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.event.Type, func(t *testing.T) { t.Run(tt.event.Type, func(t *testing.T) {
@@ -216,14 +219,45 @@ func TestIncidentDetail_RendersNotifications(t *testing.T) {
mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed") mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed")
} }
func TestUserNotifyEdit_SaysWhatAnEmptyValueDoes(t *testing.T) { func TestChannelMenu_ListsKindsAndSaysWhenThereAreNone(t *testing.T) {
m := sized() m := sized()
m.mode = modeUserNotifyEdit m.mode = modeChannelMenu
m.selectedUser = api.User{ID: 1, Username: "niklas"} m.selectedUser = api.User{ID: 1, Username: "niklas"}
mustContain(t, m.View(), "niklas", "empty to clear it", "fallback topic") mustContain(t, m.View(), "niklas", "None", "fallback", "add a channel", "remove a channel")
// The footer has to repeat it: that is where the reader looks for what a key does. mustContain(t, m.renderFooter(), "add a channel", "remove by ID")
mustContain(t, m.renderFooter(), "empty clears the topic")
// An administrator sees the kind and ID of somebody else's channel, never where it goes.
m.selectedUser.Channels = []api.NotificationChannel{{ID: 7, Type: "ntfy", Enabled: false}}
mustContain(t, m.View(), "#7", "ntfy", "(address hidden)", "(off)")
}
func TestChannelAdd_ExplainsTheSpec(t *testing.T) {
m := sized()
m.mode = modeChannelAdd
m.selectedUser = api.User{ID: 1, Username: "niklas"}
mustContain(t, m.View(), "niklas", "type", "colon")
mustContain(t, m.renderFooter(), "type:address")
}
func TestParseChannelSpec(t *testing.T) {
for spec, want := range map[string][2]string{
"ntfy:my-topic": {"ntfy", "my-topic"},
" ntfy : my-topic ": {"ntfy", "my-topic"},
"mail:a@b.example": {"mail", "a@b.example"},
"ntfy:a:b": {"ntfy", "a:b"},
} {
typ, address, ok := parseChannelSpec(spec)
if !ok || typ != want[0] || address != want[1] {
t.Errorf("%q: got %q, %q, %v", spec, typ, address, ok)
}
}
for _, bad := range []string{"", "ntfy", "ntfy:", ":topic", " "} {
if _, _, ok := parseChannelSpec(bad); ok {
t.Errorf("%q should not parse", bad)
}
}
} }
func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) { func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {