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
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
+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
- **On-call schedule** — visual calendar of who is on duty in a team, assign and remove entries
- **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day
- **User management** — add and remove users, manage API keys, set each user's ntfy topic
- **User management** — add and remove users, manage API keys, manage each user's notification channels
> Requires terdut-server **v0.20.0 or later**. The server became team-scoped in
> v0.12 and this client follows it; earlier servers answer 404 for `/api/teams`
> and the TUI says so on start. Use terdut-tui v0.9.x with servers before v0.12.
> Requires terdut-server **v0.46.0 or later** (v0.14+ of this client). The server
> became team-scoped in v0.12, so servers before v0.20 answer 404 for
> `/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
> 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.
Administrators are the only users who can create or delete users, or act on
someone else's password, topic or API keys. Everyone can manage their own.
someone else's password, notification channels or API keys. Everyone can manage their own.
## Alerts and incidents
@@ -58,17 +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
without closing it, and expires on its own.
## Push notifications
## Notifications
When the server is configured for ntfy, an incident that opens pages whoever is
on call. Each user has their own topic, shown as a column in the Users section
and edited with `t`. A user with no topic falls back to the server's shared
fallback topic, which carries **no Acknowledge button** — the topic is shared, so
a button on it would let any subscriber acknowledge as somebody else.
When the server has a notification provider configured (ntfy today), an incident
that opens pages whoever is on call, on every notification channel they have
switched on. A channel is a provider type and an address (an ntfy topic); each
user's kinds of channel are shown as a column in the Users section and managed
with `t`: `a` adds one as `type:address` (for example `ntfy:my-topic`), `r`
removes one by its ID. Addresses are only sent to their owner, so an
administrator sees the kind, not where it goes. Turning a channel off is done in
the web UI. A user with no channel falls back to the server's shared fallback,
which carries **no Acknowledge button** — the fallback is shared, so a button on
it would let any recipient acknowledge as somebody else.
Every delivery lands on the incident's timeline: `Notified <user> (triggered)`
when ntfy accepted the page, and `Notification to <user> failed` when it ran out
of retries. That second one is the one to look for when nobody's phone rang.
Every delivery lands on the incident's timeline: `Notified <user> (triggered via
ntfy)` when the provider accepted the page, and `Notification to <user> failed`
when it ran out of retries. That second one is the one to look for when nobody's
phone rang.
## Installation
@@ -131,7 +139,7 @@ accent: "#fabd2f"
A file may shadow a built-in name — `themes/gruvbox-dark.yaml` is how you tweak
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 |
|---|---|
@@ -144,6 +152,7 @@ Without `extends`, every token must be set. The twelve are:
| `resolved` | resolved alerts and incidents, the top-alerts chart |
| `error` | error banners |
| `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
would rather follow your terminal's own colours. Colours are downsampled
@@ -228,7 +237,7 @@ Users section:
| Key | Action |
|-----|--------|
| `n` | Create a user |
| `t` | Edit the user's ntfy topic — submit empty to clear it |
| `t` | Manage the user's notification channels — `a` add (`type:address`), `r` remove by ID |
| `d` | Delete a user |
| `k` | API keys for the selected user |
| `p` | Set the selected user's web UI password — asks for the current one when it is your own |
+21 -10
View File
@@ -623,21 +623,32 @@ func (c *Client) CreateUser(username, email string) (*User, error) {
return &user, c.do(req, &user)
}
// SetUserNotifyTarget points a user's push notifications at an ntfy topic.
// AddChannel gives a user a notification channel: a provider type the server
// has configured ("ntfy") and an address that provider understands (a topic).
//
// An empty topic clears it: the server stores NULL, and that user's incidents
// page the shared fallback topic instead — which carries no Acknowledge button,
// because anyone subscribed to it could otherwise acknowledge as somebody else.
func (c *Client) SetUserNotifyTarget(userID int64, topic string) (*User, error) {
// With no channel a user's incidents page the shared fallback instead, which
// carries no Acknowledge button, because anyone subscribed to it could
// otherwise acknowledge as somebody else.
func (c *Client) AddChannel(userID int64, typ, address string) (*NotificationChannel, error) {
body := struct {
NtfyTopic string `json:"ntfy_topic"`
}{NtfyTopic: topic}
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/notify", userID), body)
Type string `json:"type"`
Address string `json:"address"`
}{Type: typ, Address: address}
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/users/%d/channels", userID), body)
if err != nil {
return nil, err
}
var user User
return &user, c.do(req, &user)
var ch NotificationChannel
return &ch, c.do(req, &ch)
}
// RemoveChannel deletes one of a user's notification channels.
func (c *Client) RemoveChannel(userID, channelID int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d/channels/%d", userID, channelID))
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) DeleteUser(id int64) error {
+20 -24
View File
@@ -173,8 +173,10 @@ func TestClient_IncidentEndpoints(t *testing.T) {
http.MethodDelete, "/api/incidents/7/notes/12", ""},
{"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err },
http.MethodGet, "/api/stats/incidents", ""},
{"set notify target", func(c *Client) error { _, err := c.SetUserNotifyTarget(7, "t"); return err },
http.MethodPut, "/api/users/7/notify", ""},
{"add channel", func(c *Client) error { _, err := c.AddChannel(7, "ntfy", "t"); return err },
http.MethodPost, "/api/users/7/channels", `{}`},
{"remove channel", func(c *Client) error { return c.RemoveChannel(7, 2) },
http.MethodDelete, "/api/users/7/channels/2", ""},
}
for _, tt := range tests {
@@ -284,40 +286,34 @@ func TestClient_RequestBodies(t *testing.T) {
}
})
t.Run("set notify target", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.SetUserNotifyTarget(3, "terdut-niklas"); err != nil {
t.Fatalf("set notify target: %v", err)
t.Run("add channel", func(t *testing.T) {
c, got := stub(t, http.StatusCreated, `{"id":4,"type":"ntfy","enabled":true}`)
ch, err := c.AddChannel(3, "ntfy", "terdut-niklas")
if err != nil {
t.Fatalf("add channel: %v", err)
}
if got.body != `{"ntfy_topic":"terdut-niklas"}` {
if got.body != `{"type":"ntfy","address":"terdut-niklas"}` {
t.Errorf("unexpected body %q", got.body)
}
})
// Clearing has to put an explicit empty string on the wire: omitting the
// field would leave the topic untouched instead of removing it.
t.Run("clear notify target", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.SetUserNotifyTarget(3, ""); err != nil {
t.Fatalf("clear notify target: %v", err)
}
if got.body != `{"ntfy_topic":""}` {
t.Errorf("expected an explicit empty topic, got %q", got.body)
if ch.ID != 4 || ch.Type != "ntfy" || !ch.Enabled {
t.Errorf("unexpected channel %+v", ch)
}
})
}
func TestUser_TopicFlattensNilAndEmpty(t *testing.T) {
func TestUser_ChannelSummaryNamesKindsNeverAddresses(t *testing.T) {
var users []User
if err := json.Unmarshal([]byte(
`[{"id":1,"username":"a"},{"id":2,"username":"b","ntfy_topic":""},
{"id":3,"username":"c","ntfy_topic":"terdut-c"}]`), &users); err != nil {
`[{"id":1,"username":"a"},
{"id":2,"username":"b","channels":[{"id":1,"type":"ntfy","enabled":true}]},
{"id":3,"username":"c","channels":[{"id":2,"type":"ntfy","address":"secret","enabled":true},
{"id":3,"type":"mail","enabled":false}]}]`), &users); err != nil {
t.Fatalf("decode: %v", err)
}
want := []string{"", "", "terdut-c"}
want := []string{"", "ntfy", "ntfy, mail (off)"}
for i, u := range users {
if got := u.Topic(); got != want[i] {
t.Errorf("user %d: expected topic %q, got %q", u.ID, want[i], got)
if got := u.ChannelSummary(); got != want[i] {
t.Errorf("user %d: expected %q, got %q", u.ID, want[i], got)
}
}
}
+36 -13
View File
@@ -1,6 +1,9 @@
package api
import "time"
import (
"strings"
"time"
)
// Alert is the server's record of what Alertmanager said. It is read-only:
// acknowledging, assigning, noting and resolving all happen on the Incident an
@@ -147,7 +150,7 @@ const (
// Written by the server's notifier from the delivery result, not at enqueue.
// Detail carries the notification kind ("triggered", "reminder", "resolved"),
// and on a failure the reason after it. An absent user means the page went to
// the shared fallback topic rather than to a person.
// the shared fallback rather than to a person.
EventNotified = "notified"
EventNotifyFailed = "notify_failed"
)
@@ -224,20 +227,36 @@ type User struct {
// disabled user cannot sign in or use their keys.
DisabledAt *time.Time `json:"disabled_at,omitempty"`
// NtfyTopic is where this user's push notifications go. Nil and empty mean
// the same thing — no topic of their own — because the server stores a blank
// string as NULL. Their incidents fall back to the server's shared fallback
// topic, which carries no Acknowledge button.
NtfyTopic *string `json:"ntfy_topic,omitempty"`
// Channels are where this user's notifications go: a provider type and an
// address each. Only filled in for the caller's own row and, without the
// addresses, for an administrator's view of others. A user with none has
// their incidents page the server's shared fallback, which carries no
// Acknowledge button.
Channels []NotificationChannel `json:"channels,omitempty"`
}
// Topic reads the user's ntfy topic, flattening the nil and empty cases the
// server treats alike.
func (u User) Topic() string {
if u.NtfyTopic == nil {
return ""
// NotificationChannel is one way of reaching a user.
type NotificationChannel struct {
ID int64 `json:"id"`
Type string `json:"type"`
// Address is blank unless the caller owns the channel: it can be a secret.
Address string `json:"address,omitempty"`
Enabled bool `json:"enabled"`
}
// ChannelSummary lists the kinds of channel the user has, "ntfy, mail (off)",
// or "" for none. Never an address.
func (u User) ChannelSummary() string {
parts := make([]string, 0, len(u.Channels))
for _, c := range u.Channels {
if c.Enabled {
parts = append(parts, c.Type)
} else {
parts = append(parts, c.Type+" (off)")
}
}
return *u.NtfyTopic
return strings.Join(parts, ", ")
}
// IsDisabled reports whether the account has been disabled.
@@ -281,6 +300,10 @@ type APIKey struct {
Key string `json:"key,omitempty"` // only present in create response
CreatedAt time.Time `json:"created_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
+30 -19
View File
@@ -1,30 +1,36 @@
package theme
import "sort"
import (
"sort"
"github.com/charmbracelet/lipgloss"
)
// 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
// its own background.
const (
darkBg0 = "#282828"
darkFg1 = "#ebdbb2"
darkGray = "#928374"
darkRed = "#fb4934"
darkGrn = "#b8bb26"
darkYel = "#fabd2f"
darkBlu = "#83a598"
darkAqua = "#8ec07c"
darkOrng = "#fe8019"
darkBg0 = "#282828"
darkFg1 = "#ebdbb2"
darkGray = "#928374"
darkRed = "#fb4934"
darkGrn = "#b8bb26"
darkYel = "#fabd2f"
darkBlu = "#83a598"
darkAqua = "#8ec07c"
darkOrng = "#fe8019"
darkPurple = "#d3869b"
lightBg0 = "#fbf1c7"
lightFg1 = "#3c3836"
lightFg4 = "#7c6f64"
lightRed = "#9d0006"
lightGrn = "#79740e"
lightYel = "#b57614"
lightBlu = "#076678"
lightAqua = "#427b58"
lightOrng = "#af3a03"
lightBg0 = "#fbf1c7"
lightFg1 = "#3c3836"
lightFg4 = "#7c6f64"
lightRed = "#9d0006"
lightGrn = "#79740e"
lightYel = "#b57614"
lightBlu = "#076678"
lightAqua = "#427b58"
lightOrng = "#af3a03"
lightPurple = "#8f3f71"
)
// GruvboxDark is the default scheme. It assumes a dark terminal background:
@@ -46,6 +52,9 @@ var GruvboxDark = Theme{
SevError: darkOrng,
SevWarning: darkYel,
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.
@@ -66,6 +75,8 @@ var GruvboxLight = Theme{
SevError: lightOrng,
SevWarning: lightYel,
SevInfo: lightAqua,
Identity: [6]lipgloss.Color{lightBlu, lightAqua, lightYel, lightGrn, lightOrng, lightPurple},
}
// 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"`
SevWarning *string `yaml:"sev_warning"`
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,
@@ -56,6 +63,12 @@ func bindings(r *rawTheme, t *Theme) []binding {
{"sev_error", r.SevError, &t.SevError},
{"sev_warning", r.SevWarning, &t.SevWarning},
{"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_warning: "#00000b"
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")
@@ -115,6 +121,9 @@ sev_info: "#00000c"
if got.Primary != "#000001" || got.SevInfo != "#00000c" {
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) {
+4
View File
@@ -29,4 +29,8 @@ type Theme struct {
SevError lipgloss.Color
SevWarning 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
modeConfirm
modeUserPicker
modeTeamPicker
modeUserCreate
modeUserNotifyEdit
modeChannelMenu
modeChannelAdd
modeChannelRemoveByID
modeAPIKeyMenu
modeAPIKeyCreate
modeAPIKeyReveal
@@ -253,11 +256,12 @@ type Model struct {
// 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
// schedule is per team and always needs a concrete one, see scheduleTeam.
teams []api.Team
activeTeamID int64
defaultTeam string // config's `team`, resolved on connect
meID int64
isAdmin bool
teams []api.Team
activeTeamID int64
defaultTeam string // config's `team`, resolved on connect
meID int64
isAdmin bool
teamPickerTable table.Model
// 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
@@ -354,7 +358,8 @@ type Model struct {
selectedUser api.User
userFormInputs [2]textinput.Model
userFormFocus int
ntfyTopicInput textinput.Model
channelAddInput textinput.Model
channelRemoveIn textinput.Model
apiKeyNameInput textinput.Model
apiKeyRevokeInput textinput.Model
revealedAPIKey api.APIKey
@@ -396,6 +401,9 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
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.SetStyles(ts)
@@ -419,9 +427,13 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
emailIn.Placeholder = "email"
emailIn.CharLimit = 128
topicIn := textinput.New()
topicIn.Placeholder = "ntfy topic — empty clears it"
topicIn.CharLimit = 128
channelIn := textinput.New()
channelIn.Placeholder = "type:address, e.g. ntfy:my-topic"
channelIn.CharLimit = 160
channelRemoveIn := textinput.New()
channelRemoveIn.Placeholder = "integer channel ID"
channelRemoveIn.CharLimit = 20
keyNameIn := textinput.New()
keyNameIn.Placeholder = "key name (e.g. laptop)"
@@ -453,7 +465,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
}
for _, in := range []*textinput.Model{
&noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn,
&noteIn, &snoozeIn, &usernameIn, &emailIn, &channelIn, &channelRemoveIn, &keyNameIn, &revokeIn,
} {
*in = st.Input(*in)
}
@@ -499,9 +511,11 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
scheduleWindow: window,
scheduleTable: schedT,
userPickerTable: pickerT,
teamPickerTable: teamPickerT,
userManageTable: manageT,
userFormInputs: [2]textinput.Model{usernameIn, emailIn},
ntfyTopicInput: topicIn,
channelAddInput: channelIn,
channelRemoveIn: channelRemoveIn,
apiKeyNameInput: keyNameIn,
apiKeyRevokeInput: revokeIn,
pwInputs: pwIn,
@@ -644,15 +658,29 @@ func (m *Model) rebuildUserPickerTable() {
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() {
m.userManageTable.SetColumns(userManageColumns(m.width))
rows := make([]table.Row, len(m.users))
for i, u := range m.users {
topic := u.Topic()
if topic == "" {
topic = "—"
channels := u.ChannelSummary()
if channels == "" {
channels = "—"
}
rows[i] = table.Row{u.Username, u.Email, topic, userFlags(u), u.CreatedAt.UTC().Format("2006-01-02")}
rows[i] = table.Row{u.Username, u.Email, channels, userFlags(u), u.CreatedAt.UTC().Format("2006-01-02")}
}
setRows(&m.userManageTable, rows)
m.userManageTable.SetHeight(tableHeight(m.height, 10))
@@ -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,
// ntfy topic and API keys.
// notification channels and API keys.
func (m Model) canManageUser(u api.User) bool {
return m.isAdmin || u.ID == m.meID
}
@@ -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 {
createdW := 12
usernameW := 25
topicW := 22
channelsW := 22
flagsW := 14
// 10 = bubbles' Padding(0, 1) on each of the five cells.
emailW := width - usernameW - topicW - flagsW - createdW - 10
emailW := width - usernameW - channelsW - flagsW - createdW - 10
if emailW < 15 {
emailW = 15
}
return []table.Column{
{Title: "Username", Width: usernameW},
{Title: "Email", Width: emailW},
{Title: "Ntfy Topic", Width: topicW},
{Title: "Channels", Width: channelsW},
{Title: "Flags", Width: flagsW},
{Title: "Created", Width: createdW},
}
@@ -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
// topic is empty. It re-lists afterwards so the table shows what the server
// stored rather than what was typed.
func setUserNotifyTargetCmd(client *api.Client, userID int64, topic string) tea.Cmd {
// addChannelCmd gives a user a notification channel. It re-lists afterwards so
// the table shows what the server stored rather than what was typed.
func addChannelCmd(client *api.Client, userID int64, typ, address string) tea.Cmd {
return func() tea.Msg {
if _, err := client.SetUserNotifyTarget(userID, topic); err != nil {
if _, err := client.AddChannel(userID, typ, address); err != nil {
return userActionErrMsg{err}
}
users, err := client.ListUsers()
if err != nil {
return userActionErrMsg{err}
}
return usersFetchedMsg{users: users}
}
}
// removeChannelCmd deletes one of a user's channels and re-lists.
func removeChannelCmd(client *api.Client, userID, channelID int64) tea.Cmd {
return func() tea.Msg {
if err := client.RemoveChannel(userID, channelID); err != nil {
return userActionErrMsg{err}
}
users, err := client.ListUsers()
+18 -14
View File
@@ -1,6 +1,7 @@
package tui
import (
"strings"
"testing"
"time"
@@ -196,29 +197,32 @@ func TestAlertRows_ShowIncidentLink(t *testing.T) {
}
}
// A user with no ntfy topic gets no pages of their own — the row has to say so
// A user with no channel gets no pages of their own — the row has to say so
// rather than leaving a blank that reads as "not loaded yet".
func TestUserManageRows_ShowMissingTopic(t *testing.T) {
topic := "terdut-niklas"
empty := ""
func TestUserManageRows_ShowMissingChannels(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40
m.users = []api.User{
{ID: 1, Username: "niklas", NtfyTopic: &topic},
{ID: 1, Username: "niklas", Channels: []api.NotificationChannel{
{ID: 1, Type: "ntfy", Address: "terdut-niklas", Enabled: true}}},
{ID: 2, Username: "alex"},
// The server stores a blank topic as NULL, but a stale client or an older
// server can still hand one back; it means the same thing.
{ID: 3, Username: "sam", NtfyTopic: &empty},
{ID: 3, Username: "sam", Channels: []api.NotificationChannel{
{ID: 2, Type: "ntfy", Enabled: false}}},
}
m.rebuildUserManageTable()
rows := m.userManageTable.Rows()
if rows[0][2] != "terdut-niklas" {
t.Errorf("expected the topic in the row, got %q", rows[0][2])
if rows[0][2] != "ntfy" {
t.Errorf("expected the channel kind in the row, got %q", rows[0][2])
}
if rows[1][2] != "—" || rows[2][2] != "—" {
t.Errorf("expected an em dash for nil and empty topics, got %q and %q",
rows[1][2], rows[2][2])
if strings.Contains(rows[0][2], "terdut-niklas") {
t.Errorf("the row must not carry an address, got %q", rows[0][2])
}
if rows[1][2] != "—" {
t.Errorf("expected an em dash for a user with no channel, got %q", rows[1][2])
}
if rows[2][2] != "ntfy (off)" {
t.Errorf("expected a switched-off channel to say so, got %q", rows[2][2])
}
}
@@ -228,7 +232,7 @@ func TestUserManageRows_ShowMissingTopic(t *testing.T) {
func TestColumnWidthsFitTheTerminal(t *testing.T) {
for _, width := range []int{100, 110, 140, 200} {
// Five cells each, or six once a Team column is added. userManageColumns
// is five cells as well: username, email, topic, flags, created.
// is five cells as well: username, email, channels, flags, created.
for name, tc := range map[string]struct {
cols []table.Column
cells int
+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 ────────────────────────────────────────────
//
// 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)
}
}
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.rebuildScheduleTable()
m.rebuildUserPickerTable()
m.rebuildTeamPickerTable()
m.rebuildUserManageTable()
m.detailViewport.Width = m.width
m.detailViewport.Height = m.detailViewportHeight()
@@ -400,18 +401,33 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m2, ourCmd := m.handleKey(msg)
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:
var inputCmd tea.Cmd
m.userFormInputs[m.userFormFocus], inputCmd = m.userFormInputs[m.userFormFocus].Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeUserNotifyEdit:
case modeChannelAdd:
var inputCmd tea.Cmd
m.ntfyTopicInput, inputCmd = m.ntfyTopicInput.Update(msg)
m.channelAddInput, inputCmd = m.channelAddInput.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeChannelRemoveByID:
var inputCmd tea.Cmd
m.channelRemoveIn, inputCmd = m.channelRemoveIn.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeChannelMenu:
return m.handleKey(msg)
case modeAPIKeyCreate:
var inputCmd tea.Cmd
m.apiKeyNameInput, inputCmd = m.apiKeyNameInput.Update(msg)
@@ -496,10 +512,16 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m.handleConfirmKey(msg)
case modeUserPicker:
return m.handleUserPickerKey(msg)
case modeTeamPicker:
return m.handleTeamPickerKey(msg)
case modeUserCreate:
return m.handleUserCreateKey(msg)
case modeUserNotifyEdit:
return m.handleUserNotifyEditKey(msg)
case modeChannelMenu:
return m.handleChannelMenuKey(msg)
case modeChannelAdd:
return m.handleChannelAddKey(msg)
case modeChannelRemoveByID:
return m.handleChannelRemoveKey(msg)
case modeAPIKeyMenu:
return m.handleAPIKeyMenuKey(msg)
case modePasswordSet:
@@ -566,7 +588,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if !m.connected || len(m.teams) == 0 {
return m, nil
}
return m.switchTeam()
return m.openTeamPicker()
case "enter":
switch m.activeSection {
@@ -712,15 +734,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
}
m.selectedUser = m.users[cursor]
if !m.canManageUser(m.selectedUser) {
cmd := m.refuseUserAction("notification topic")
cmd := m.refuseUserAction("notification channels")
return m, cmd
}
// Prefilled with what they have, so editing a topic does not mean
// retyping it, and clearing one is a deliberate wipe.
m.ntfyTopicInput.SetValue(m.selectedUser.Topic())
m.ntfyTopicInput.CursorEnd()
m.ntfyTopicInput.Focus()
m.mode = modeUserNotifyEdit
m.mode = modeChannelMenu
return m, nil
case "k":
@@ -847,22 +864,21 @@ func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
return m, nil
}
// switchTeam steps the active team through all teams, then each of the caller's
// teams in turn, and reloads what depends on it. Sections that are not on screen
// are emptied rather than fetched, so they load when next opened; the incident
// queue is the exception because it is what the caller returns to.
func (m Model) switchTeam() (Model, tea.Cmd) {
next := int64(0)
if m.activeTeamID == 0 {
next = m.teams[0].ID
} else {
for i, t := range m.teams {
if t.ID == m.activeTeamID && i+1 < len(m.teams) {
next = m.teams[i+1].ID
}
}
}
m.activeTeamID = next
// openTeamPicker opens the full list of the caller's teams, plus "All teams",
// for the "T" key to choose among rather than blindly cycling through them.
// Team data is already loaded at connect time, so no fetch is needed.
func (m Model) openTeamPicker() (Model, tea.Cmd) {
m.mode = modeTeamPicker
m.rebuildTeamPickerTable()
return m, nil
}
// selectTeam sets the active team to teamID (0 meaning all teams) and reloads
// what depends on it. Sections that are not on screen are emptied rather than
// 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.incidents, m.alerts, m.archivedIncidents = 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
}
// ── 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
// 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
}
// handleUserNotifyEditKey edits one user's ntfy topic.
//
// Unlike the other forms here, an empty value is not a mistake to reject: it is
// how a topic is cleared, which the server accepts and treats as NULL.
func (m Model) handleUserNotifyEditKey(msg tea.KeyMsg) (Model, tea.Cmd) {
// handleChannelMenuKey is the menu for one user's notification channels: add
// one, or remove one by its ID. The same shape as the API key menu.
func (m Model) handleChannelMenuKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.ntfyTopicInput.Blur()
m.mode = modeDashboard
return m, nil
case "a":
m.channelAddInput.Reset()
m.channelAddInput.Focus()
m.mode = modeChannelAdd
return m, nil
case "r":
m.channelRemoveIn.Reset()
m.channelRemoveIn.Focus()
m.mode = modeChannelRemoveByID
return m, nil
}
return m, nil
}
// parseChannelSpec splits "type:address" at the first colon. The address may
// itself contain colons (a URL-ish topic, say), the type never does.
func parseChannelSpec(spec string) (typ, address string, ok bool) {
typ, address, found := strings.Cut(strings.TrimSpace(spec), ":")
typ, address = strings.TrimSpace(typ), strings.TrimSpace(address)
return typ, address, found && typ != "" && address != ""
}
func (m Model) handleChannelAddKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.channelAddInput.Blur()
m.mode = modeChannelMenu
return m, nil
case "enter":
topic := strings.TrimSpace(m.ntfyTopicInput.Value())
m.ntfyTopicInput.Blur()
typ, address, ok := parseChannelSpec(m.channelAddInput.Value())
if !ok {
m.statusMsg = "use type:address, e.g. ntfy:my-topic"
return m, clearStatusCmd()
}
m.channelAddInput.Blur()
m.mode = modeDashboard
m.usersLoading = true
return m, setUserNotifyTargetCmd(m.client, m.selectedUser.ID, topic)
return m, addChannelCmd(m.client, m.selectedUser.ID, typ, address)
}
return m, nil
}
func (m Model) handleChannelRemoveKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.channelRemoveIn.Blur()
m.mode = modeChannelMenu
return m, nil
case "enter":
channelID, err := strconv.ParseInt(strings.TrimSpace(m.channelRemoveIn.Value()), 10, 64)
if err != nil || channelID <= 0 {
m.statusMsg = "invalid channel ID — must be a positive integer"
return m, clearStatusCmd()
}
m.channelRemoveIn.Blur()
m.mode = modeDashboard
m.usersLoading = true
return m, removeChannelCmd(m.client, m.selectedUser.ID, channelID)
}
return m, nil
+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")
}
// ── Ntfy topic ────────────────────────────────────────────────────────────
// ── Notification channels ─────────────────────────────────────────────────
// onUsers puts the model in the Users section with a loaded table.
func onUsers(users []api.User) Model {
@@ -428,42 +428,96 @@ func onUsers(users []api.User) Model {
}
func userFixtures() []api.User {
topic := "terdut-niklas"
return []api.User{
{ID: 1, Username: "niklas", Email: "niklas@example.com", NtfyTopic: &topic},
{ID: 1, Username: "niklas", Email: "niklas@example.com",
Channels: []api.NotificationChannel{{ID: 3, Type: "ntfy", Address: "terdut-niklas", Enabled: true}}},
{ID: 2, Username: "alex", Email: "alex@example.com"},
}
}
func TestNotifyTopic_EditPrefillsTheCurrentTopic(t *testing.T) {
func TestChannels_TOpensTheMenuForTheUserUnderTheCursor(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
if m.mode != modeUserNotifyEdit {
t.Fatalf("expected the topic editor, got mode %v", m.mode)
if m.mode != modeChannelMenu {
t.Fatalf("expected the channel menu, got mode %v", m.mode)
}
if m.selectedUser.ID != 1 {
t.Errorf("expected the user under the cursor, got %d", m.selectedUser.ID)
}
// Prefilled, so editing a topic does not mean retyping it from scratch.
if got := m.ntfyTopicInput.Value(); got != "terdut-niklas" {
t.Errorf("expected the current topic prefilled, got %q", got)
}
}
// A user with no topic opens an empty field rather than the previous user's.
func TestNotifyTopic_EditStartsEmptyWhenUnset(t *testing.T) {
m := onUsers(userFixtures())
m, _ = press(t, m, "t")
func TestChannels_AddStartsEmptyEveryTime(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "a")
if m.mode != modeChannelAdd {
t.Fatalf("expected the add form, got mode %v", m.mode)
}
m = typeInto(t, m, "ntfy:abandoned")
m, _ = press(t, m, "esc")
m.userManageTable.SetCursor(1)
if m.mode != modeChannelMenu {
t.Fatalf("escape should go back to the menu, got mode %v", m.mode)
}
m, _ = press(t, m, "t")
if got := m.ntfyTopicInput.Value(); got != "" {
t.Errorf("expected an empty field for a user with no topic, got %q", got)
m, _ = press(t, m, "a")
if got := m.channelAddInput.Value(); got != "" {
t.Errorf("expected an empty field, got %q", got)
}
}
func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) {
func TestChannels_AddRejectsASpecWithoutTypeAndAddress(t *testing.T) {
for _, spec := range []string{"", "ntfy", "ntfy:", ":topic"} {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "a")
m.channelAddInput.SetValue(spec)
m, cmd := press(t, m, "enter")
if m.mode != modeChannelAdd {
t.Errorf("%q: expected to stay on the form, got mode %v", spec, m.mode)
}
if m.statusMsg == "" {
t.Errorf("%q: expected an explanation", spec)
}
_ = cmd // only the status-clearing tick
}
}
func TestChannels_AddSubmitsAndReturnsToTheDashboard(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "a")
m.channelAddInput.SetValue("ntfy:my-topic")
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("expected adding a channel to call the server")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
}
func TestChannels_RemoveNeedsAPositiveID(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, _ = press(t, m, "r")
if m.mode != modeChannelRemoveByID {
t.Fatalf("expected the remove form, got mode %v", m.mode)
}
for _, bad := range []string{"", "abc", "0", "-3"} {
m.channelRemoveIn.SetValue(bad)
next, _ := press(t, m, "enter")
if next.mode != modeChannelRemoveByID {
t.Errorf("%q: expected to stay on the form, got mode %v", bad, next.mode)
}
}
m.channelRemoveIn.SetValue("3")
m, cmd := press(t, m, "enter")
if cmd == nil || m.mode != modeDashboard {
t.Errorf("expected a valid ID to call the server and return, mode %v", m.mode)
}
}
func TestChannels_EscapeAbandonsWithoutSaving(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, cmd := press(t, m, "esc")
@@ -475,23 +529,7 @@ func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) {
}
}
// Clearing a topic is a real action, not a no-op: it is how a user is taken off
// their own topic and back onto the shared fallback. Contrast the snooze prompt,
// where an empty value means "I changed my mind".
func TestNotifyTopic_EmptyInputStillSubmits(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m.ntfyTopicInput.SetValue("")
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("expected clearing the topic to call the server")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
}
func TestNotifyTopic_IsUsersSectionOnly(t *testing.T) {
func TestChannels_IsUsersSectionOnly(t *testing.T) {
m := sized()
m.activeSection = sectionIncidents
if next, cmd := press(t, m, "t"); cmd != nil || next.mode != modeDashboard {
@@ -630,7 +668,7 @@ func TestAlertDetail_JumpToIncident(t *testing.T) {
// A refresh underneath a prompt would move the ground under the user.
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 {
m := sized()
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.teams = twoTeams()
var seen []int64
for i := 0; i < 4; i++ {
var cmd tea.Cmd
m, cmd = press(t, m, "T")
if cmd == nil {
t.Fatal("switching team should reload")
}
seen = append(seen, m.activeTeamID)
m, cmd := press(t, m, "T")
if m.mode != modeTeamPicker {
t.Fatalf("T should open the team picker, got mode %v", m.mode)
}
want := []int64{1, 2, 0, 1}
for i := range want {
if seen[i] != want[i] {
t.Fatalf("expected the cycle %v, got %v", want, seen)
}
if cmd != nil {
t.Error("opening the picker should not itself trigger a reload")
}
}
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.teams = twoTeams()
m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}}
m.rebuildIncidentTable()
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 {
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")
if cmd != nil || m.activeTeamID != 0 {
t.Errorf("without teams T has nothing to switch to")
if cmd != nil || m.mode == modeTeamPicker {
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 {
title := m.styles.Header.Render("terdut-tui")
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)
return spread(title, right, m.width)
@@ -153,10 +157,16 @@ func (m Model) renderBody() string {
}
case modeUserPicker:
return m.renderUserPicker()
case modeTeamPicker:
return m.renderTeamPicker()
case modeUserCreate:
return m.renderUserCreate()
case modeUserNotifyEdit:
return m.renderUserNotifyEdit()
case modeChannelMenu:
return m.renderChannelMenu()
case modeChannelAdd:
return m.renderChannelAdd()
case modeChannelRemoveByID:
return m.renderChannelRemoveByID()
case modeAPIKeyMenu:
return m.renderAPIKeyMenu()
case modeAPIKeyCreate:
@@ -210,11 +220,20 @@ func (m Model) renderFooter() string {
}
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:
return withStatus(" tab·next field enter·create esc·cancel")
case modeUserNotifyEdit:
return withStatus(" enter·save esc·cancel (empty clears the topic)")
case modeChannelMenu:
return withStatus(" a·add a channel r·remove by ID esc·back")
case modeChannelAdd:
return withStatus(" enter·add esc·back (type:address)")
case modeChannelRemoveByID:
return withStatus(" enter·remove esc·back")
case modeAPIKeyMenu:
return withStatus(" n·new key r·revoke by ID esc·back")
@@ -247,7 +266,7 @@ func (m Model) renderFooter() string {
case sectionSchedule:
return withStatus(" +·assign day W·assign week d·del ←/→·shift week " + m.teamHint() + "tab·section r·refresh q·quit")
case sectionUsers:
return withStatus(" n·new user t·topic d·delete k·API keys p·password r·refresh L·sign out q·quit")
return withStatus(" n·new user t·channels d·delete k·API keys p·password r·refresh L·sign out q·quit")
}
return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
}
@@ -496,6 +515,11 @@ func (m Model) renderUserPicker() string {
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 ─────────────────────────────────────────────────────────────────
func (m Model) renderDetail() string {
@@ -734,10 +758,10 @@ func eventLabel(e api.IncidentEvent) string {
return " Resolved (all alerts stopped firing)"
case api.EventNotified:
// An empty username here is not "the server acted": it means the page
// went to the shared fallback topic, so it belongs to nobody.
// went to the shared fallback, so it belongs to nobody.
return fmt.Sprintf(" Notified %s%s", notifiedTarget(who), notifyKind(e.Detail))
case api.EventNotifyFailed:
// The detail is "<kind>: <reason>", and the reason is the point — it is
// The detail is "<kind> via <channel>: <reason>", and the reason is the point — it is
// the only thing that says why nobody's phone rang.
return truncate(fmt.Sprintf(" Notification to %s failed · %s",
notifiedTarget(who), e.Detail), 52)
@@ -753,11 +777,11 @@ func eventLabel(e api.IncidentEvent) string {
}
// notifiedTarget names who a page reached. The server attaches no user when it
// published to the shared fallback topic, and saying so is the difference
// published to the shared fallback, and saying so is the difference
// between "somebody was paged" and "the on-call rota was empty".
func notifiedTarget(username string) string {
if username == "" {
return "the fallback topic"
return "the fallback"
}
return username
}
@@ -945,14 +969,44 @@ func (m Model) renderUserCreate() string {
emailLabel + m.userFormInputs[1].View() + "\n"
}
func (m Model) renderUserNotifyEdit() string {
header := fmt.Sprintf("\n Push notifications for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
func (m Model) renderChannelMenu() string {
header := fmt.Sprintf("\n Notification channels for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
var list string
if len(m.selectedUser.Channels) == 0 {
list = line(m.styles.Muted, " None — their incidents page the server's shared fallback,\n"+
" which carries no Acknowledge button.")
}
for _, c := range m.selectedUser.Channels {
state := ""
if !c.Enabled {
state = " (off)"
}
// The address is only sent to its owner; an administrator sees the kind.
address := c.Address
if address == "" {
address = "(address hidden)"
}
list += fmt.Sprintf(" %s %s %s%s\n", m.styles.Accent.Render(fmt.Sprintf("#%d", c.ID)), c.Type, address, state)
}
options := "\n" +
m.styles.Accent.Render(" a") + " · add a channel (type:address, e.g. ntfy:my-topic)\n" +
m.styles.Accent.Render(" r") + " · remove a channel by ID\n"
return header + list + options
}
func (m Model) renderChannelAdd() string {
header := fmt.Sprintf("\n New channel for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
hint := line(m.styles.Muted,
" The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+
" their incidents then page the server's shared fallback topic, which\n"+
" carries no Acknowledge button.")
label := m.styles.Selected.Render(" Topic: ")
return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n"
" The kind of channel, a colon, then where it goes — for ntfy, the topic.\n"+
" Only kinds the server has configured are accepted.")
label := m.styles.Selected.Render(" Channel: ")
return header + hint + "\n" + label + m.channelAddInput.View() + "\n"
}
func (m Model) renderChannelRemoveByID() string {
header := fmt.Sprintf("\n Remove a channel from %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
label := m.styles.Selected.Render(" Channel ID: ")
return header + label + m.channelRemoveIn.View() + "\n"
}
func (m Model) renderPasswordSet() string {
+43 -9
View File
@@ -184,14 +184,17 @@ func TestEventLabel_KnownTypes(t *testing.T) {
"Notified bo (triggered)"},
{api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "reminder"},
"Notified bo (reminder)"},
// On a notification, no user means the shared fallback topic — not that
// The server names the channel type after the kind.
{api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "triggered via ntfy"},
"Notified bo (triggered via ntfy)"},
// On a notification, no user means the shared fallback — not that
// the server acted on its own.
{api.IncidentEvent{Type: api.EventNotified, Detail: "triggered"},
"Notified the fallback topic (triggered)"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered: ntfy returned 502"},
"Notified the fallback (triggered)"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered via ntfy: ntfy returned 502"},
"Notification to bo failed"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Detail: "triggered: no route to host"},
"Notification to the fallback topic failed"},
"Notification to the fallback failed"},
}
for _, tt := range tests {
t.Run(tt.event.Type, func(t *testing.T) {
@@ -216,14 +219,45 @@ func TestIncidentDetail_RendersNotifications(t *testing.T) {
mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed")
}
func TestUserNotifyEdit_SaysWhatAnEmptyValueDoes(t *testing.T) {
func TestChannelMenu_ListsKindsAndSaysWhenThereAreNone(t *testing.T) {
m := sized()
m.mode = modeUserNotifyEdit
m.mode = modeChannelMenu
m.selectedUser = api.User{ID: 1, Username: "niklas"}
mustContain(t, m.View(), "niklas", "empty to clear it", "fallback topic")
// The footer has to repeat it: that is where the reader looks for what a key does.
mustContain(t, m.renderFooter(), "empty clears the topic")
mustContain(t, m.View(), "niklas", "None", "fallback", "add a channel", "remove a channel")
mustContain(t, m.renderFooter(), "add a channel", "remove by ID")
// An administrator sees the kind and ID of somebody else's channel, never where it goes.
m.selectedUser.Channels = []api.NotificationChannel{{ID: 7, Type: "ntfy", Enabled: false}}
mustContain(t, m.View(), "#7", "ntfy", "(address hidden)", "(off)")
}
func TestChannelAdd_ExplainsTheSpec(t *testing.T) {
m := sized()
m.mode = modeChannelAdd
m.selectedUser = api.User{ID: 1, Username: "niklas"}
mustContain(t, m.View(), "niklas", "type", "colon")
mustContain(t, m.renderFooter(), "type:address")
}
func TestParseChannelSpec(t *testing.T) {
for spec, want := range map[string][2]string{
"ntfy:my-topic": {"ntfy", "my-topic"},
" ntfy : my-topic ": {"ntfy", "my-topic"},
"mail:a@b.example": {"mail", "a@b.example"},
"ntfy:a:b": {"ntfy", "a:b"},
} {
typ, address, ok := parseChannelSpec(spec)
if !ok || typ != want[0] || address != want[1] {
t.Errorf("%q: got %q, %q, %v", spec, typ, address, ok)
}
}
for _, bad := range []string{"", "ntfy", "ntfy:", ":topic", " "} {
if _, _, ok := parseChannelSpec(bad); ok {
t.Errorf("%q should not parse", bad)
}
}
}
func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {