7 Commits

Author SHA1 Message Date
Niklas Ye ee25552a53 Sign in through the server's single sign-on, with a code
CI / test (push) Successful in 17s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 23s
The sign-in screen asks the server how it can be signed in to
(GET /api/auth/config) and offers what it finds: the password form, and
"Sign in with <provider>" when the server can do a device login. The TUI
shows a link and a short code, the person approves it in any browser, and
the next poll hands over the ordinary session, so it works over SSH where
no browser can be opened. The terminal never talks to the identity
provider.

The password form is hidden when the server has turned password login
off. `auth: sso` in config.yaml starts the SSO login straight away, but not
right after signing out, where that would sign the person straight back
in; any other value is refused when the config is read. Polling honours the
server's interval, backs off on slow_down, and gives up after repeated
failures rather than retrying forever.

A server without /api/auth/config answers 404 and is treated as passwords
only, so the sign-in screen is the one it had. Needs terdut-server v0.29.0
for SSO.
2026-09-26 21:47:13 +02:00
Niklas Ye 057302cb39 Show similar earlier incidents and let notes be marked as the fix
The incident view gets a "Seen before" section from the server's new
/similar endpoint; an older server without it just shows nothing. C adds a
note as the resolution note, alongside c for a plain note. Needs the
server release that adds /similar.

Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU
2026-09-25 15:42:25 +02:00
Niklas Ye 451ce99a62 Say plainly that stats cover all teams
CI / test (push) Successful in 3s
The README said stats were "not team-scoped by the server", which reads
as though they were unscoped. They are scoped, to all of the caller's
teams (TestTeams_AlertsAndStatsAreScoped in terdut-server); what they
cannot do is narrow to one. Docs only, no code change.
2026-09-25 13:10:41 +02:00
Niklas Ye 79e77fadc6 Let the release skill drive this repo, and let make drive the pipeline
CI / test (push) Successful in 4s
Release / test (push) Successful in 4s
Release / binaries (push) Successful in 12s
The release skill only knew repos that deploy an image through a wrapper
chart. terdut-tui publishes binaries to a Gitea release and nothing else,
so its first two releases were cut by hand. It now has a .release.conf
saying KIND=binary, which the skill treats as gate, tag, wait for the
pipeline, then check what was published.

The gate had to exist as make targets for that: fmt, lint and test, the
same three the other repos have. ci.yaml and release.yaml now call them
instead of carrying their own copy of gofmt, vet and the tests, so a green
gate locally and a green pipeline are the same code and cannot drift. The
gofmt handling moved over as written, including the comment on why both of
its failure modes need catching; both fail the target, checked with a
misformatted file and an unparseable one.

The binaries job calls make dist too. DIST_TARGETS is now the one place
that says what a release contains, and dist-assets prints the names dist
builds so the skill can verify the published release against a list
instead of a count. The names are unchanged, and they are the self-updater's
contract with every installed binary: internal/updater matches
terdut-tui-<tag>-<goos>-<goarch> exactly.

CLAUDE.md gains a Release section, including that the annotated tag's
message is what appears on the release page.

Not run in the pipeline yet: make is in the golang image, as terdut-server's
CI relies on, but this repo's workflows only exercise it on the push that
carries this commit, and make dist only on the next tag. A failure in the
release workflow's test job stops the publish rather than shipping
something unchecked.
2026-09-24 08:38:02 +02:00
Niklas Ye f4ca0059dc Sign in as a user instead of with an API key
CI / test (push) Successful in 12s
Release / test (push) Successful in 5s
Release / binaries (push) Successful in 12s
The web UI signs in with a username and password and holds a session
cookie; the TUI was the only client still needing an API key pasted into
a config file. It now asks for the same credentials on a form at start.

What is kept between runs is the session token, not the password, in
session.json under the config directory, mode 0600 and keyed by server
URL so one server's token is never offered to another. It resumes on the
next start; the server's sessions last 30 days and slide with use. L
signs out, which ends the session on the server and deletes the saved
one even if the server cannot be reached.

The client attaches the cookie by hand instead of using a cookie jar:
the server marks it Secure behind https, and a jar drops a Secure cookie
it is given over plain http, which would break a local server for no
reason. It sends no Authorization header at all, since the server judges
a request carrying one on that alone and never falls back to the cookie.
Writes go through the server's cross-origin guard, which lets a client
that sends neither Origin nor Sec-Fetch-Site through; checked against a
real v0.20.1 server for both reads and writes.

A 401 from anything means the session is gone (expired, ended from the
web UI, or the account disabled), so the TUI returns to the form with the
reason, forgets the saved token, and drops what the last session loaded
rather than showing it to whoever signs in next. A 403 is a permission
and leaves the session alone. The refresh timer is started once, so
signing out and in does not leave two running.

An account with no password cannot sign in, and the server answers it
exactly like a wrong password, so the form's message says a password
must be set first. Users created only for API access hit this.

Breaking: api_key in config.yaml is no longer used. It is not an error
to leave it there; the form says it is ignored. API keys still exist on
the server and k in Users still manages them.
2026-09-23 22:15:14 +02:00
Niklas Ye 496e7b6d90 Put the tag message on the release page
The release page has been empty since the first release: the workflow
attached the binaries and created the release with no body, so the
changelog lived only in the annotated tag, where nobody reads it.

The tag message, minus its subject line, is now set as the release notes.
Only an annotated tag has a message worth copying, and only a release
with no notes is filled, so re-running a failed release repairs an empty
one without overwriting notes somebody edited by hand afterwards.

The image has no jq, so the JSON string is escaped with sed and awk.
Checked locally against the real v0.10.0 tag text and a string with
quotes, backslashes, tabs, CRLF, backticks and $; each round-trips
through a JSON parser unchanged. Not run in the pipeline: the PATCH call
and the shallow tag clone's git commands are first exercised by the next
tag push, and a failure there fails the step rather than leaving the
notes silently empty.
2026-09-23 22:15:14 +02:00
Niklas Ye 27008086b0 Follow terdut-server into teams: switch team, per-team schedule
CI / test (push) Successful in 12s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 14s
terdut-server v0.12 made everything team-scoped and v0.20 is what this
client now targets. Against it the old client was wrong in three ways:
the schedule moved to /api/teams/{id}/schedule, GET /api/schedule/current
became a list with one entry per team, and users, incidents, alerts and
schedule entries all grew fields the client ignored.

T steps through all teams and then each of yours. The header names what
is showing, and incident and alert rows gain a Team column when more than
one team can appear. team: in config.yaml picks the team to start on, by
name or id; an unknown one is reported and falls back to all teams.

The schedule is one team's rota, so it shows the active team, or with
all teams showing the first one you own. Writes need an owner or an
administrator, and the picker offers only the team's members, since the
server answers 404 for anybody else. Both are checked up front and the
reason goes in the status bar, rather than surfacing as a 403 after the
user has picked somebody. Stats are not team-scoped by the server and
stay that way here.

Users shows an admin/disabled Flags column. Creating and deleting users
is administrators only, and topic, keys and password work on your own
row or on anyone's for an administrator; the server enforces the same
rule, this only explains it before the round trip.

The server has no version endpoint, so an older one is recognised by
GET /api/teams answering 404, and the TUI says it needs v0.20 or later.
Connecting now also loads /api/teams and /api/me with the key, which
means a wrong key fails on start instead of on the first list; /healthz
does not check it. There is no fallback to the pre-team paths.

Rebuilding a table whose column count changes under loaded rows panicked
inside bubbles, because it re-renders the old rows on SetColumns. The
rows are now cleared first and the cursor put back, so a refresh still
does not jump to the top.

Escalation ladders, invites, integrations and the admin settings are
left to the server's web UI. Checked against a real v0.20.1 server with
two teams, an administrator and a plain member.

Breaking: requires terdut-server v0.20.0 or later. Use terdut-tui v0.9.x
with servers before v0.12.
2026-09-23 21:57:28 +02:00
23 changed files with 3276 additions and 318 deletions
+5 -31
View File
@@ -56,34 +56,8 @@ jobs:
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" . git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
fi fi
# This exists because `go vet` does not look at import order: the move to # The Makefile is the single definition of the gate -- gofmt with both of its failure
# git.ryuvia.com rewrote every import path without re-sorting, the new path sorts # modes handled, go vet, and the tests -- so this is exactly what a developer and the
# before github.com/..., and both repos sat unformatted through a green CI run and # release skill run. See the comments on the targets for why each is shaped as it is.
# a release before anyone noticed. - name: Format, vet and test
# run: make fmt lint test
# Both of gofmt's failure modes need handling, and they are not alike. A file that
# is merely misformatted is listed on stdout with exit 0 -- so the failure has to
# be raised by hand. A file that does not parse is the opposite: nothing on stdout
# and exit 2, which a naive `[ -n "$unformatted" ]` reads as success. The first
# draft of this step had exactly that hole.
- name: Format
run: |
if ! unformatted=$(gofmt -l .); then
echo "::error::gofmt could not parse the tree"
gofmt -l . # re-run unredirected so the parse errors reach the log
exit 1
fi
if [ -n "$unformatted" ]; then
echo "::error::not gofmt'd:"
echo "$unformatted"
gofmt -d .
exit 1
fi
- name: Vet
run: go vet ./...
# Covers the API client against a stub server, the Update state machine, and View
# rendering -- all three are pure enough to test without a terminal.
- name: Test
run: go test ./...
+31 -44
View File
@@ -8,9 +8,9 @@ name: Release
# the job that publishes them. Nothing is handed between jobs. # the job that publishes them. Nothing is handed between jobs.
# #
# The asset names matter beyond being tidy: internal/updater looks for exactly # The asset names matter beyond being tidy: internal/updater looks for exactly
# terdut-tui-<tag>-<goos>-<goarch> in the latest release and reports every available name # terdut-tui-<tag>-<goos>-<goarch> in the latest release. The pattern is defined once, by
# when it cannot find one. Renaming the pattern here breaks self-update for every # `make dist` (and `make dist-assets`, which the release skill checks the published release
# installed binary. # against) -- see DIST_TARGETS in the Makefile before touching it.
on: on:
push: push:
tags: tags:
@@ -41,35 +41,9 @@ jobs:
REF_NAME: ${{ github.ref_name }} REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" . run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
# This exists because `go vet` does not look at import order: the move to # Same target CI and the release skill run; a tag that fails it publishes nothing.
# git.ryuvia.com rewrote every import path without re-sorting, the new path sorts - name: Format, vet and test
# before github.com/..., and both repos sat unformatted through a green CI run and run: make fmt lint test
# a release before anyone noticed.
#
# Both of gofmt's failure modes need handling, and they are not alike. A file that
# is merely misformatted is listed on stdout with exit 0 -- so the failure has to
# be raised by hand. A file that does not parse is the opposite: nothing on stdout
# and exit 2, which a naive `[ -n "$unformatted" ]` reads as success. The first
# draft of this step had exactly that hole.
- name: Format
run: |
if ! unformatted=$(gofmt -l .); then
echo "::error::gofmt could not parse the tree"
gofmt -l . # re-run unredirected so the parse errors reach the log
exit 1
fi
if [ -n "$unformatted" ]; then
echo "::error::not gofmt'd:"
echo "$unformatted"
gofmt -d .
exit 1
fi
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
binaries: binaries:
needs: test needs: test
@@ -86,21 +60,12 @@ jobs:
REF_NAME: ${{ github.ref_name }} REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" . run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
# The Makefile owns the target list and the asset names -- see DIST_TARGETS there for
# why the naming pattern cannot change.
- name: Build every target - name: Build every target
env: env:
REF_NAME: ${{ github.ref_name }} REF_NAME: ${{ github.ref_name }}
run: | run: make dist VERSION="$REF_NAME"
set -eu
mkdir -p dist
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
GOOS="${target%/*}"
GOARCH="${target#*/}"
out="dist/terdut-tui-${REF_NAME}-${GOOS}-${GOARCH}"
echo "building $out"
GOOS="$GOOS" GOARCH="$GOARCH" go build \
-ldflags "-X main.version=${REF_NAME}" \
-o "$out" .
done
# Creating the release is made idempotent rather than assumed-new: a re-run of a # Creating the release is made idempotent rather than assumed-new: a re-run of a
# failed release must not die on the release that already exists. Assets are # failed release must not die on the release that already exists. Assets are
@@ -126,6 +91,28 @@ jobs:
[ -n "$release_id" ] || { echo "::error::could not determine release id"; exit 1; } [ -n "$release_id" ] || { echo "::error::could not determine release id"; exit 1; }
echo "release id $release_id" echo "release id $release_id"
# The release notes are the tag's own message, minus its subject line: the
# tag body is the changelog for this project, and without this the release
# page stays empty. Only an annotated tag has one, and only a release with
# no notes is filled, so a re-run repairs a release created empty without
# overwriting notes somebody has since edited by hand.
#
# There is no jq in this image, so the JSON string is escaped by hand:
# backslashes first (or the ones added next would double), then quotes and
# tabs, then each line end becomes a literal \n.
if [ "$(git cat-file -t "$REF_NAME")" = tag ] \
&& printf '%s' "$body" | grep -q '"body":""'; then
notes=$(git tag -l --format='%(contents)' "$REF_NAME" | sed '1,2d')
if [ -n "$notes" ]; then
notes_json=$(printf '%s\n' "$notes" \
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r$//' \
| awk 'BEGIN { ORS = "\\n" } { print }')
echo "setting release notes from the tag message"
curl -sf -X PATCH -H "$auth" -H 'Content-Type: application/json' \
-d "{\"body\":\"$notes_json\"}" "$API/releases/$release_id" > /dev/null
fi
fi
for f in dist/*; do for f in dist/*; do
name=$(basename "$f") name=$(basename "$f")
# Drop an existing asset of the same name first: Gitea happily stores two # Drop an existing asset of the same name first: Gitea happily stores two
+1
View File
@@ -1,2 +1,3 @@
terdut-tui terdut-tui
.graymatter/ .graymatter/
dist/
+9
View File
@@ -0,0 +1,9 @@
# Read by the `release` skill (~/.claude/skills/release).
#
# terdut-tui publishes binaries to a Gitea release and nothing else: no image, no Helm
# chart, no wrapper in Ryuvia/charts. KIND=binary tells the skill to gate, tag, wait for
# release.yaml and verify the published assets, and to skip the chart steps.
KIND=binary
# English, like the rest of the terdut projects.
PROSE_LANG=en
+42 -5
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.4.0+**. 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).
## Domain model ## Domain model
@@ -11,10 +11,37 @@ The server splits alerts from incidents, and this client mirrors it:
assignee, snooze, notes and an append-only timeline. Many alerts to one incident, assignee, snooze, notes and an append-only timeline. Many alerts to one incident,
correlated by Alertmanager's `groupKey`. correlated by Alertmanager's `groupKey`.
The server is multi-team: incidents, alerts and the schedule belong to a team, and
the caller only sees their own teams. `Model.activeTeamID` (0 = all) narrows the
incident and alert lists via `team_id`; the schedule is per team and uses
`Model.scheduleTeam()`. Users have `is_admin`, and the TUI mirrors the server's
permission rules up front (`canEditSchedule`, `canManageUser`, `isAdmin`) so a 403 is
explained before the round trip, not after. The server has no version endpoint;
an old one is recognised by `GET /api/teams` answering 404 (`errServerTooOld`).
All user actions target incidents. Two server behaviours the UI has to respect: All user actions target incidents. Two server behaviours the UI has to respect:
manual resolve is **terminal** (hence the confirmation prompt), and snooze is the manual resolve is **terminal** (hence the confirmation prompt), and snooze is the
non-destructive "not now" alternative. non-destructive "not now" alternative.
## Release
Say **"Release"** (or "Release X.Y.Z") and the `release` skill runs it. This repo is
`KIND=binary` in `.release.conf`: it publishes binaries to a Gitea release and has no image,
chart or wrapper-chart PR. The run is gate, commit, push, tag, wait for `release.yaml`, then
`verify-release`. Preconditions and the plan, without side effects:
```sh
~/.claude/skills/release/scripts/release-preflight # state + suggested version
~/.claude/skills/release/scripts/release-preflight vX.Y.Z # validate that release
```
- `make fmt lint test` is the gate, and it **is** what `ci.yaml` and `release.yaml` run.
- `make dist VERSION=vX.Y.Z` builds the four binaries; `make dist-assets` lists their names.
The pattern `terdut-tui-<tag>-<goos>-<goarch>` is the self-updater's contract with every
installed binary, so changing it breaks self-update.
- **The annotated tag's message becomes the release notes** (`release.yaml` copies it, minus
its subject line). Write it for a reader of the release page. Never move a published tag.
## Tech stack ## Tech stack
- Go 1.25+ - Go 1.25+
@@ -25,7 +52,7 @@ non-destructive "not now" alternative.
## Project layout ## Project layout
``` ```
main.go CLI entry point: flags, config load, health check, start TUI main.go CLI entry point: flags, config load, start TUI
internal/api/client.go REST API client — one method per endpoint internal/api/client.go REST API client — one method per endpoint
internal/config/config.go Config loader (~/.config/terdut-tui/config.yaml) internal/config/config.go Config loader (~/.config/terdut-tui/config.yaml)
internal/theme/ Colour themes: semantic tokens, built-ins, user file loader internal/theme/ Colour themes: semantic tokens, built-ins, user file loader
@@ -35,7 +62,7 @@ internal/tui/ Bubbletea UI
view.go View() — pure rendering view.go View() — pure rendering
keys.go keyMap (bubbles/key pattern) keys.go keyMap (bubbles/key pattern)
styles.go Styles struct — every lipgloss style, built from a theme styles.go Styles struct — every lipgloss style, built from a theme
internal/updater/updater.go Self-update via GitHub Releases internal/updater/updater.go Self-update via Gitea releases
``` ```
## Architecture rules ## Architecture rules
@@ -54,16 +81,24 @@ Location: `~/.config/terdut-tui/config.yaml`
```yaml ```yaml
server_url: https://terdut.example.com server_url: https://terdut.example.com
api_key: <64-char hex key> username: niklas # optional, prefills the sign-in form
refresh_interval: 30 # seconds, optional, default 30 refresh_interval: 30 # seconds, optional, default 30
theme: gruvbox-dark # optional, default gruvbox-dark theme: gruvbox-dark # optional, default gruvbox-dark
team: Ops # optional, team name or id to start on, default all
``` ```
Built-in themes are `gruvbox-dark` and `gruvbox-light`; user themes are YAML Built-in themes are `gruvbox-dark` and `gruvbox-light`; user themes are YAML
files in `~/.config/terdut-tui/themes/`, optionally `extends:`-ing a built-in. files in `~/.config/terdut-tui/themes/`, optionally `extends:`-ing a built-in.
See the README for the token list. See the README for the token list.
The API key is a one-time secret generated by terdut-server (`POST /api/users/{id}/api-keys`). There is no API key in the config. The TUI signs in as a user (`POST /api/login`, the
same session cookie as the web UI) and `internal/session` keeps the token in
`~/.config/terdut-tui/session.json`, mode 0600, keyed by server URL. The client
attaches `terdut_session` itself rather than using a cookie jar, because a jar drops the
server's Secure cookie over plain http. It must never send `Authorization` as well: the
server judges a request with that header on it alone. A 401 from anything (`msgError`
in `update.go`) returns to the sign-in form and clears `Model`. A user with no password
cannot sign in, and the server answers it like a wrong one, so the form says so.
## Running ## Running
@@ -95,6 +130,8 @@ go build -ldflags="-X main.version=v0.1.0" -o terdut-tui .
| 4 | On-call schedule calendar view | | 4 | On-call schedule calendar view |
| 5 | User management and API key lifecycle | | 5 | User management and API key lifecycle |
| 6 | Incidents: queue, timeline, ack/assign/snooze/resolve, MTTA/MTTR | | 6 | Incidents: queue, timeline, ack/assign/snooze/resolve, MTTA/MTTR |
| 7 | Teams: `T` switcher, per-team schedule, admin/disabled markers (server v0.20) |
| 8 | Sign in as a user instead of an API key (server v0.10+ session cookie) |
<!-- graymatter:instructions:begin — managed by `graymatter init`; edits inside this block are overwritten --> <!-- graymatter:instructions:begin — managed by `graymatter init`; edits inside this block are overwritten -->
## Memory (GrayMatter) ## Memory (GrayMatter)
+56 -2
View File
@@ -1,6 +1,6 @@
VERSION := $(shell git describe --tags --always --dirty) VERSION := $(shell git describe --tags --always --dirty)
.PHONY: build install test .PHONY: build install test lint fmt dist dist-assets release-vars
build: build:
go build -ldflags "-X main.version=$(VERSION)" -o terdut-tui . go build -ldflags "-X main.version=$(VERSION)" -o terdut-tui .
@@ -8,5 +8,59 @@ build:
install: install:
go install -ldflags "-X main.version=$(VERSION)" . go install -ldflags "-X main.version=$(VERSION)" .
test: # ci.yaml and release.yaml run `make fmt lint test`, so a green gate here and a green
# pipeline are the same code rather than two descriptions of it. It is also what the
# release skill runs before tagging.
# Covers the API client against a stub server, the sign-in flow, the Update state
# machine, and View rendering -- all pure enough to test without a terminal.
test: ## Run the test suite
go test ./... go test ./...
lint: ## go vet
go vet ./...
# This exists because `go vet` does not look at import order: the move to
# git.ryuvia.com rewrote every import path without re-sorting, the new path sorts
# before github.com/..., and both repos sat unformatted through a green CI run and
# a release before anyone noticed.
#
# Both of gofmt's failure modes need handling, and they are not alike. A file that
# is merely misformatted is listed on stdout with exit 0 -- so the failure has to
# be raised by hand. A file that does not parse is the opposite: nothing on stdout
# and exit 2, which a naive `[ -n "$$unformatted" ]` reads as success. The first
# draft of this target had exactly that hole.
fmt: ## Fail on files that are not gofmt'd
@if ! unformatted=$$(gofmt -l .); then \
echo "gofmt could not parse the tree:"; gofmt -l .; exit 1; \
fi; \
if [ -n "$$unformatted" ]; then \
echo "not gofmt'd:"; echo "$$unformatted"; gofmt -d .; exit 1; \
fi
# What a release publishes. The asset names matter beyond being tidy: internal/updater
# looks for exactly terdut-tui-<tag>-<goos>-<goarch> in the latest release and reports
# every available name when it cannot find one. Renaming the pattern here breaks
# self-update for every installed binary.
DIST_TARGETS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64
dist: ## Build every release binary into dist/ (VERSION=vX.Y.Z to name them)
@set -eu; mkdir -p dist; \
for target in $(DIST_TARGETS); do \
goos="$${target%/*}"; goarch="$${target#*/}"; \
out="dist/terdut-tui-$(VERSION)-$$goos-$$goarch"; \
echo "building $$out"; \
GOOS="$$goos" GOARCH="$$goarch" go build -ldflags "-X main.version=$(VERSION)" -o "$$out" .; \
done
# The names dist produces, one per line, so the release skill can check the published
# release has every one of them rather than a count.
dist-assets: ## Print the asset names a release of VERSION carries
@for target in $(DIST_TARGETS); do \
echo "terdut-tui-$(VERSION)-$${target%/*}-$${target#*/}"; \
done
# Read by the release skill for a repo that publishes binaries and no image or chart
# (KIND=binary in .release.conf). There is nothing to say about images or charts.
release-vars: ## Print the variables the release process reads
@printf 'APP=terdut-tui\n'
+52 -13
View File
@@ -10,12 +10,33 @@ Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea).
- **Incident actions** — acknowledge, assign, snooze, note, resolve and archive - **Incident actions** — acknowledge, assign, snooze, note, resolve and archive
- **Timeline** — the full history of an incident, system events, pages and notes together - **Timeline** — the full history of an incident, system events, pages and notes together
- **Alert feed** — the raw read-only alerts underneath, each linked to its incident - **Alert feed** — the raw read-only alerts underneath, each linked to its incident
- **On-call schedule** — visual calendar of who is on duty, assign and remove entries - **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 - **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, set each user's ntfy topic
> Requires terdut-server **v0.4.0 or later**. Earlier servers have no incidents API; > Requires terdut-server **v0.20.0 or later**. The server became team-scoped in
> use terdut-tui v0.3.x with those. > 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.
> Escalation ladders, invites, integrations and the admin settings stay in the
> server's web UI.
## Teams
Everything the server returns is scoped to the teams your key's user belongs
to. The header shows which are on screen, and `T` steps through *all* → each of
your teams in turn. With several teams showing, incident and alert rows carry a
Team column.
The schedule is one team's rota, so the Schedule section shows the active team,
or with *all* showing the first team you own. Only a team's owners, and
administrators, can change its rota; anyone else gets the reason in the status
bar instead of a picker. The picker offers only that team's members, because the
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.
## Alerts and incidents ## Alerts and incidents
@@ -49,9 +70,6 @@ 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 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. of retries. That second one is the one to look for when nobody's phone rang.
Editing topics needs terdut-server **v0.6.0 or later**; the timeline entries need
**v0.7.0 or later**. Against an older server the topic column stays empty and
editing one reports the server's 404.
## Installation ## Installation
@@ -67,12 +85,31 @@ Create `~/.config/terdut-tui/config.yaml`:
```yaml ```yaml
server_url: https://terdut.example.com server_url: https://terdut.example.com
api_key: <your-api-key> username: niklas # optional, prefills the sign-in form
refresh_interval: 30 # seconds, optional refresh_interval: 30 # seconds, optional
theme: gruvbox-dark # optional, this is the default theme: gruvbox-dark # optional, this is the default
team: Ops # optional, a team name or id to start on; default is all
``` ```
The API key is generated in terdut-server. See the server documentation for how to bootstrap a user and issue an API key. ## Signing in
The TUI signs in the way the web UI does: with a user account's username and
password, on a form shown at start. It keeps the server's session, not the
password, in `~/.config/terdut-tui/session.json` (readable by you only), so the
next start resumes it. The server's sessions last 30 days and slide with use.
When it has ended, or the account is disabled or the session is ended from the web
UI, the TUI returns to the form and says so. `L` signs out, which also ends the
session on the server and deletes the saved one.
The account needs a password, since that is what signing in uses. A user
created only for API access has none and cannot sign in: the server answers it
exactly like a wrong password. Set one in the web UI, or have an administrator
press `p` on that user in Users. Too many failed attempts are rate limited by
the server for a few minutes.
> **Upgrading from v0.10.0 and earlier:** `api_key` in `config.yaml` is no longer
> used. Remove it and sign in. API keys still exist on the server, and `k` in
> Users still manages them, for whatever else uses them.
## Themes ## Themes
@@ -133,6 +170,8 @@ Global:
| `esc` | Go back | | `esc` | Go back |
| `r` | Refresh | | `r` | Refresh |
| `f` | Cycle filter | | `f` | Cycle filter |
| `L` | Sign out |
| `T` | Switch team: all → each of your teams (when you have more than one) |
| `q` | Quit | | `q` | Quit |
The sections, in `tab` order: Incidents · Alerts · Stats · Archived · Schedule · Users. The sections, in `tab` order: Incidents · Alerts · Stats · Archived · Schedule · Users.
@@ -181,9 +220,8 @@ Schedule section:
One person holds a given day. Assigning over days somebody else already has One person holds a given day. Assigning over days somebody else already has
asks first — naming them and how many days are being taken — and moves the whole asks first — naming them and how many days are being taken — and moves the whole
selection at once when you accept, so reassigning a week is one confirmation selection at once when you accept, so reassigning a week is one confirmation
rather than seven deletions. Taking somebody's shift needs terdut-server rather than seven deletions. The header line names the team whose rota this is,
**v0.8.0 or later**; against an older server the assignment is refused with and "On-call today" lists everyone on call across your teams.
`date already assigned`.
Users section: Users section:
@@ -196,5 +234,6 @@ Users section:
| `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 |
In Users, `k` and `d` act on the selected row, so move with `↑`/`↓` there rather In Users, `k` and `d` act on the selected row, so move with `↑`/`↓` there rather
than `k`. Setting passwords needs terdut-server **v0.10.2 or later**, the first than `k`. The Flags column marks administrators and disabled accounts. `n` and `d`
with a web UI. are for administrators; `t`, `k` and `p` work on your own row, or on anyone's if you
are one.
+259 -53
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@@ -11,29 +12,190 @@ import (
"time" "time"
) )
// SessionCookie is the cookie terdut-server's web UI signs in with.
const SessionCookie = "terdut_session"
// Client talks to terdut-server as the user who signed in. Login trades a
// username and password for a session, the same one the web UI holds, and every
// request after it carries that session's cookie.
//
// The cookie is attached by hand rather than through a cookie jar: the server
// marks it Secure behind https, and a jar drops a Secure cookie it is handed
// over plain http, which would make a local server unusable for no reason. There
// is nothing else a jar would do here — the token is opaque and does not change
// while the session lives.
type Client struct { type Client struct {
baseURL string baseURL string
httpClient *http.Client httpClient *http.Client
apiKey string session string
} }
func NewClient(baseURL, apiKey string) *Client { func NewClient(baseURL string) *Client {
return &Client{ return &Client{
baseURL: strings.TrimRight(baseURL, "/"), baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
}, },
} }
} }
// SetSession resumes a session from a token saved earlier.
func (c *Client) SetSession(token string) { c.session = token }
// HasSession reports whether there is a session to try. It says nothing about
// whether the server still honours it.
func (c *Client) HasSession() bool { return c.session != "" }
// Login signs in and returns the session token, which the client also keeps and
// sends from then on. The server answers a wrong password, an unknown user and
// an account with no password all with the same 401, so the caller cannot tell
// them apart. Too many failures come back as 429.
func (c *Client) Login(username, password string) (string, error) {
body := struct {
Username string `json:"username"`
Password string `json:"password"`
}{Username: username, Password: password}
req, err := c.newRequestWithBody(http.MethodPost, "/api/login", body)
if err != nil {
return "", err
}
// A stale session must not ride along on the request that replaces it.
req.Header.Del("Cookie")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", statusError(resp)
}
for _, ck := range resp.Cookies() {
if ck.Name == SessionCookie && ck.Value != "" {
c.session = ck.Value
return ck.Value, nil
}
}
return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie)
}
// AuthConfig asks how the server can be signed in to. It is unauthenticated, so
// it works before anybody has signed in.
func (c *Client) AuthConfig() (AuthConfig, error) {
var cfg AuthConfig
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/api/auth/config", nil)
if err != nil {
return cfg, err
}
req.Header.Set("Accept", "application/json")
err = c.do(req, &cfg)
return cfg, err
}
// The ways a device login poll can end other than with a session.
var (
// ErrDevicePending means nobody has approved yet: poll again after the
// interval.
ErrDevicePending = errors.New("waiting for approval")
// ErrDeviceSlowDown means the server was polled faster than it asked. It is
// not a failure; poll again, a little slower.
ErrDeviceSlowDown = errors.New("polling too fast")
// ErrDeviceExpired means the person took too long, or the server forgot the
// login. ErrDeviceDenied means they refused it.
ErrDeviceExpired = errors.New("the sign-in expired")
ErrDeviceDenied = errors.New("the sign-in was refused")
)
// StartDeviceLogin asks the server to begin a device login.
func (c *Client) StartDeviceLogin() (*DeviceLogin, error) {
req, err := c.newRequestWithBody(http.MethodPost, "/api/oidc/device", struct{}{})
if err != nil {
return nil, err
}
req.Header.Del("Cookie")
var d DeviceLogin
if err := c.do(req, &d); err != nil {
return nil, err
}
if d.DeviceCode == "" || d.UserCode == "" || d.VerificationURL == "" {
return nil, errors.New("server started a sign-in but sent no code")
}
return &d, nil
}
// PollDeviceLogin asks whether the person has approved. On approval it returns
// the session token, which the client also keeps; until then it returns one of
// the ErrDevice* errors.
func (c *Client) PollDeviceLogin(deviceCode string) (string, error) {
req, err := c.newRequestWithBody(http.MethodPost, "/api/oidc/device/token",
struct {
DeviceCode string `json:"device_code"`
}{deviceCode})
if err != nil {
return "", err
}
req.Header.Del("Cookie")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusAccepted:
return "", ErrDevicePending
case http.StatusTooManyRequests:
return "", ErrDeviceSlowDown
case http.StatusGone:
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error == "denied" {
return "", ErrDeviceDenied
}
return "", ErrDeviceExpired
}
if resp.StatusCode >= 400 {
return "", statusError(resp)
}
for _, ck := range resp.Cookies() {
if ck.Name == SessionCookie && ck.Value != "" {
c.session = ck.Value
return ck.Value, nil
}
}
return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie)
}
// Logout ends the session on the server and forgets it here.
func (c *Client) Logout() error {
req, err := c.newRequest(http.MethodPost, "/api/logout")
if err != nil {
return err
}
err = c.do(req, nil)
c.session = ""
return err
}
// authorize puts the session on a request.
func (c *Client) authorize(req *http.Request) {
req.Header.Set("Accept", "application/json")
if c.session != "" {
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: c.session})
}
}
func (c *Client) newRequest(method, path string) (*http.Request, error) { func (c *Client) newRequest(method, path string) (*http.Request, error) {
req, err := http.NewRequest(method, c.baseURL+path, nil) req, err := http.NewRequest(method, c.baseURL+path, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Authorization", "Bearer "+c.apiKey) c.authorize(req)
req.Header.Set("Accept", "application/json")
return req, nil return req, nil
} }
@@ -51,6 +213,21 @@ func (e *StatusError) Error() string {
return fmt.Sprintf("server returned %d", e.Code) return fmt.Sprintf("server returned %d", e.Code)
} }
// IsUnauthorized reports whether err is the server refusing the session: it
// expired, was ended elsewhere, or belongs to an account since disabled.
func IsUnauthorized(err error) bool {
var se *StatusError
return errors.As(err, &se) && se.Code == http.StatusUnauthorized
}
func statusError(resp *http.Response) error {
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
return &StatusError{Code: resp.StatusCode, Message: e.Error}
}
func (c *Client) do(req *http.Request, out any) error { func (c *Client) do(req *http.Request, out any) error {
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -59,11 +236,7 @@ func (c *Client) do(req *http.Request, out any) error {
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
var e struct { return statusError(resp)
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
return &StatusError{Code: resp.StatusCode, Message: e.Error}
} }
if out != nil { if out != nil {
@@ -72,14 +245,18 @@ func (c *Client) do(req *http.Request, out any) error {
return nil return nil
} }
// ListAlerts fetches alerts. status may be "firing", "resolved", or "" for all. // ListAlerts fetches alerts. teamID limits them to one team; 0 means every team
// the caller belongs to. status may be "firing", "resolved", or "" for all.
// Set archived=true to fetch only archived alerts; false returns only non-archived. // Set archived=true to fetch only archived alerts; false returns only non-archived.
// //
// Alerts are read-only on the server — there is nothing to acknowledge or // Alerts are read-only on the server — there is nothing to acknowledge or
// archive here. This is the raw feed, useful for checking what Alertmanager is // archive here. This is the raw feed, useful for checking what Alertmanager is
// actually sending; the work queue is ListIncidents. // actually sending; the work queue is ListIncidents.
func (c *Client) ListAlerts(status string, archived bool, limit int) ([]Alert, error) { func (c *Client) ListAlerts(teamID int64, status string, archived bool, limit int) ([]Alert, error) {
q := url.Values{} q := url.Values{}
if teamID > 0 {
q.Set("team_id", strconv.FormatInt(teamID, 10))
}
if status != "" { if status != "" {
q.Set("status", status) q.Set("status", status)
} }
@@ -121,8 +298,7 @@ func (c *Client) newRequestWithBody(method, path string, body any) (*http.Reques
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Authorization", "Bearer "+c.apiKey) c.authorize(req)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
return req, nil return req, nil
} }
@@ -138,12 +314,16 @@ func (c *Client) GetAlert(id int64) (*Alert, error) {
// ── Incidents ────────────────────────────────────────────────────────────── // ── Incidents ──────────────────────────────────────────────────────────────
// ListIncidents fetches the work queue. status may be "triggered", // ListIncidents fetches the work queue. teamID limits it to one team; 0 means
// every team the caller belongs to. status may be "triggered",
// "acknowledged", "resolved", or "" for the server default of open incidents // "acknowledged", "resolved", or "" for the server default of open incidents
// only. archived and snoozed each switch the list to that set rather than // only. archived and snoozed each switch the list to that set rather than
// adding to it, matching the server's filters. // adding to it, matching the server's filters.
func (c *Client) ListIncidents(status string, archived, snoozed bool, limit int) ([]Incident, error) { func (c *Client) ListIncidents(teamID int64, status string, archived, snoozed bool, limit int) ([]Incident, error) {
q := url.Values{} q := url.Values{}
if teamID > 0 {
q.Set("team_id", strconv.FormatInt(teamID, 10))
}
if status != "" { if status != "" {
q.Set("status", status) q.Set("status", status)
} }
@@ -188,6 +368,18 @@ func (c *Client) GetIncidentTimeline(id int64) ([]IncidentEvent, error) {
return events, c.do(req, &events) return events, c.do(req, &events)
} }
// GetSimilarIncidents lists earlier resolved incidents that look like this one
// and have notes. Servers before the similar-incidents endpoint answer 404; the
// caller treats any error as "nothing to show".
func (c *Client) GetSimilarIncidents(id int64) ([]SimilarIncident, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d/similar", id))
if err != nil {
return nil, err
}
var similar []SimilarIncident
return similar, c.do(req, &similar)
}
func (c *Client) AcknowledgeIncident(id int64) (*Incident, error) { func (c *Client) AcknowledgeIncident(id int64) (*Incident, error) {
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/acknowledge", id)) req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/acknowledge", id))
if err != nil { if err != nil {
@@ -269,10 +461,11 @@ func (c *Client) UnarchiveIncident(id int64) error {
return c.do(req, nil) return c.do(req, nil)
} }
// AddNote appends a note to the incident's timeline. // AddNote appends a note to the incident's timeline. pinned files it as the
func (c *Client) AddNote(incidentID int64, content string) (*IncidentEvent, error) { // resolution note: what fixed the incident, shown on similar ones later.
func (c *Client) AddNote(incidentID int64, content string, pinned bool) (*IncidentEvent, error) {
req, err := c.newRequestWithBody(http.MethodPost, req, err := c.newRequestWithBody(http.MethodPost,
fmt.Sprintf("/api/incidents/%d/notes", incidentID), map[string]string{"content": content}) fmt.Sprintf("/api/incidents/%d/notes", incidentID), map[string]any{"content": content, "pinned": pinned})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -329,8 +522,37 @@ func (c *Client) GetStatsByDay() ([]DayStat, error) {
return result, c.do(req, &result) return result, c.do(req, &result)
} }
func (c *Client) GetSchedule(from, to string) ([]ScheduleEntry, error) { // ── Teams ──────────────────────────────────────────────────────────────────
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/schedule?from=%s&to=%s", from, to))
// ListTeams returns the teams the caller belongs to, with the caller's role in
// each. Everything else the server returns is scoped to these. A server that
// predates teams (v0.12) answers 404, which is how the TUI spots one.
func (c *Client) ListTeams() ([]Team, error) {
req, err := c.newRequest(http.MethodGet, "/api/teams")
if err != nil {
return nil, err
}
var teams []Team
return teams, c.do(req, &teams)
}
// ListTeamMembers returns who belongs to a team. A schedule can only be given to
// its own members, so this is the assignee list for one.
func (c *Client) ListTeamMembers(teamID int64) ([]TeamMember, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/teams/%d/members", teamID))
if err != nil {
return nil, err
}
var members []TeamMember
return members, c.do(req, &members)
}
// ── Schedule ───────────────────────────────────────────────────────────────
// GetSchedule returns a team's on-call entries between two YYYY-MM-DD dates.
func (c *Client) GetSchedule(teamID int64, from, to string) ([]ScheduleEntry, error) {
q := url.Values{"from": {from}, "to": {to}}
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/teams/%d/schedule?%s", teamID, q.Encode()))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -338,49 +560,30 @@ func (c *Client) GetSchedule(from, to string) ([]ScheduleEntry, error) {
return entries, c.do(req, &entries) return entries, c.do(req, &entries)
} }
// GetCurrentOnCall returns today's on-call entry, or nil if nobody is scheduled. // GetCurrentOnCall returns today's on-call entries, one per team that has
func (c *Client) GetCurrentOnCall() (*ScheduleEntry, error) { // somebody scheduled. It is empty, not an error, when nobody is.
func (c *Client) GetCurrentOnCall() ([]ScheduleEntry, error) {
req, err := c.newRequest(http.MethodGet, "/api/schedule/current") req, err := c.newRequest(http.MethodGet, "/api/schedule/current")
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err := c.httpClient.Do(req) var entries []ScheduleEntry
if err != nil { return entries, c.do(req, &entries)
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode >= 400 {
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error != "" {
return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, e.Error)
}
return nil, fmt.Errorf("server returned %d", resp.StatusCode)
}
var entry ScheduleEntry
if err := json.NewDecoder(resp.Body).Decode(&entry); err != nil {
return nil, err
}
return &entry, nil
} }
// AssignSchedule puts one user on call for the given dates. // AssignSchedule puts one team member on call for the given dates. Only a team
// owner or an administrator may.
// //
// The server holds one person per day and refuses a date somebody already has, // The server holds one person per day and refuses a date somebody already has,
// so replace is what takes a shift off its current holder. It is all-or-nothing // so replace is what takes a shift off its current holder. It is all-or-nothing
// either way: a week of free and taken days moves as a unit, or not at all. // either way: a week of free and taken days moves as a unit, or not at all.
func (c *Client) AssignSchedule(userID int64, dates []string, replace bool) ([]ScheduleEntry, error) { func (c *Client) AssignSchedule(teamID, userID int64, dates []string, replace bool) ([]ScheduleEntry, error) {
body := struct { body := struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Dates []string `json:"dates"` Dates []string `json:"dates"`
Replace bool `json:"replace,omitempty"` Replace bool `json:"replace,omitempty"`
}{UserID: userID, Dates: dates, Replace: replace} }{UserID: userID, Dates: dates, Replace: replace}
req, err := c.newRequestWithBody(http.MethodPost, "/api/schedule", body) req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/teams/%d/schedule", teamID), body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -388,14 +591,16 @@ func (c *Client) AssignSchedule(userID int64, dates []string, replace bool) ([]S
return entries, c.do(req, &entries) return entries, c.do(req, &entries)
} }
func (c *Client) DeleteScheduleEntry(id int64) error { func (c *Client) DeleteScheduleEntry(teamID, id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/schedule/%d", id)) req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/teams/%d/schedule/%d", teamID, id))
if err != nil { if err != nil {
return err return err
} }
return c.do(req, nil) return c.do(req, nil)
} }
// ── Users ──────────────────────────────────────────────────────────────────
func (c *Client) ListUsers() ([]User, error) { func (c *Client) ListUsers() ([]User, error) {
req, err := c.newRequest(http.MethodGet, "/api/users") req, err := c.newRequest(http.MethodGet, "/api/users")
if err != nil { if err != nil {
@@ -464,7 +669,7 @@ func (c *Client) DeleteAPIKey(userID, keyID int64) error {
} }
// Me returns the user the API key belongs to, and whether they have a web UI // Me returns the user the API key belongs to, and whether they have a web UI
// password. Needs terdut-server v0.10.2 or later; older servers answer 404. // password.
func (c *Client) Me() (*Me, error) { func (c *Client) Me() (*Me, error) {
req, err := c.newRequest(http.MethodGet, "/api/me") req, err := c.newRequest(http.MethodGet, "/api/me")
if err != nil { if err != nil {
@@ -488,7 +693,8 @@ func (c *Client) SetPassword(userID int64, password, current string) error {
return c.do(req, nil) return c.do(req, nil)
} }
// HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway). // HealthCheck calls GET /healthz, which is unauthenticated and does no database
// check, so it says the process is up, not that the API key works.
func (c *Client) HealthCheck() error { func (c *Client) HealthCheck() error {
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil) req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
if err != nil { if err != nil {
+298 -24
View File
@@ -19,7 +19,9 @@ type call struct {
path string path string
query string query string
body string body string
auth string cookie string
// authz is the Authorization header, which the client no longer sends at all.
authz string
} }
// stub serves one canned response and records the request that fetched it. // stub serves one canned response and records the request that fetched it.
@@ -29,22 +31,107 @@ func stub(t *testing.T, status int, response string) (*Client, *call) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
got.method, got.path, got.query = r.Method, r.URL.Path, r.URL.RawQuery got.method, got.path, got.query = r.Method, r.URL.Path, r.URL.RawQuery
got.body, got.auth = string(body), r.Header.Get("Authorization") got.body, got.authz = string(body), r.Header.Get("Authorization")
if ck, err := r.Cookie(SessionCookie); err == nil {
got.cookie = ck.Value
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) w.WriteHeader(status)
io.WriteString(w, response) io.WriteString(w, response)
})) }))
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
return NewClient(srv.URL, "test-key"), got c := NewClient(srv.URL)
c.SetSession("test-session")
return c, got
} }
func TestClient_SendsBearerToken(t *testing.T) { func TestClient_SendsTheSessionCookie(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`) c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListIncidents("", false, false, 0); err != nil { if _, err := c.ListIncidents(0, "", false, false, 0); err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
if got.auth != "Bearer test-key" { if got.cookie != "test-session" {
t.Errorf("expected bearer token, got %q", got.auth) t.Errorf("expected the session cookie, got %q", got.cookie)
}
// The server judges a request with an Authorization header on that alone and
// never falls back to the cookie, so sending one would defeat the session.
if got.authz != "" {
t.Errorf("expected no Authorization header, got %q", got.authz)
}
}
// Login has to work over plain http, where a cookie jar would discard the
// Secure cookie a server behind https sets.
func TestLogin_KeepsTheSessionFromTheCookie(t *testing.T) {
var body string
var sentCookie bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
body = string(b)
_, err := r.Cookie(SessionCookie)
sentCookie = err == nil
http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "fresh", Path: "/", HttpOnly: true, Secure: true})
w.WriteHeader(http.StatusOK)
io.WriteString(w, `{"user":{"id":1},"has_password":true}`)
}))
t.Cleanup(srv.Close)
c := NewClient(srv.URL)
c.SetSession("stale")
token, err := c.Login("niklas", "correct horse")
if err != nil {
t.Fatalf("login: %v", err)
}
if token != "fresh" || !c.HasSession() {
t.Errorf("expected the new token to be kept, got %q", token)
}
if body != `{"username":"niklas","password":"correct horse"}` {
t.Errorf("unexpected body %q", body)
}
if sentCookie {
t.Error("a stale session must not ride along on the login that replaces it")
}
}
func TestLogin_RefusalCarriesTheServersWords(t *testing.T) {
c, _ := stub(t, http.StatusUnauthorized, `{"error":"invalid username or password"}`)
_, err := c.Login("niklas", "wrong")
if !IsUnauthorized(err) || !strings.Contains(err.Error(), "invalid username or password") {
t.Errorf("expected the server's 401 message, got %v", err)
}
c, _ = stub(t, http.StatusTooManyRequests, `{"error":"too many attempts"}`)
if _, err := c.Login("niklas", "wrong"); err == nil || IsUnauthorized(err) {
t.Errorf("a rate limit is not an authentication failure, got %v", err)
}
}
func TestLogin_NoCookieIsAnError(t *testing.T) {
c, _ := stub(t, http.StatusOK, `{}`)
if _, err := c.Login("niklas", "pw"); err == nil {
t.Error("a 200 without a session cookie is not a sign-in")
}
}
func TestLogout_ForgetsTheSession(t *testing.T) {
c, got := stub(t, http.StatusNoContent, ``)
if err := c.Logout(); err != nil {
t.Fatalf("logout: %v", err)
}
if got.method != "POST" || got.path != "/api/logout" || got.cookie != "test-session" {
t.Errorf("unexpected request %s %s cookie=%q", got.method, got.path, got.cookie)
}
if c.HasSession() {
t.Error("the session should be gone locally")
}
}
func TestIsUnauthorized(t *testing.T) {
if !IsUnauthorized(&StatusError{Code: 401}) {
t.Error("a 401 is unauthorized")
}
if IsUnauthorized(&StatusError{Code: 403}) || IsUnauthorized(errors.New("x")) || IsUnauthorized(nil) {
t.Error("only a 401 means the session is refused; a 403 is a permission")
} }
} }
@@ -78,8 +165,10 @@ func TestClient_IncidentEndpoints(t *testing.T) {
http.MethodPost, "/api/incidents/7/archive", ""}, http.MethodPost, "/api/incidents/7/archive", ""},
{"unarchive", func(c *Client) error { return c.UnarchiveIncident(7) }, {"unarchive", func(c *Client) error { return c.UnarchiveIncident(7) },
http.MethodDelete, "/api/incidents/7/archive", ""}, http.MethodDelete, "/api/incidents/7/archive", ""},
{"add note", func(c *Client) error { _, err := c.AddNote(7, "hi"); return err }, {"add note", func(c *Client) error { _, err := c.AddNote(7, "hi", false); return err },
http.MethodPost, "/api/incidents/7/notes", ""}, http.MethodPost, "/api/incidents/7/notes", ""},
{"similar", func(c *Client) error { _, err := c.GetSimilarIncidents(7); return err },
http.MethodGet, "/api/incidents/7/similar", `[]`},
{"delete note", func(c *Client) error { return c.DeleteNote(7, 12) }, {"delete note", func(c *Client) error { return c.DeleteNote(7, 12) },
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 },
@@ -108,23 +197,26 @@ func TestClient_IncidentEndpoints(t *testing.T) {
func TestListIncidents_Filters(t *testing.T) { func TestListIncidents_Filters(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
teamID int64
status string status string
archived bool archived bool
snoozed bool snoozed bool
limit int limit int
want string want string
}{ }{
{"default is the open queue", "", false, false, 0, ""}, {"default is the open queue", 0, "", false, false, 0, ""},
{"status", "triggered", false, false, 0, "status=triggered"}, {"status", 0, "triggered", false, false, 0, "status=triggered"},
{"archived", "resolved", true, false, 0, "archived=true&status=resolved"}, {"archived", 0, "resolved", true, false, 0, "archived=true&status=resolved"},
{"snoozed", "", false, true, 0, "snoozed=true"}, {"snoozed", 0, "", false, true, 0, "snoozed=true"},
{"limit", "", false, false, 500, "limit=500"}, {"limit", 0, "", false, false, 500, "limit=500"},
{"one team", 4, "", false, false, 0, "team_id=4"},
{"no team means all of them", 0, "triggered", false, false, 0, "status=triggered"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`) c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListIncidents(tt.status, tt.archived, tt.snoozed, tt.limit); err != nil { if _, err := c.ListIncidents(tt.teamID, tt.status, tt.archived, tt.snoozed, tt.limit); err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
if got.query != tt.want { if got.query != tt.want {
@@ -171,9 +263,12 @@ func TestClient_RequestBodies(t *testing.T) {
// wire when asked for — and stay off it when not. // wire when asked for — and stay off it when not.
t.Run("assign schedule", func(t *testing.T) { t.Run("assign schedule", func(t *testing.T) {
c, got := stub(t, http.StatusCreated, `[]`) c, got := stub(t, http.StatusCreated, `[]`)
if _, err := c.AssignSchedule(3, []string{"2026-07-27"}, false); err != nil { if _, err := c.AssignSchedule(9, 3, []string{"2026-07-27"}, false); err != nil {
t.Fatalf("assign: %v", err) t.Fatalf("assign: %v", err)
} }
if got.method != "POST" || got.path != "/api/teams/9/schedule" {
t.Errorf("expected POST /api/teams/9/schedule, got %s %s", got.method, got.path)
}
if got.body != `{"user_id":3,"dates":["2026-07-27"]}` { if got.body != `{"user_id":3,"dates":["2026-07-27"]}` {
t.Errorf("unexpected body %q", got.body) t.Errorf("unexpected body %q", got.body)
} }
@@ -181,7 +276,7 @@ func TestClient_RequestBodies(t *testing.T) {
t.Run("assign schedule with replace", func(t *testing.T) { t.Run("assign schedule with replace", func(t *testing.T) {
c, got := stub(t, http.StatusCreated, `[]`) c, got := stub(t, http.StatusCreated, `[]`)
if _, err := c.AssignSchedule(3, []string{"2026-07-27"}, true); err != nil { if _, err := c.AssignSchedule(9, 3, []string{"2026-07-27"}, true); err != nil {
t.Fatalf("assign: %v", err) t.Fatalf("assign: %v", err)
} }
if got.body != `{"user_id":3,"dates":["2026-07-27"],"replace":true}` { if got.body != `{"user_id":3,"dates":["2026-07-27"],"replace":true}` {
@@ -247,15 +342,91 @@ func TestClient_ErrorWithoutBody(t *testing.T) {
} }
} }
// Nobody on call is a normal state, not a failure. // Nobody on call is a normal state, not a failure: the server answers with an
func TestGetCurrentOnCall_404IsNotAnError(t *testing.T) { // empty list, one entry per team that has somebody scheduled.
c, _ := stub(t, http.StatusNotFound, `{"error":"no one is on call today"}`) func TestGetCurrentOnCall_ListsOnePerTeam(t *testing.T) {
entry, err := c.GetCurrentOnCall() c, got := stub(t, http.StatusOK, `[
{"id":1,"team_id":1,"team_name":"Ops","user_id":5,"username":"alice","date":"2026-09-23"},
{"id":2,"team_id":2,"team_name":"Dev","user_id":6,"username":"bob","date":"2026-09-23"}]`)
entries, err := c.GetCurrentOnCall()
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("on call: %v", err)
} }
if entry != nil { if got.path != "/api/schedule/current" {
t.Errorf("expected nil entry, got %+v", entry) t.Errorf("unexpected path %q", got.path)
}
if len(entries) != 2 || entries[0].TeamName != "Ops" || entries[1].Username != "bob" {
t.Errorf("unexpected entries %+v", entries)
}
c, _ = stub(t, http.StatusOK, `[]`)
if entries, err := c.GetCurrentOnCall(); err != nil || len(entries) != 0 {
t.Errorf("expected no entries and no error, got %v, %v", entries, err)
}
}
// Schedules belong to a team, so every call for one has to say which.
func TestSchedule_IsPerTeam(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.GetSchedule(7, "2026-09-21", "2026-09-27"); err != nil {
t.Fatalf("get schedule: %v", err)
}
if got.path != "/api/teams/7/schedule" || got.query != "from=2026-09-21&to=2026-09-27" {
t.Errorf("unexpected request %s?%s", got.path, got.query)
}
c, got = stub(t, http.StatusNoContent, ``)
if err := c.DeleteScheduleEntry(7, 12); err != nil {
t.Fatalf("delete: %v", err)
}
if got.method != "DELETE" || got.path != "/api/teams/7/schedule/12" {
t.Errorf("unexpected request %s %s", got.method, got.path)
}
}
func TestTeams(t *testing.T) {
c, got := stub(t, http.StatusOK, `[{"id":3,"name":"Ops","created_at":"2026-09-20T10:00:00Z","role":"owner"}]`)
teams, err := c.ListTeams()
if err != nil {
t.Fatalf("list teams: %v", err)
}
if got.path != "/api/teams" || len(teams) != 1 || teams[0].Role != RoleOwner || teams[0].Name != "Ops" {
t.Errorf("unexpected %s %+v", got.path, teams)
}
c, got = stub(t, http.StatusOK, `[{"team_id":3,"user_id":5,"username":"alice","role":"member"}]`)
members, err := c.ListTeamMembers(3)
if err != nil {
t.Fatalf("list members: %v", err)
}
if got.path != "/api/teams/3/members" || len(members) != 1 || members[0].UserID != 5 {
t.Errorf("unexpected %s %+v", got.path, members)
}
}
// A server that predates teams has no /api/teams, and the TUI recognises one by
// that 404, so it must come back as a StatusError carrying the code.
func TestListTeams_OldServerIs404(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `{"error":"not found"}`)
_, err := c.ListTeams()
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Errorf("expected a 404 StatusError, got %v", err)
}
}
func TestUser_DecodesAdminAndDisabled(t *testing.T) {
var u User
if err := json.Unmarshal([]byte(
`{"id":1,"username":"a","is_admin":true,"disabled_at":"2026-09-22T08:00:00Z"}`), &u); err != nil {
t.Fatalf("decode: %v", err)
}
if !u.IsAdmin || !u.IsDisabled() {
t.Errorf("expected an admin who is disabled, got %+v", u)
}
var other User
if err := json.Unmarshal([]byte(`{"id":2,"username":"b","is_admin":false}`), &other); err != nil || other.IsDisabled() {
t.Errorf("a user with no disabled_at must not be disabled")
} }
} }
@@ -340,7 +511,7 @@ func TestAlert_DecodesIncidentLink(t *testing.T) {
func TestListAlerts_ArchivedFilter(t *testing.T) { func TestListAlerts_ArchivedFilter(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`) c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListAlerts("", true, 50); err != nil { if _, err := c.ListAlerts(0, "", true, 50); err != nil {
t.Fatalf("list alerts: %v", err) t.Fatalf("list alerts: %v", err)
} }
if got.path != "/api/alerts" || got.query != "archived=true&limit=50" { if got.path != "/api/alerts" || got.query != "archived=true&limit=50" {
@@ -413,3 +584,106 @@ func TestClient_StatusErrorKeepsCodeAndMessage(t *testing.T) {
t.Errorf("message changed: %q", err.Error()) t.Errorf("message changed: %q", err.Error())
} }
} }
func TestAuthConfig_ReadsWhatTheServerOffers(t *testing.T) {
c, got := stub(t, http.StatusOK,
`{"password_login":false,"oidc":{"enabled":true,"name":"Authentik"},"device_login":true}`)
cfg, err := c.AuthConfig()
if err != nil {
t.Fatal(err)
}
if got.method != http.MethodGet || got.path != "/api/auth/config" {
t.Errorf("wrong request: %s %s", got.method, got.path)
}
if cfg.PasswordLogin || !cfg.OIDC.Enabled || cfg.OIDC.Name != "Authentik" || !cfg.DeviceLogin {
t.Errorf("config: %+v", cfg)
}
}
func TestAuthConfig_OldServerAnswers404(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `{"error":"not found"}`)
_, err := c.AuthConfig()
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Errorf("want a 404 StatusError, got %v", err)
}
}
func TestStartDeviceLogin_SendsNoSessionAndReturnsTheCodes(t *testing.T) {
c, got := stub(t, http.StatusOK, `{"device_code":"dev","user_code":"BCDF-GHJK",
"verification_url":"https://terdut.example.com/device?code=BCDF-GHJK","interval":5,"expires_in":600}`)
d, err := c.StartDeviceLogin()
if err != nil {
t.Fatal(err)
}
if got.method != http.MethodPost || got.path != "/api/oidc/device" {
t.Errorf("wrong request: %s %s", got.method, got.path)
}
// A stale session must not ride along on a request that replaces it.
if got.cookie != "" {
t.Errorf("sent the old session %q", got.cookie)
}
if d.DeviceCode != "dev" || d.UserCode != "BCDF-GHJK" || d.Interval != 5 || d.ExpiresIn != 600 ||
d.VerificationURL != "https://terdut.example.com/device?code=BCDF-GHJK" {
t.Errorf("login: %+v", d)
}
}
func TestStartDeviceLogin_AReplyWithoutCodesIsAnError(t *testing.T) {
c, _ := stub(t, http.StatusOK, `{}`)
if _, err := c.StartDeviceLogin(); err == nil {
t.Error("an empty reply must not be taken for a started login")
}
}
func TestPollDeviceLogin_Outcomes(t *testing.T) {
for _, tc := range []struct {
name string
status int
body string
want error
}{
{"pending", http.StatusAccepted, `{"status":"pending"}`, ErrDevicePending},
{"slow down", http.StatusTooManyRequests, `{"error":"slow_down"}`, ErrDeviceSlowDown},
{"expired", http.StatusGone, `{"error":"expired"}`, ErrDeviceExpired},
{"denied", http.StatusGone, `{"error":"denied"}`, ErrDeviceDenied},
} {
t.Run(tc.name, func(t *testing.T) {
c, got := stub(t, tc.status, tc.body)
tok, err := c.PollDeviceLogin("dev")
if !errors.Is(err, tc.want) || tok != "" {
t.Errorf("got %q, %v; want %v", tok, err, tc.want)
}
if got.path != "/api/oidc/device/token" || !strings.Contains(got.body, `"device_code":"dev"`) {
t.Errorf("wrong request: %s %s", got.path, got.body)
}
if c.HasSession() && c.session == "" {
t.Error("session state corrupted")
}
})
}
}
func TestPollDeviceLogin_ApprovalKeepsTheSessionFromTheCookie(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "granted"})
io.WriteString(w, `{"user":{}}`)
}))
t.Cleanup(srv.Close)
c := NewClient(srv.URL)
tok, err := c.PollDeviceLogin("dev")
if err != nil || tok != "granted" {
t.Fatalf("got %q, %v", tok, err)
}
if !c.HasSession() {
t.Error("the client must keep the session it was given")
}
}
func TestPollDeviceLogin_ApprovalWithoutACookieIsAnError(t *testing.T) {
c, _ := stub(t, http.StatusOK, `{"user":{}}`)
c.SetSession("")
if _, err := c.PollDeviceLogin("dev"); err == nil {
t.Error("a 200 with no session cookie is not a sign-in")
}
}
+101 -3
View File
@@ -7,6 +7,8 @@ import "time"
// alert belongs to. // alert belongs to.
type Alert struct { type Alert struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
Fingerprint string `json:"fingerprint"` Fingerprint string `json:"fingerprint"`
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
@@ -25,10 +27,40 @@ type Alert struct {
// ResolutionSource records why a resolved alert left the firing state: // ResolutionSource records why a resolved alert left the firing state:
// "alertmanager" for a real resolved webhook, "expiry" when the server // "alertmanager" for a real resolved webhook, "expiry" when the server
// inferred it after the alert stopped being refreshed. // inferred it after the alert stopped being refreshed, "deadman" for a
// dead man's switch that came back. Treat the value set as open.
ResolutionSource *string `json:"resolution_source,omitempty"` ResolutionSource *string `json:"resolution_source,omitempty"`
} }
// AuthConfig is how the server can be signed in to, from the unauthenticated
// GET /api/auth/config. A server too old to have the endpoint answers 404, which
// callers treat as "passwords only".
type AuthConfig struct {
PasswordLogin bool `json:"password_login"`
OIDC struct {
Enabled bool `json:"enabled"`
Name string `json:"name"`
} `json:"oidc"`
// DeviceLogin is whether the server can sign in a client that has no browser,
// by showing a code (see StartDeviceLogin).
DeviceLogin bool `json:"device_login"`
}
// DeviceLogin is a sign-in the server has started for this client: the person
// opens VerificationURL, checks UserCode, and approves; the client polls with
// DeviceCode until the server hands over a session.
type DeviceLogin struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_url"`
// Interval is how many seconds to wait between polls, and ExpiresIn how many
// the person has to approve.
Interval int `json:"interval"`
ExpiresIn int `json:"expires_in"`
}
// Incident statuses. // Incident statuses.
const ( const (
StatusTriggered = "triggered" StatusTriggered = "triggered"
@@ -41,6 +73,8 @@ const (
// groupKey Alertmanager computed from the operator's group_by configuration. // groupKey Alertmanager computed from the operator's group_by configuration.
type Incident struct { type Incident struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
GroupKey string `json:"group_key"` GroupKey string `json:"group_key"`
Title string `json:"title"` Title string `json:"title"`
GroupLabels map[string]string `json:"group_labels"` GroupLabels map[string]string `json:"group_labels"`
@@ -63,10 +97,17 @@ type Incident struct {
ResolvedAt *time.Time `json:"resolved_at,omitempty"` ResolvedAt *time.Time `json:"resolved_at,omitempty"`
// ResolutionSource is "alerts" when every alert stopped firing, or "manual" // ResolutionSource is "alerts" when every alert stopped firing, "manual"
// when a person closed it. Treat the value set as open. // when a person closed it, or "recovered" when a dead man's switch came back.
// Treat the value set as open.
ResolutionSource *string `json:"resolution_source,omitempty"` ResolutionSource *string `json:"resolution_source,omitempty"`
// EscalationLevel is how far up the team's escalation ladder the incident has
// climbed (0 = not escalated). EscalationDueAt is when the next step fires,
// and is nil once the ladder is exhausted or the incident is acknowledged.
EscalationLevel int `json:"escalation_level"`
EscalationDueAt *time.Time `json:"escalation_due_at,omitempty"`
ArchivedAt *time.Time `json:"archived_at,omitempty"` ArchivedAt *time.Time `json:"archived_at,omitempty"`
// Alerts is populated by GET /api/incidents/{id} only. // Alerts is populated by GET /api/incidents/{id} only.
@@ -96,6 +137,13 @@ const (
EventResolved = "resolved" EventResolved = "resolved"
EventNote = "note" EventNote = "note"
// A note marked as what fixed the incident. The server leads similar
// incidents with these.
EventResolutionNote = "resolution_note"
// Written when a team's dead man's switch stops reporting.
EventDeadmanSilent = "deadman_silent"
// 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
@@ -154,6 +202,8 @@ type DayStat struct {
type ScheduleEntry struct { type ScheduleEntry struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
Date string `json:"date"` // YYYY-MM-DD Date string `json:"date"` // YYYY-MM-DD
@@ -166,6 +216,14 @@ type User struct {
Email string `json:"email"` Email string `json:"email"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
// IsAdmin marks a system administrator: the only kind of user who can create
// or delete users and act on other people's passwords and keys.
IsAdmin bool `json:"is_admin"`
// DisabledAt is set when an administrator has disabled the account. A
// 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 // 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 // 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 // string as NULL. Their incidents fall back to the server's shared fallback
@@ -182,12 +240,40 @@ func (u User) Topic() string {
return *u.NtfyTopic return *u.NtfyTopic
} }
// IsDisabled reports whether the account has been disabled.
func (u User) IsDisabled() bool { return u.DisabledAt != nil }
// Me is GET /api/me: the caller, and whether they can sign in to the web UI. // Me is GET /api/me: the caller, and whether they can sign in to the web UI.
type Me struct { type Me struct {
User User `json:"user"` User User `json:"user"`
HasPassword bool `json:"has_password"` HasPassword bool `json:"has_password"`
} }
// Team roles.
const (
RoleOwner = "owner"
RoleMember = "member"
)
// Team is a group that owns integrations, incidents, a schedule and an
// escalation ladder. Role is the caller's role in it, and is only present on
// the caller's own team lists (GET /api/teams).
type Team struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
Role string `json:"role,omitempty"`
}
// TeamMember is one person's membership of a team.
type TeamMember struct {
TeamID int64 `json:"team_id"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
}
type APIKey struct { type APIKey struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
@@ -196,3 +282,15 @@ type APIKey struct {
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"`
} }
// SimilarIncident is an earlier, resolved incident with the same signature
// (alert name plus stable group labels) as the one being viewed. ResolutionNotes
// are its "what fixed it" notes; NoteCount counts its plain notes.
type SimilarIncident struct {
ID int64 `json:"id"`
Title string `json:"title"`
TriggeredAt time.Time `json:"triggered_at"`
ResolvedAt time.Time `json:"resolved_at"`
NoteCount int `json:"note_count"`
ResolutionNotes []IncidentEvent `json:"resolution_notes"`
}
+29 -6
View File
@@ -13,16 +13,33 @@ const defaultRefreshInterval = 30 * time.Second
type Config struct { type Config struct {
ServerURL string ServerURL string
APIKey string Username string // optional, prefills the sign-in form
RefreshInterval time.Duration RefreshInterval time.Duration
Theme string Theme string
// LegacyAPIKey is set when the file still has an `api_key`. The TUI signs in
// with a user account now and ignores it; this is only so it can say so.
LegacyAPIKey bool
// Team is the team to start on, by name or id. Empty shows every team the
// key's user belongs to.
Team string
// Auth is how to sign in when the server offers a choice: "sso" starts a
// single sign-on login straight away, "password" (or empty) shows the
// password form. The server decides what is on offer; this only picks the
// default among it.
Auth string
} }
type rawConfig struct { type rawConfig struct {
ServerURL string `yaml:"server_url"` ServerURL string `yaml:"server_url"`
APIKey string `yaml:"api_key"` Username string `yaml:"username,omitempty"`
APIKey string `yaml:"api_key,omitempty"` // no longer used; see Config.LegacyAPIKey
RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds
Theme string `yaml:"theme,omitempty"` Theme string `yaml:"theme,omitempty"`
Team string `yaml:"team,omitempty"`
Auth string `yaml:"auth,omitempty"`
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -35,7 +52,7 @@ func Load() (*Config, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n api_key: <your-api-key>\n theme: gruvbox-dark # optional", path) return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n username: <your-username> # optional, prefills the sign-in form\n theme: gruvbox-dark # optional\n team: Ops # optional, team to start on\n auth: sso # optional, sso or password: how to sign in by default", path)
} }
return nil, fmt.Errorf("cannot read config file: %w", err) return nil, fmt.Errorf("cannot read config file: %w", err)
} }
@@ -48,8 +65,11 @@ func Load() (*Config, error) {
if raw.ServerURL == "" { if raw.ServerURL == "" {
return nil, fmt.Errorf("config: 'server_url' is required") return nil, fmt.Errorf("config: 'server_url' is required")
} }
if raw.APIKey == "" {
return nil, fmt.Errorf("config: 'api_key' is required") switch raw.Auth {
case "", "password", "sso":
default:
return nil, fmt.Errorf("config: 'auth' must be sso or password, not %q", raw.Auth)
} }
interval := defaultRefreshInterval interval := defaultRefreshInterval
@@ -59,8 +79,11 @@ func Load() (*Config, error) {
return &Config{ return &Config{
ServerURL: raw.ServerURL, ServerURL: raw.ServerURL,
APIKey: raw.APIKey, Username: raw.Username,
LegacyAPIKey: raw.APIKey != "",
RefreshInterval: interval, RefreshInterval: interval,
Theme: raw.Theme, Theme: raw.Theme,
Team: raw.Team,
Auth: raw.Auth,
}, nil }, nil
} }
+84
View File
@@ -0,0 +1,84 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeConfig(t *testing.T, body string) {
t.Helper()
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
if err := os.MkdirAll(filepath.Join(dir, "terdut-tui"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "terdut-tui", "config.yaml"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
func TestLoad_TeamIsOptional(t *testing.T) {
writeConfig(t, "server_url: https://terdut.example.com\n")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Team != "" {
t.Errorf("expected no default team, got %q", cfg.Team)
}
writeConfig(t, "server_url: https://terdut.example.com\nteam: Ops\n")
cfg, err = Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Team != "Ops" {
t.Errorf("expected team Ops, got %q", cfg.Team)
}
}
// Signing in replaced the API key, so a config that has only a server URL is
// complete, and one that still carries an api_key is noted rather than refused.
func TestLoad_NoAPIKeyNeeded(t *testing.T) {
writeConfig(t, "server_url: https://terdut.example.com\nusername: niklas\n")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Username != "niklas" || cfg.LegacyAPIKey {
t.Errorf("unexpected config %+v", cfg)
}
writeConfig(t, "server_url: https://terdut.example.com\napi_key: old\n")
cfg, err = Load()
if err != nil {
t.Fatalf("a leftover api_key must not stop the TUI starting: %v", err)
}
if !cfg.LegacyAPIKey {
t.Error("expected the leftover api_key to be noted")
}
}
func TestLoad_AuthIsOptionalAndChecked(t *testing.T) {
for _, tc := range []struct {
yaml, want string
bad bool
}{
{"", "", false},
{"auth: password\n", "password", false},
{"auth: sso\n", "sso", false},
{"auth: oidc\n", "", true},
} {
writeConfig(t, "server_url: https://terdut.example.com\n"+tc.yaml)
cfg, err := Load()
switch {
case tc.bad && err == nil:
t.Errorf("%q: expected an error", tc.yaml)
case !tc.bad && err != nil:
t.Errorf("%q: %v", tc.yaml, err)
case !tc.bad && cfg.Auth != tc.want:
t.Errorf("%q: auth %q, want %q", tc.yaml, cfg.Auth, tc.want)
}
}
}
+98
View File
@@ -0,0 +1,98 @@
// Package session keeps the signed-in session between runs, so the TUI does not
// ask for a password every time it starts.
//
// What is stored is the server's session token, not the password: it is what the
// web UI keeps in a cookie, it expires on the server's schedule (30 days, sliding
// with use) and signing out or a password change ends it. It is still a
// credential, so the file is readable by its owner only.
package session
import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
type file struct {
ServerURL string `json:"server_url"`
Token string `json:"token"`
}
func path() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "terdut-tui", "session.json"), nil
}
// normalise makes two spellings of one server compare equal.
func normalise(serverURL string) string {
return strings.TrimRight(serverURL, "/")
}
// Load returns the saved token for serverURL, or "" when there is none. A session
// saved for a different server is not offered to this one, and an unreadable file
// is the same as no file: the worst outcome is being asked to sign in.
func Load(serverURL string) string {
p, err := path()
if err != nil {
return ""
}
data, err := os.ReadFile(p)
if err != nil {
return ""
}
var f file
if json.Unmarshal(data, &f) != nil || normalise(f.ServerURL) != normalise(serverURL) {
return ""
}
return f.Token
}
// Save stores the token for serverURL, replacing whatever was there.
func Save(serverURL, token string) error {
p, err := path()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return err
}
data, err := json.Marshal(file{ServerURL: normalise(serverURL), Token: token})
if err != nil {
return err
}
// Written beside the target and renamed over it, so a crash cannot leave a
// half-written token, and created 0600 so it is never briefly world-readable.
tmp, err := os.CreateTemp(filepath.Dir(p), ".session-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := os.Chmod(tmp.Name(), 0o600); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), p)
}
// Clear forgets the saved session. Having none to forget is not an error.
func Clear() error {
p, err := path()
if err != nil {
return err
}
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package session
import (
"os"
"path/filepath"
"testing"
)
func isolate(t *testing.T) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
}
func TestSaveThenLoad(t *testing.T) {
isolate(t)
if got := Load("https://terdut.example.com"); got != "" {
t.Fatalf("expected no session yet, got %q", got)
}
if err := Save("https://terdut.example.com", "tok"); err != nil {
t.Fatalf("save: %v", err)
}
if got := Load("https://terdut.example.com"); got != "tok" {
t.Errorf("expected tok, got %q", got)
}
// A trailing slash is the same server.
if got := Load("https://terdut.example.com/"); got != "tok" {
t.Errorf("a trailing slash should not lose the session, got %q", got)
}
}
// A token is only ever valid for the server that issued it; offering it to a
// different one would send a credential to somebody it was never meant for.
func TestLoadIgnoresAnotherServersSession(t *testing.T) {
isolate(t)
if err := Save("https://a.example.com", "tok"); err != nil {
t.Fatal(err)
}
if got := Load("https://b.example.com"); got != "" {
t.Errorf("a session for another server must not be reused, got %q", got)
}
}
func TestSaveIsOwnerOnly(t *testing.T) {
isolate(t)
if err := Save("https://a.example.com", "tok"); err != nil {
t.Fatal(err)
}
p, _ := path()
info, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("the session file holds a credential and must be 0600, got %o", perm)
}
entries, _ := os.ReadDir(filepath.Dir(p))
for _, e := range entries {
if e.Name() != "session.json" {
t.Errorf("a temporary file was left behind: %s", e.Name())
}
}
}
func TestClear(t *testing.T) {
isolate(t)
if err := Clear(); err != nil {
t.Errorf("clearing nothing is not an error, got %v", err)
}
if err := Save("https://a.example.com", "tok"); err != nil {
t.Fatal(err)
}
if err := Clear(); err != nil {
t.Fatalf("clear: %v", err)
}
if got := Load("https://a.example.com"); got != "" {
t.Errorf("expected the session gone, got %q", got)
}
}
func TestLoadToleratesGarbage(t *testing.T) {
isolate(t)
p, _ := path()
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
if got := Load("https://a.example.com"); got != "" {
t.Errorf("an unreadable file means no session, got %q", got)
}
}
+255
View File
@@ -0,0 +1,255 @@
package tui
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/session"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
tea "github.com/charmbracelet/bubbletea"
)
// signedOut is a model with no session, so it starts on the sign-in form.
func signedOut(serverURL string) Model {
m := NewModel(api.NewClient(serverURL), serverURL, time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40
return m
}
func TestStartsOnTheFormWithoutASession(t *testing.T) {
m := signedOut("http://test")
if m.mode != modeLogin {
t.Fatalf("expected the sign-in form, got mode %v", m.mode)
}
// Nothing is connected without a session. The one thing started is asking
// how the server can be signed in to, and a server too old to be asked (404)
// must leave the password form as it was.
srv := httptest.NewServer(http.NotFoundHandler())
t.Cleanup(srv.Close)
cmd := signedOut(srv.URL).Init()
if cmd == nil {
t.Fatal("expected the form to ask the server how it can be signed in to")
}
msg, ok := cmd().(authConfigMsg)
if !ok {
t.Fatalf("expected authConfigMsg, got %#v", cmd())
}
if !msg.cfg.PasswordLogin || msg.cfg.DeviceLogin {
t.Errorf("an old server offers passwords only, got %+v", msg.cfg)
}
}
func TestStartsConnectedWithASavedSession(t *testing.T) {
c := api.NewClient("http://test")
c.SetSession("saved")
m := NewModel(c, "http://test", time.Minute, theme.GruvboxDark)
if m.mode == modeLogin {
t.Fatal("a saved session should be tried before asking for a password")
}
if m.Init() == nil {
t.Error("expected the saved session to be tried on start")
}
}
func TestLoginForm_ChecksBothFieldsBeforeSending(t *testing.T) {
m := signedOut("http://test")
m, cmd := press(t, m, "enter")
if cmd != nil || m.loggingIn || !strings.Contains(m.loginErr, "username") {
t.Errorf("an empty form must not be sent, got err %q", m.loginErr)
}
m = typeInto(t, m, "niklas")
m, cmd = press(t, m, "enter")
if cmd != nil || m.loggingIn || !strings.Contains(m.loginErr, "password") {
t.Errorf("a missing password must not be sent, got err %q", m.loginErr)
}
}
func TestLoginForm_QIsTypedNotQuit(t *testing.T) {
m := signedOut("http://test")
m = typeInto(t, m, "quentin")
if got := m.loginInputs[loginUsername].Value(); got != "quentin" {
t.Errorf("q is a letter in a username, got %q", got)
}
}
// The whole flow against a server: type both fields, submit, and the session is
// kept for the next run.
func TestLogin_SignsInAndSavesTheSession(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
if r.URL.Path != "/api/login" || string(b) != `{"username":"niklas","password":"secret-pass"}` {
t.Errorf("unexpected request %s %s", r.URL.Path, b)
}
http.SetCookie(w, &http.Cookie{Name: api.SessionCookie, Value: "tok-1", Path: "/"})
io.WriteString(w, `{}`)
}))
t.Cleanup(srv.Close)
m := signedOut(srv.URL)
m = typeInto(t, m, "niklas")
m, _ = press(t, m, "tab")
m = typeInto(t, m, "secret-pass")
m, cmd := press(t, m, "enter")
if !m.loggingIn || cmd == nil {
t.Fatal("expected the sign-in to be under way")
}
msg := cmd()
if _, ok := msg.(loginDoneMsg); !ok {
t.Fatalf("expected loginDoneMsg, got %#v", msg)
}
if got := session.Load(srv.URL); got != "tok-1" {
t.Errorf("expected the session saved for next time, got %q", got)
}
next, connect := m.Update(msg)
m = next.(Model)
if m.mode != modeDashboard || m.loggingIn || connect == nil {
t.Errorf("expected to move on and connect, got mode %v", m.mode)
}
if m.loginInputs[loginPassword].Value() != "" {
t.Error("the password must not be kept once it has been used")
}
}
func TestLogin_WrongPasswordStaysOnTheForm(t *testing.T) {
m := signedOut("http://test")
m.loggingIn = true
next, _ := m.Update(loginErrMsg{&api.StatusError{Code: 401, Message: "invalid username or password"}})
m = next.(Model)
if m.mode != modeLogin || m.loggingIn {
t.Fatalf("expected to be back on the form, got mode %v", m.mode)
}
// The server gives the same 401 for an account with no password, so the
// message has to say so or it reads as a typo.
if !strings.Contains(m.loginErr, "no password") {
t.Errorf("expected the no-password hint, got %q", m.loginErr)
}
if m.loginFocus != loginPassword {
t.Error("focus should return to the password to retry")
}
next, _ = m.Update(loginErrMsg{&api.StatusError{Code: 429, Message: "slow down"}})
if got := next.(Model).loginErr; !strings.Contains(got, "too many") {
t.Errorf("expected the rate limit explained, got %q", got)
}
next, _ = m.Update(loginErrMsg{errors.New("dial tcp: refused")})
if got := next.(Model).loginErr; !strings.Contains(got, "refused") {
t.Errorf("other errors should show as they are, got %q", got)
}
}
// A 401 anywhere means the session is gone. Every action would fail the same
// way, so it goes back to the form instead, without keeping the old data.
func TestUnauthorized_ReturnsToTheFormAndDropsTheData(t *testing.T) {
for name, msg := range map[string]tea.Msg{
"refresh": fetchDataErrMsg{&api.StatusError{Code: 401}},
"action": actionErrMsg{&api.StatusError{Code: 401}},
"schedule": scheduleActionErrMsg{&api.StatusError{Code: 401}},
"connect": connectErrMsg{&api.StatusError{Code: 401}},
} {
t.Run(name, func(t *testing.T) {
m := sized()
m.incidents = []api.Incident{{ID: 1, Title: "secret incident"}}
m.teams = twoTeams()
m.isAdmin = true
m.loginInputs[loginUsername].SetValue("niklas")
next, cmd := m.Update(msg)
m = next.(Model)
if m.mode != modeLogin || m.connected {
t.Fatalf("expected the sign-in form, got mode %v connected=%v", m.mode, m.connected)
}
if len(m.incidents) != 0 || len(m.teams) != 0 || m.isAdmin {
t.Error("the previous session's data must not survive into the next sign-in")
}
if cmd == nil {
t.Error("the dead session should be forgotten on disk")
}
if !strings.Contains(m.loginNote, "session") {
t.Errorf("expected the reason, got %q", m.loginNote)
}
if m.loginInputs[loginUsername].Value() != "niklas" || m.loginFocus != loginPassword {
t.Error("the username should be kept so only the password is retyped")
}
if strings.Contains(m.View(), "secret incident") {
t.Error("nothing from the old session may still be on screen")
}
})
}
}
// A 403 is a permission, not a lost session: it must not sign anybody out.
func TestForbidden_DoesNotSignOut(t *testing.T) {
m := sized()
next, _ := m.Update(actionErrMsg{&api.StatusError{Code: 403, Message: "administrator access required"}})
if got := next.(Model); got.mode == modeLogin || !got.connected {
t.Error("a 403 must leave the session alone")
}
}
func TestLogout(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
m := sized()
m.teams = twoTeams()
m.incidents = []api.Incident{{ID: 1}}
m, cmd := press(t, m, "L")
if cmd == nil {
t.Fatal("L should sign out")
}
next, _ := m.Update(logoutDoneMsg{})
m = next.(Model)
if m.mode != modeLogin || m.connected || len(m.incidents) != 0 {
t.Errorf("expected the form with nothing loaded, got mode %v", m.mode)
}
if !strings.Contains(m.loginNote, "signed out") {
t.Errorf("expected it to say so, got %q", m.loginNote)
}
}
// Signing out and in again must not leave two refresh timers running, each
// re-arming itself for ever.
func TestSigningInAgainStartsNoSecondTimer(t *testing.T) {
m := sized()
next, _ := m.Update(connectedMsg{})
m = next.(Model)
if !m.ticking {
t.Fatal("the first connect starts the refresh timer")
}
m = m.requireLogin("x")
if !m.ticking {
t.Fatal("the timer is still running while signed out")
}
// Ticks while signed out must do nothing rather than fetch.
if _, cmd := m.Update(tickMsg(time.Now())); cmd == nil {
t.Error("the timer keeps ticking")
}
if cmd := m.refreshActiveSection(); cmd != nil {
t.Error("no refresh should be attempted while signed out")
}
}
func TestLoginView_HidesThePasswordAndShowsTheNote(t *testing.T) {
m := signedOut("https://terdut.example.com").WithLogin("niklas", "api_key in config.yaml is no longer used")
if m.loginFocus != loginPassword {
t.Error("with the username known the cursor should start on the password")
}
m = typeInto(t, m, "hunter2-hunter2")
view := m.View()
for _, want := range []string{"Sign in to https://terdut.example.com", "niklas", "api_key in config.yaml is no longer used"} {
if !strings.Contains(view, want) {
t.Errorf("expected %q on the form:\n%s", want, view)
}
}
if strings.Contains(view, "hunter2") {
t.Error("the password must be masked")
}
}
+501 -66
View File
@@ -5,9 +5,12 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"slices" "slices"
"strconv"
"strings"
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/session"
"git.ryuvia.com/niklas/terdut-tui/internal/theme" "git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
@@ -50,6 +53,14 @@ const (
modeAPIKeyReveal modeAPIKeyReveal
modeAPIKeyRevokeByID modeAPIKeyRevokeByID
modePasswordSet modePasswordSet
modeLogin
)
// Fields of the sign-in form, in tab order.
const (
loginUsername = iota
loginPassword
loginFieldCount
) )
// Fields of the set-password form, in tab order. // Fields of the set-password form, in tab order.
@@ -108,8 +119,56 @@ func filterLabel(filter string) string {
// ── Messages ─────────────────────────────────────────────────────────────── // ── Messages ───────────────────────────────────────────────────────────────
// dashboard // dashboard
type connectedMsg struct{} type connectedMsg struct {
teams []api.Team
me api.Me
}
type connectErrMsg struct{ err error } type connectErrMsg struct{ err error }
type loginDoneMsg struct{}
type loginErrMsg struct{ err error }
// Single sign-on. A device login is a chain: the server hands out a code
// (deviceStartedMsg), the client waits out the interval (devicePollMsg), asks
// (devicePendingMsg, or loginDoneMsg on approval), and waits again. Every message
// carries the attempt it belongs to, so the late answers of an attempt that was
// cancelled or replaced are dropped rather than acted on.
type authConfigMsg struct{ cfg api.AuthConfig }
type deviceStartedMsg struct {
attempt int
login api.DeviceLogin
}
type devicePollMsg struct{ attempt int }
type devicePendingMsg struct {
attempt int
slower bool // the server asked for fewer polls
err error // a poll that failed in a way worth retrying (network, 5xx)
}
type deviceFailedMsg struct {
attempt int
err error
}
// ssoLogin is a device login in progress; the zero value is none. attempt only
// ever goes up: starting, cancelling and finishing all bump it, which is what
// makes the messages of an earlier attempt stale.
type ssoLogin struct {
attempt int
active bool // started, and not yet cancelled, failed or finished
login *api.DeviceLogin // nil until the server has answered
// interval is the wait between polls: the server's, lengthened when it says
// it is being asked too often.
interval time.Duration
// failures counts polls in a row that failed for a reason other than "not
// yet", so a dead connection ends the wait instead of spinning forever.
failures int
}
const (
defaultDevicePoll = 5 * time.Second
maxPollFailures = 3
)
type logoutDoneMsg struct{}
type incidentsFetchedMsg struct{ incidents []api.Incident } type incidentsFetchedMsg struct{ incidents []api.Incident }
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident } type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
type incidentActionDoneMsg struct { type incidentActionDoneMsg struct {
@@ -129,6 +188,7 @@ type clearStatusMsg struct{}
type incidentDetailFetchedMsg struct { type incidentDetailFetchedMsg struct {
incident api.Incident incident api.Incident
timeline []api.IncidentEvent timeline []api.IncidentEvent
similar []api.SimilarIncident
} }
type alertDetailFetchedMsg struct{ alert api.Alert } type alertDetailFetchedMsg struct{ alert api.Alert }
type detailErrMsg struct{ err error } type detailErrMsg struct{ err error }
@@ -143,7 +203,11 @@ type detailStatsErrMsg struct{ err error }
// schedule // schedule
type scheduleFetchedMsg struct { type scheduleFetchedMsg struct {
entries []api.ScheduleEntry entries []api.ScheduleEntry
current *api.ScheduleEntry current []api.ScheduleEntry
}
type pickerReadyMsg struct {
users []api.User
members map[int64]bool
} }
type scheduleFetchErrMsg struct{ err error } type scheduleFetchErrMsg struct{ err error }
type scheduleActionErrMsg struct{ err error } type scheduleActionErrMsg struct{ err error }
@@ -179,12 +243,39 @@ type Model struct {
client *api.Client client *api.Client
serverURL string serverURL string
refreshInterval time.Duration refreshInterval time.Duration
theme theme.Theme // kept to build a fresh model when signing out
activeSection section activeSection section
mode mode mode mode
width int width int
height int height int
// 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
// 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
// ticking says the refresh timer is already running, so signing in again
// after signing out does not start a second one.
loginInputs [loginFieldCount]textinput.Model
loginFocus int
loggingIn bool
loginErr string
loginNote string
ticking bool
// How the server can be signed in to, nil until it has answered. authPref is
// the config's `auth`, which only chooses among what the server offers.
authInfo *api.AuthConfig
authPref string
sso ssoLogin
// Connection & dashboard // Connection & dashboard
connected bool connected bool
loading bool loading bool
@@ -211,7 +302,9 @@ type Model struct {
// Incident detail // Incident detail
selectedIncident api.Incident selectedIncident api.Incident
timeline []api.IncidentEvent timeline []api.IncidentEvent
similar []api.SimilarIncident
noteCursor int noteCursor int
notePinned bool // the note being typed is the resolution note
detailLoading bool detailLoading bool
detailViewport viewport.Model detailViewport viewport.Model
@@ -242,7 +335,7 @@ type Model struct {
scheduleWindow time.Time scheduleWindow time.Time
scheduleEntries []api.ScheduleEntry scheduleEntries []api.ScheduleEntry
scheduleDays []scheduleDay scheduleDays []scheduleDay
currentOnCall *api.ScheduleEntry currentOnCall []api.ScheduleEntry
scheduleLoading bool scheduleLoading bool
scheduleTable table.Model scheduleTable table.Model
@@ -252,6 +345,9 @@ type Model struct {
userPickerTable table.Model userPickerTable table.Model
pickerTarget pickerTarget pickerTarget pickerTarget
pickerAssignWeek bool pickerAssignWeek bool
// pickerMembers is who belongs to the schedule's team, so the schedule picker
// offers only people the server will accept. Nil until fetched.
pickerMembers map[int64]bool
// User management section // User management section
userManageTable table.Model userManageTable table.Model
@@ -344,6 +440,18 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
pwIn[i].CharLimit = 72 // bcrypt's limit; the server refuses longer pwIn[i].CharLimit = 72 // bcrypt's limit; the server refuses longer
} }
var loginIn [loginFieldCount]textinput.Model
for i, placeholder := range [loginFieldCount]string{"username", "password"} {
loginIn[i] = textinput.New()
loginIn[i].Placeholder = placeholder
loginIn[i].CharLimit = 72 // bcrypt's limit, and the server refuses longer passwords
}
loginIn[loginPassword].EchoMode = textinput.EchoPassword
loginIn[loginPassword].EchoCharacter = '•'
for i := range loginIn {
loginIn[i] = st.Input(loginIn[i])
}
for _, in := range []*textinput.Model{ for _, in := range []*textinput.Model{
&noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn, &noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn,
} { } {
@@ -364,12 +472,20 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
} }
window := today.AddDate(0, 0, -(weekday - 1)) window := today.AddDate(0, 0, -(weekday - 1))
startMode := modeDashboard
if client != nil && !client.HasSession() {
startMode = modeLogin
loginIn[loginUsername].Focus()
}
return Model{ return Model{
client: client, client: client,
serverURL: serverURL, serverURL: serverURL,
refreshInterval: refreshInterval, refreshInterval: refreshInterval,
theme: th,
activeSection: sectionIncidents, activeSection: sectionIncidents,
mode: modeDashboard, mode: startMode,
loginInputs: loginIn,
loading: true, loading: true,
incidentFilter: "", incidentFilter: "",
alertFilter: "firing", alertFilter: "firing",
@@ -395,7 +511,43 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
} }
} }
// WithDefaultTeam names the team to start on, by name or id. It is resolved
// against the caller's teams once connected; an unknown one is reported and the
// TUI starts on all teams.
func (m Model) WithDefaultTeam(team string) Model {
m.defaultTeam = strings.TrimSpace(team)
return m
}
// WithAuth sets the default way to sign in, from the config's `auth`: "sso"
// starts a single sign-on login by itself when the server offers one.
func (m Model) WithAuth(pref string) Model {
m.authPref = pref
return m
}
// WithLogin prefills the sign-in form's username and sets a note shown above it.
func (m Model) WithLogin(username, note string) Model {
m.loginInputs[loginUsername].SetValue(username)
m.loginNote = note
if username != "" && m.mode == modeLogin {
// The name is known, so the only thing left to type is the password.
m.loginInputs[loginUsername].Blur()
m.loginFocus = loginPassword
m.loginInputs[loginPassword].Focus()
}
return m
}
// Init tries the saved session, if there is one; otherwise the sign-in form is
// already showing and there is nothing to do until it is submitted.
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
if m.mode == modeLogin {
// Ask how the server can be signed in to, so the form offers the right
// thing. Nothing depends on the answer arriving: the password form is
// already usable.
return authConfigCmd(m.client)
}
return connectCmd(m.client) return connectCmd(m.client)
} }
@@ -444,21 +596,34 @@ func setRows(t *table.Model, rows []table.Row) {
} }
} }
// setTable replaces a table's columns and rows together, for tables whose column
// count can change (the Team column comes and goes). bubbles re-renders the
// existing rows as soon as SetColumns is called, and a row with a different
// number of cells than the new columns indexes past the end and panics, so the
// old rows have to go first. The cursor is put back afterwards, since a refresh
// must not send it to the top.
func setTable(t *table.Model, cols []table.Column, rows []table.Row) {
cursor := t.Cursor()
t.SetRows(nil)
t.SetColumns(cols)
setRows(t, rows)
if cursor > 0 && cursor < len(rows) {
t.SetCursor(cursor)
}
}
func (m *Model) rebuildIncidentTable() { func (m *Model) rebuildIncidentTable() {
m.incidentTable.SetColumns(incidentColumns(m.width)) setTable(&m.incidentTable, incidentColumns(m.width, m.showTeamColumn()), incidentRows(m.incidents, m.showTeamColumn()))
setRows(&m.incidentTable, incidentRows(m.incidents))
m.incidentTable.SetHeight(tableHeight(m.height, 8)) m.incidentTable.SetHeight(tableHeight(m.height, 8))
} }
func (m *Model) rebuildTable() { func (m *Model) rebuildTable() {
m.alertTable.SetColumns(alertColumns(m.width)) setTable(&m.alertTable, alertColumns(m.width, m.showTeamColumn()), alertRows(m.alerts, m.showTeamColumn()))
setRows(&m.alertTable, alertRows(m.alerts))
m.alertTable.SetHeight(tableHeight(m.height, 8)) m.alertTable.SetHeight(tableHeight(m.height, 8))
} }
func (m *Model) rebuildArchivedTable() { func (m *Model) rebuildArchivedTable() {
m.archivedTable.SetColumns(incidentColumns(m.width)) setTable(&m.archivedTable, incidentColumns(m.width, m.showTeamColumn()), incidentRows(m.archivedIncidents, m.showTeamColumn()))
setRows(&m.archivedTable, incidentRows(m.archivedIncidents))
m.archivedTable.SetHeight(tableHeight(m.height, 8)) m.archivedTable.SetHeight(tableHeight(m.height, 8))
} }
@@ -470,8 +635,9 @@ func (m *Model) rebuildScheduleTable() {
func (m *Model) rebuildUserPickerTable() { func (m *Model) rebuildUserPickerTable() {
m.userPickerTable.SetColumns(userPickerColumns(m.width)) m.userPickerTable.SetColumns(userPickerColumns(m.width))
rows := make([]table.Row, len(m.users)) pickable := m.pickerUsers()
for i, u := range m.users { rows := make([]table.Row, len(pickable))
for i, u := range pickable {
rows[i] = table.Row{u.Username, u.Email} rows[i] = table.Row{u.Username, u.Email}
} }
setRows(&m.userPickerTable, rows) setRows(&m.userPickerTable, rows)
@@ -486,7 +652,7 @@ func (m *Model) rebuildUserManageTable() {
if topic == "" { if topic == "" {
topic = "—" topic = "—"
} }
rows[i] = table.Row{u.Username, u.Email, topic, u.CreatedAt.UTC().Format("2006-01-02")} rows[i] = table.Row{u.Username, u.Email, topic, 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))
@@ -509,7 +675,7 @@ func (m *Model) refreshDetailContent() {
return return
} }
m.detailViewport.SetContent( m.detailViewport.SetContent(
buildIncidentDetailContent(m.styles, m.selectedIncident, m.timeline, m.noteCursor, m.width)) buildIncidentDetailContent(m.styles, m.selectedIncident, m.timeline, m.similar, m.noteCursor, m.width))
} }
func (m *Model) refreshStatsContent() { func (m *Model) refreshStatsContent() {
@@ -536,12 +702,112 @@ func (m Model) detailViewportHeight() int {
return h return h
} }
// showTeamColumn is whether list rows need saying which team they belong to:
// only when they can come from more than one.
func (m Model) showTeamColumn() bool {
return m.activeTeamID == 0 && len(m.teams) > 1
}
// activeTeam returns the team the lists are narrowed to.
func (m Model) activeTeam() (api.Team, bool) {
return m.teamByID(m.activeTeamID)
}
func (m Model) teamByID(id int64) (api.Team, bool) {
for _, t := range m.teams {
if t.ID == id {
return t, true
}
}
return api.Team{}, false
}
// scheduleTeam is the team whose schedule the Schedule section shows. That is
// the active team; with all teams showing it is the first one the caller owns,
// else their first, because a rota belongs to one team and there is no
// meaningful union to display.
func (m Model) scheduleTeam() (api.Team, bool) {
if t, ok := m.activeTeam(); ok {
return t, true
}
for _, t := range m.teams {
if t.Role == api.RoleOwner {
return t, true
}
}
if len(m.teams) > 0 {
return m.teams[0], true
}
return api.Team{}, false
}
// canEditSchedule mirrors the server: writes need a team owner or an
// administrator. Saying so up front beats a 403 after picking a user.
func (m Model) canEditSchedule(t api.Team) bool {
return m.isAdmin || t.Role == api.RoleOwner
}
// canManageUser mirrors the server's self-or-admin rule for a user's password,
// ntfy topic and API keys.
func (m Model) canManageUser(u api.User) bool {
return m.isAdmin || u.ID == m.meID
}
// resolveTeam finds a team by id or, failing that, by name.
func resolveTeam(teams []api.Team, want string) (api.Team, bool) {
if id, err := strconv.ParseInt(want, 10, 64); err == nil {
for _, t := range teams {
if t.ID == id {
return t, true
}
}
}
for _, t := range teams {
if strings.EqualFold(t.Name, want) {
return t, true
}
}
return api.Team{}, false
}
// pickerUsers is who the user picker offers. Disabled accounts are never worth
// assigning to. For the schedule it is also limited to the team's members: the
// server answers 404 for anyone else, and shows nothing until they are known.
func (m Model) pickerUsers() []api.User {
out := make([]api.User, 0, len(m.users))
for _, u := range m.users {
if u.IsDisabled() {
continue
}
if m.pickerTarget == pickerSchedule && !m.pickerMembers[u.ID] {
continue
}
out = append(out, u)
}
return out
}
// userFlags is the Users table's marker column.
func userFlags(u api.User) string {
var flags []string
if u.IsAdmin {
flags = append(flags, "admin")
}
if u.IsDisabled() {
flags = append(flags, "disabled")
}
if len(flags) == 0 {
return "—"
}
return strings.Join(flags, ",")
}
// noteEvents filters a timeline down to the deletable entries, which is what // noteEvents filters a timeline down to the deletable entries, which is what
// the [ and ] cursor walks. // the [ and ] cursor walks.
func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent { func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
notes := make([]api.IncidentEvent, 0, len(timeline)) notes := make([]api.IncidentEvent, 0, len(timeline))
for _, e := range timeline { for _, e := range timeline {
if e.Type == api.EventNote { if e.Type == api.EventNote || e.Type == api.EventResolutionNote {
notes = append(notes, e) notes = append(notes, e)
} }
} }
@@ -550,44 +816,67 @@ func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
// ── Column definitions ───────────────────────────────────────────────────── // ── Column definitions ─────────────────────────────────────────────────────
func incidentColumns(width int) []table.Column { // teamW is the width of the Team column shown when rows can span teams.
const teamW = 14
func incidentColumns(width int, showTeam bool) []table.Column {
const sevW, statusW, timeW = 9, 15, 12 const sevW, statusW, timeW = 9, 15, 12
titleW := width/2 - 10 titleW := width/2 - 10
if showTeam {
titleW -= teamW + 2
}
if titleW < 20 { if titleW < 20 {
titleW = 20 titleW = 20
} }
// 10 = bubbles' Padding(0, 1) on each of the five cells. // 10 = bubbles' Padding(0, 1) on each of the five cells.
assigneeW := width - sevW - titleW - statusW - timeW - 10 assigneeW := width - sevW - titleW - statusW - timeW - 10
if showTeam {
assigneeW -= teamW + 2
}
if assigneeW < 8 { if assigneeW < 8 {
assigneeW = 8 assigneeW = 8
} }
return []table.Column{ cols := []table.Column{
{Title: "Sev", Width: sevW}, {Title: "Sev", Width: sevW},
{Title: "Incident", Width: titleW}, {Title: "Incident", Width: titleW},
{Title: "Status", Width: statusW},
{Title: "Assignee", Width: assigneeW},
{Title: "Triggered", Width: timeW},
} }
if showTeam {
cols = append(cols, table.Column{Title: "Team", Width: teamW})
}
return append(cols,
table.Column{Title: "Status", Width: statusW},
table.Column{Title: "Assignee", Width: assigneeW},
table.Column{Title: "Triggered", Width: timeW},
)
} }
func alertColumns(width int) []table.Column { func alertColumns(width int, showTeam bool) []table.Column {
const statusW, timeW = 10, 12 const statusW, timeW = 10, 12
nameW := width/2 - 14 nameW := width/2 - 14
if showTeam {
nameW -= teamW + 2
}
if nameW < 20 { if nameW < 20 {
nameW = 20 nameW = 20
} }
// 10 = bubbles' Padding(0, 1) on each of the five cells. // 10 = bubbles' Padding(0, 1) on each of the five cells.
incW := width - nameW - statusW - 2*timeW - 10 incW := width - nameW - statusW - 2*timeW - 10
if showTeam {
incW -= teamW + 2
}
if incW < 8 { if incW < 8 {
incW = 8 incW = 8
} }
return []table.Column{ cols := []table.Column{{Title: "Name", Width: nameW}}
{Title: "Name", Width: nameW}, if showTeam {
{Title: "Status", Width: statusW}, cols = append(cols, table.Column{Title: "Team", Width: teamW})
{Title: "Started", Width: timeW},
{Title: "Last Seen", Width: timeW},
{Title: "Incident", Width: incW},
} }
return append(cols,
table.Column{Title: "Status", Width: statusW},
table.Column{Title: "Started", Width: timeW},
table.Column{Title: "Last Seen", Width: timeW},
table.Column{Title: "Incident", Width: incW},
)
} }
func scheduleColumns(width int) []table.Column { func scheduleColumns(width int) []table.Column {
@@ -618,8 +907,9 @@ func userManageColumns(width int) []table.Column {
createdW := 12 createdW := 12
usernameW := 25 usernameW := 25
topicW := 22 topicW := 22
// 8 = bubbles' Padding(0, 1) on each of the four cells. flagsW := 14
emailW := width - usernameW - topicW - createdW - 8 // 10 = bubbles' Padding(0, 1) on each of the five cells.
emailW := width - usernameW - topicW - flagsW - createdW - 10
if emailW < 15 { if emailW < 15 {
emailW = 15 emailW = 15
} }
@@ -627,13 +917,14 @@ func userManageColumns(width int) []table.Column {
{Title: "Username", Width: usernameW}, {Title: "Username", Width: usernameW},
{Title: "Email", Width: emailW}, {Title: "Email", Width: emailW},
{Title: "Ntfy Topic", Width: topicW}, {Title: "Ntfy Topic", Width: topicW},
{Title: "Flags", Width: flagsW},
{Title: "Created", Width: createdW}, {Title: "Created", Width: createdW},
} }
} }
// ── Row builders ─────────────────────────────────────────────────────────── // ── Row builders ───────────────────────────────────────────────────────────
func incidentRows(incidents []api.Incident) []table.Row { func incidentRows(incidents []api.Incident, showTeam bool) []table.Row {
now := time.Now() now := time.Now()
rows := make([]table.Row, len(incidents)) rows := make([]table.Row, len(incidents))
for i, inc := range incidents { for i, inc := range incidents {
@@ -651,12 +942,16 @@ func incidentRows(incidents []api.Incident) []table.Row {
if assignee == "" { if assignee == "" {
assignee = "—" assignee = "—"
} }
if showTeam {
rows[i] = table.Row{severity, inc.Title, teamLabel(inc.TeamName), status, assignee, humanAgo(now, inc.TriggeredAt)}
continue
}
rows[i] = table.Row{severity, inc.Title, status, assignee, humanAgo(now, inc.TriggeredAt)} rows[i] = table.Row{severity, inc.Title, status, assignee, humanAgo(now, inc.TriggeredAt)}
} }
return rows return rows
} }
func alertRows(alerts []api.Alert) []table.Row { func alertRows(alerts []api.Alert, showTeam bool) []table.Row {
now := time.Now() now := time.Now()
rows := make([]table.Row, len(alerts)) rows := make([]table.Row, len(alerts))
for i, a := range alerts { for i, a := range alerts {
@@ -664,11 +959,23 @@ func alertRows(alerts []api.Alert) []table.Row {
if a.IncidentID != nil { if a.IncidentID != nil {
incident = fmt.Sprintf("#%d", *a.IncidentID) incident = fmt.Sprintf("#%d", *a.IncidentID)
} }
if showTeam {
rows[i] = table.Row{a.Name, teamLabel(a.TeamName), a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), incident}
continue
}
rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), incident} rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), incident}
} }
return rows return rows
} }
// teamLabel is a team name for a table cell, with a dash when the server sent none.
func teamLabel(name string) string {
if name == "" {
return "—"
}
return name
}
func scheduleRows(days []scheduleDay) []table.Row { func scheduleRows(days []scheduleDay) []table.Row {
today := time.Now().UTC().Format("2006-01-02") today := time.Now().UTC().Format("2006-01-02")
rows := make([]table.Row, len(days)) rows := make([]table.Row, len(days))
@@ -763,19 +1070,122 @@ func humanSeconds(secs *float64) string {
// ── Commands ─────────────────────────────────────────────────────────────── // ── Commands ───────────────────────────────────────────────────────────────
// errServerTooOld is what connecting to a server without teams looks like: the
// server has no version endpoint, so its missing /api/teams is the tell.
var errServerTooOld = errors.New("this server predates teams -- terdut-tui needs terdut-server v0.20 or later")
// connectCmd checks the server is up, then loads the caller's teams and identity
// with their key. /healthz is unauthenticated, so this is also the first thing
// to notice a wrong key.
func connectCmd(client *api.Client) tea.Cmd { func connectCmd(client *api.Client) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := client.HealthCheck(); err != nil { if err := client.HealthCheck(); err != nil {
return connectErrMsg{err} return connectErrMsg{err}
} }
return connectedMsg{} teams, err := client.ListTeams()
var se *api.StatusError
if errors.As(err, &se) && se.Code == http.StatusNotFound {
return connectErrMsg{errServerTooOld}
}
if err != nil {
return connectErrMsg{err}
}
me, err := client.Me()
if err != nil {
return connectErrMsg{err}
}
return connectedMsg{teams: teams, me: *me}
} }
} }
func fetchIncidentsCmd(client *api.Client, filter string) tea.Cmd { // loginCmd signs in and saves the session, so the next run can resume it. Failing
// to save is not failing to sign in: the session works for this run either way.
func loginCmd(client *api.Client, serverURL, username, password string) tea.Cmd {
return func() tea.Msg {
token, err := client.Login(username, password)
if err != nil {
return loginErrMsg{err}
}
_ = session.Save(serverURL, token)
return loginDoneMsg{}
}
}
// authConfigCmd asks how the server can be signed in to. A server too old to be
// asked, or one that cannot be reached, is treated as offering passwords only: the
// form that always existed is the safe fallback, and it reports a real connection
// problem itself when it is submitted.
func authConfigCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
cfg, err := client.AuthConfig()
if err != nil {
cfg = api.AuthConfig{PasswordLogin: true}
}
return authConfigMsg{cfg}
}
}
// startDeviceCmd asks the server to begin a device login.
func startDeviceCmd(client *api.Client, attempt int) tea.Cmd {
return func() tea.Msg {
login, err := client.StartDeviceLogin()
if err != nil {
return deviceFailedMsg{attempt, err}
}
return deviceStartedMsg{attempt, *login}
}
}
// devicePollAfter waits out the interval before the next poll.
func devicePollAfter(attempt int, interval time.Duration) tea.Cmd {
return tea.Tick(interval, func(time.Time) tea.Msg { return devicePollMsg{attempt} })
}
// pollDeviceCmd asks whether the login has been approved. Approval saves the
// session the way a password sign-in does, and ends in the same loginDoneMsg.
func pollDeviceCmd(client *api.Client, serverURL string, attempt int, deviceCode string) tea.Cmd {
return func() tea.Msg {
token, err := client.PollDeviceLogin(deviceCode)
switch {
case err == nil:
_ = session.Save(serverURL, token)
return loginDoneMsg{}
case errors.Is(err, api.ErrDevicePending):
return devicePendingMsg{attempt: attempt}
case errors.Is(err, api.ErrDeviceSlowDown):
return devicePendingMsg{attempt: attempt, slower: true}
case errors.Is(err, api.ErrDeviceExpired), errors.Is(err, api.ErrDeviceDenied):
return deviceFailedMsg{attempt, err}
}
// Anything else is the network or the server having a moment, which a
// person waiting on a browser should not have to start over for.
return devicePendingMsg{attempt: attempt, err: err}
}
}
// logoutCmd ends the session on the server and deletes the saved one. The saved
// copy goes even when the server cannot be reached, because the person asked to
// be signed out and a token left on disk would say otherwise.
func logoutCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
_ = client.Logout()
_ = session.Clear()
return logoutDoneMsg{}
}
}
// forgetSessionCmd drops a saved session the server no longer honours.
func forgetSessionCmd() tea.Cmd {
return func() tea.Msg {
_ = session.Clear()
return nil
}
}
func fetchIncidentsCmd(client *api.Client, teamID int64, filter string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
status, snoozed := incidentQuery(filter) status, snoozed := incidentQuery(filter)
incidents, err := client.ListIncidents(status, false, snoozed, 500) incidents, err := client.ListIncidents(teamID, status, false, snoozed, 500)
if err != nil { if err != nil {
return fetchDataErrMsg{err} return fetchDataErrMsg{err}
} }
@@ -783,11 +1193,11 @@ func fetchIncidentsCmd(client *api.Client, filter string) tea.Cmd {
} }
} }
func fetchArchivedIncidentsCmd(client *api.Client) tea.Cmd { func fetchArchivedIncidentsCmd(client *api.Client, teamID int64) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
// Archived incidents are all resolved, so the status filter has to be // Archived incidents are all resolved, so the status filter has to be
// widened past the server's open-only default or nothing comes back. // widened past the server's open-only default or nothing comes back.
incidents, err := client.ListIncidents(api.StatusResolved, true, false, 500) incidents, err := client.ListIncidents(teamID, api.StatusResolved, true, false, 500)
if err != nil { if err != nil {
return fetchDataErrMsg{err} return fetchDataErrMsg{err}
} }
@@ -795,13 +1205,13 @@ func fetchArchivedIncidentsCmd(client *api.Client) tea.Cmd {
} }
} }
func fetchAlertsCmd(client *api.Client, filter string) tea.Cmd { func fetchAlertsCmd(client *api.Client, teamID int64, filter string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
status, archived := filter, false status, archived := filter, false
if filter == "archived" { if filter == "archived" {
status, archived = "", true status, archived = "", true
} }
alerts, err := client.ListAlerts(status, archived, 500) alerts, err := client.ListAlerts(teamID, status, archived, 500)
if err != nil { if err != nil {
return fetchDataErrMsg{err} return fetchDataErrMsg{err}
} }
@@ -834,7 +1244,10 @@ func incidentDetail(client *api.Client, id int64) tea.Msg {
if err != nil { if err != nil {
return detailErrMsg{err} return detailErrMsg{err}
} }
return incidentDetailFetchedMsg{incident: *incident, timeline: timeline} // Best effort: an older server has no such endpoint, and the incident is
// still worth showing without it.
similar, _ := client.GetSimilarIncidents(id)
return incidentDetailFetchedMsg{incident: *incident, timeline: timeline, similar: similar}
} }
func fetchIncidentDetailCmd(client *api.Client, id int64) tea.Cmd { func fetchIncidentDetailCmd(client *api.Client, id int64) tea.Cmd {
@@ -888,9 +1301,9 @@ func unsnoozeIncidentCmd(client *api.Client, id int64) tea.Cmd {
return incidentActionCmd(client, id, func() error { return client.UnsnoozeIncident(id) }) return incidentActionCmd(client, id, func() error { return client.UnsnoozeIncident(id) })
} }
func addNoteCmd(client *api.Client, id int64, content string) tea.Cmd { func addNoteCmd(client *api.Client, id int64, content string, pinned bool) tea.Cmd {
return incidentActionCmd(client, id, func() error { return incidentActionCmd(client, id, func() error {
_, err := client.AddNote(id, content) _, err := client.AddNote(id, content, pinned)
return err return err
}) })
} }
@@ -901,13 +1314,13 @@ func deleteNoteCmd(client *api.Client, id, eventID int64) tea.Cmd {
// archiveIncidentCmd archives from the list view, so it reloads the list rather // archiveIncidentCmd archives from the list view, so it reloads the list rather
// than a detail pane. // than a detail pane.
func archiveIncidentCmd(client *api.Client, id int64, filter string) tea.Cmd { func archiveIncidentCmd(client *api.Client, id, teamID int64, filter string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if _, err := client.ArchiveIncident(id); err != nil { if _, err := client.ArchiveIncident(id); err != nil {
return actionErrMsg{err} return actionErrMsg{err}
} }
status, snoozed := incidentQuery(filter) status, snoozed := incidentQuery(filter)
incidents, err := client.ListIncidents(status, false, snoozed, 500) incidents, err := client.ListIncidents(teamID, status, false, snoozed, 500)
if err != nil { if err != nil {
return actionErrMsg{err} return actionErrMsg{err}
} }
@@ -915,12 +1328,12 @@ func archiveIncidentCmd(client *api.Client, id int64, filter string) tea.Cmd {
} }
} }
func unarchiveIncidentCmd(client *api.Client, id int64) tea.Cmd { func unarchiveIncidentCmd(client *api.Client, id, teamID int64) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := client.UnarchiveIncident(id); err != nil { if err := client.UnarchiveIncident(id); err != nil {
return actionErrMsg{err} return actionErrMsg{err}
} }
incidents, err := client.ListIncidents(api.StatusResolved, true, false, 500) incidents, err := client.ListIncidents(teamID, api.StatusResolved, true, false, 500)
if err != nil { if err != nil {
return actionErrMsg{err} return actionErrMsg{err}
} }
@@ -956,51 +1369,73 @@ func fetchDetailStatsCmd(client *api.Client) tea.Cmd {
} }
} }
func fetchScheduleCmd(client *api.Client, from, to time.Time) tea.Cmd { // loadSchedule reads one team's window and everyone's on-call today, the way
// every schedule command ends so the view reflects what the server now holds.
func loadSchedule(client *api.Client, teamID int64, from, to time.Time) (scheduleFetchedMsg, error) {
entries, err := client.GetSchedule(teamID, from.Format("2006-01-02"), to.Format("2006-01-02"))
if err != nil {
return scheduleFetchedMsg{}, err
}
current, err := client.GetCurrentOnCall()
if err != nil {
return scheduleFetchedMsg{}, err
}
return scheduleFetchedMsg{entries: entries, current: current}, nil
}
func fetchScheduleCmd(client *api.Client, teamID int64, from, to time.Time) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02")) msg, err := loadSchedule(client, teamID, from, to)
if err != nil { if err != nil {
return scheduleFetchErrMsg{err} return scheduleFetchErrMsg{err}
} }
current, err := client.GetCurrentOnCall() return msg
if err != nil {
return scheduleFetchErrMsg{err}
}
return scheduleFetchedMsg{entries: entries, current: current}
} }
} }
func assignScheduleCmd(client *api.Client, userID int64, dates []string, replace bool, from, to time.Time) tea.Cmd { func assignScheduleCmd(client *api.Client, teamID, userID int64, dates []string, replace bool, from, to time.Time) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if _, err := client.AssignSchedule(userID, dates, replace); err != nil { if _, err := client.AssignSchedule(teamID, userID, dates, replace); err != nil {
return scheduleActionErrMsg{err} return scheduleActionErrMsg{err}
} }
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02")) msg, err := loadSchedule(client, teamID, from, to)
if err != nil { if err != nil {
return scheduleActionErrMsg{err} return scheduleActionErrMsg{err}
} }
current, err := client.GetCurrentOnCall() return msg
if err != nil {
return scheduleActionErrMsg{err}
}
return scheduleFetchedMsg{entries: entries, current: current}
} }
} }
func deleteScheduleEntryCmd(client *api.Client, entryID int64, from, to time.Time) tea.Cmd { func deleteScheduleEntryCmd(client *api.Client, teamID, entryID int64, from, to time.Time) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := client.DeleteScheduleEntry(entryID); err != nil { if err := client.DeleteScheduleEntry(teamID, entryID); err != nil {
return scheduleActionErrMsg{err} return scheduleActionErrMsg{err}
} }
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02")) msg, err := loadSchedule(client, teamID, from, to)
if err != nil { if err != nil {
return scheduleActionErrMsg{err} return scheduleActionErrMsg{err}
} }
current, err := client.GetCurrentOnCall() return msg
}
}
// fetchPickerCmd loads what the schedule's user picker offers: everyone, and who
// belongs to the team, since only members can be put on its rota.
func fetchPickerCmd(client *api.Client, teamID int64) tea.Cmd {
return func() tea.Msg {
users, err := client.ListUsers()
if err != nil { if err != nil {
return scheduleActionErrMsg{err} return userActionErrMsg{err}
} }
return scheduleFetchedMsg{entries: entries, current: current} members, err := client.ListTeamMembers(teamID)
if err != nil {
return userActionErrMsg{err}
}
ids := make(map[int64]bool, len(members))
for _, mem := range members {
ids[mem.UserID] = true
}
return pickerReadyMsg{users: users, members: ids}
} }
} }
+43 -19
View File
@@ -143,7 +143,7 @@ func TestIncidentRows(t *testing.T) {
{Title: "Unowned", Status: api.StatusTriggered, TriggeredAt: time.Now()}, {Title: "Unowned", Status: api.StatusTriggered, TriggeredAt: time.Now()},
{Title: "Quiet", Status: api.StatusTriggered, Severity: "info", {Title: "Quiet", Status: api.StatusTriggered, Severity: "info",
AssignedTo: "alice", SnoozedUntil: &future, TriggeredAt: time.Now()}, AssignedTo: "alice", SnoozedUntil: &future, TriggeredAt: time.Now()},
}) }, false)
if len(rows) != 3 { if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows)) t.Fatalf("expected 3 rows, got %d", len(rows))
} }
@@ -159,12 +159,35 @@ func TestIncidentRows(t *testing.T) {
} }
} }
// Rows only carry a team cell when the columns have a Team header for it, or
// every cell after it would sit under the wrong heading.
func TestRows_TeamCellMatchesTeamColumn(t *testing.T) {
now := time.Now()
incRows := incidentRows([]api.Incident{{Title: "DiskFull", TeamName: "Ops", Status: api.StatusTriggered, TriggeredAt: now}}, true)
if got, want := len(incRows[0]), len(incidentColumns(120, true)); got != want {
t.Errorf("incident row has %d cells for %d columns", got, want)
}
if incRows[0][2] != "Ops" {
t.Errorf("expected the team after the title, got %v", incRows[0])
}
alRows := alertRows([]api.Alert{{Name: "DiskFull", StartsAt: now, ReceivedAt: now}}, true)
if got, want := len(alRows[0]), len(alertColumns(120, true)); got != want {
t.Errorf("alert row has %d cells for %d columns", got, want)
}
if alRows[0][1] != "—" {
t.Errorf("a missing team name should show an em dash, got %v", alRows[0])
}
if got, want := len(incidentRows([]api.Incident{{}}, false)[0]), len(incidentColumns(120, false)); got != want {
t.Errorf("incident row has %d cells for %d columns without teams", got, want)
}
}
func TestAlertRows_ShowIncidentLink(t *testing.T) { func TestAlertRows_ShowIncidentLink(t *testing.T) {
id := int64(7) id := int64(7)
rows := alertRows([]api.Alert{ rows := alertRows([]api.Alert{
{Name: "DiskFull", Status: "firing", IncidentID: &id}, {Name: "DiskFull", Status: "firing", IncidentID: &id},
{Name: "Orphan", Status: "resolved"}, {Name: "Orphan", Status: "resolved"},
}) }, false)
if rows[0][4] != "#7" { if rows[0][4] != "#7" {
t.Errorf("expected #7, got %q", rows[0][4]) t.Errorf("expected #7, got %q", rows[0][4])
} }
@@ -204,31 +227,31 @@ func TestUserManageRows_ShowMissingTopic(t *testing.T) {
// the window width. // the window width.
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} {
for name, cols := range map[string][]int{ // Five cells each, or six once a Team column is added. userManageColumns
"incident": widths(incidentColumns(width)), // is five cells as well: username, email, topic, flags, created.
"alert": widths(alertColumns(width)), for name, tc := range map[string]struct {
cols []table.Column
cells int
}{
"incident": {incidentColumns(width, false), 5},
"incident with team": {incidentColumns(width, true), 6},
"alert": {alertColumns(width, false), 5},
"alert with team": {alertColumns(width, true), 6},
"user": {userManageColumns(width), 5},
} { } {
sum := 0 sum := 0
for _, w := range cols { for _, w := range widths(tc.cols) {
sum += w sum += w
} }
const padding = 10 // bubbles applies Padding(0, 1) to each of five cells padding := 2 * tc.cells // bubbles applies Padding(0, 1) to each cell
if len(tc.cols) != tc.cells {
t.Errorf("%s has %d columns, expected %d", name, len(tc.cols), tc.cells)
}
if sum+padding != width { if sum+padding != width {
t.Errorf("%s columns at width %d sum to %d+%d = %d", t.Errorf("%s columns at width %d sum to %d+%d = %d",
name, width, sum, padding, sum+padding) name, width, sum, padding, sum+padding)
} }
} }
// The users table is four cells, so its padding budget differs.
sum := 0
for _, w := range widths(userManageColumns(width)) {
sum += w
}
const userPadding = 8
if sum+userPadding != width {
t.Errorf("user columns at width %d sum to %d+%d = %d",
width, sum, userPadding, sum+userPadding)
}
} }
} }
@@ -236,7 +259,7 @@ func TestColumnWidthsFitTheTerminal(t *testing.T) {
// what must not happen is a negative or zero column. // what must not happen is a negative or zero column.
func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) { func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) {
for _, width := range []int{20, 40, 60} { for _, width := range []int{20, 40, 60} {
cols := append(widths(incidentColumns(width)), widths(alertColumns(width))...) cols := append(widths(incidentColumns(width, true)), widths(alertColumns(width, true))...)
cols = append(cols, widths(userManageColumns(width))...) cols = append(cols, widths(userManageColumns(width))...)
for _, w := range cols { for _, w := range cols {
if w < 1 { if w < 1 {
@@ -287,6 +310,7 @@ func scheduledWeek(entries []api.ScheduleEntry) Model {
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.connected = true m.connected = true
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleOwner}}
m.activeSection = sectionSchedule m.activeSection = sectionSchedule
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC) m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
m.scheduleEntries = entries m.scheduleEntries = entries
+378
View File
@@ -0,0 +1,378 @@
package tui
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/session"
tea "github.com/charmbracelet/bubbletea"
)
// fakeServer is the device-login half of terdut-server: it starts a login,
// answers polls with whatever poll says, and records what it was asked.
type fakeServer struct {
*httptest.Server
mu sync.Mutex
polls int
// poll is called for each poll and writes the response.
poll func(w http.ResponseWriter, n int)
}
func newFakeServer(t *testing.T) *fakeServer {
t.Helper()
f := &fakeServer{}
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/oidc/device":
io.WriteString(w, `{"device_code":"dev-1","user_code":"BCDF-GHJK",
"verification_url":"https://terdut.example.com/device?code=BCDF-GHJK","interval":5,"expires_in":600}`)
case "/api/oidc/device/token":
f.mu.Lock()
f.polls++
n := f.polls
f.mu.Unlock()
var body struct {
DeviceCode string `json:"device_code"`
}
json.NewDecoder(r.Body).Decode(&body)
if body.DeviceCode != "dev-1" {
t.Errorf("polled with %q", body.DeviceCode)
}
f.poll(w, n)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(f.Close)
return f
}
func pending(w http.ResponseWriter, _ int) {
w.WriteHeader(http.StatusAccepted)
io.WriteString(w, `{"status":"pending"}`)
}
// offering is a signed-out model that has been told what the server offers.
func offering(t *testing.T, url string, cfg api.AuthConfig, pref string) (Model, tea.Cmd) {
t.Helper()
m := signedOut(url).WithAuth(pref)
next, cmd := m.Update(authConfigMsg{cfg})
return next.(Model), cmd
}
func both() api.AuthConfig {
c := api.AuthConfig{PasswordLogin: true, DeviceLogin: true}
c.OIDC.Enabled, c.OIDC.Name = true, "Authentik"
return c
}
func ssoOnly() api.AuthConfig {
c := both()
c.PasswordLogin = false
return c
}
func update(t *testing.T, m Model, msg tea.Msg) (Model, tea.Cmd) {
t.Helper()
next, cmd := m.Update(msg)
return next.(Model), cmd
}
func ctrlO() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyCtrlO} }
func TestSSO_OfferedAlongsidePasswordsIsNotStartedByItself(t *testing.T) {
m, cmd := offering(t, "http://test", both(), "")
if cmd != nil || m.sso.active {
t.Fatal("with passwords on offer nothing should start until asked")
}
view := m.View()
for _, want := range []string{"Username:", "Password:", "ctrl+o to sign in with Authentik"} {
if !strings.Contains(view, want) {
t.Errorf("expected %q on the form:\n%s", want, view)
}
}
}
func TestSSO_NotOfferedShowsNoSuchHint(t *testing.T) {
m, _ := offering(t, "http://test", api.AuthConfig{PasswordLogin: true}, "")
if strings.Contains(m.View(), "ctrl+o") {
t.Error("a server with no SSO must not advertise it")
}
if m, cmd := update(t, m, ctrlO()); cmd != nil || m.sso.active {
t.Error("ctrl+o must do nothing when the server has no SSO")
}
}
func TestSSO_FullFlow(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
f := newFakeServer(t)
f.poll = func(w http.ResponseWriter, n int) {
if n < 3 {
pending(w, n)
return
}
http.SetCookie(w, &http.Cookie{Name: api.SessionCookie, Value: "tok-sso", Path: "/"})
io.WriteString(w, `{"user":{}}`)
}
m, _ := offering(t, f.URL, both(), "")
// ctrl+o asks the server for a login.
m, cmd := update(t, m, ctrlO())
if !m.sso.active || cmd == nil {
t.Fatal("ctrl+o should start a single sign-on login")
}
if !strings.Contains(m.View(), "Contacting the server") {
t.Errorf("before the server answers:\n%s", m.View())
}
started, ok := cmd().(deviceStartedMsg)
if !ok {
t.Fatalf("expected deviceStartedMsg, got %#v", cmd())
}
// The link and the code are shown, and a poll is scheduled.
m, cmd = update(t, m, started)
if cmd == nil || m.sso.interval != 5*time.Second {
t.Fatalf("expected a poll to be scheduled every 5s, got cmd %v interval %v", cmd != nil, m.sso.interval)
}
view := m.View()
for _, want := range []string{"Sign in with Authentik", "https://terdut.example.com/device?code=BCDF-GHJK",
"BCDF-GHJK", "Waiting for approval", "10 minutes", "esc·cancel"} {
if !strings.Contains(view, want) {
t.Errorf("expected %q while waiting:\n%s", want, view)
}
}
if strings.Contains(view, "Username:") {
t.Error("the password form must be out of the way while waiting")
}
// Two polls that find nothing, each scheduling the next.
for i := 1; i <= 2; i++ {
m, cmd = update(t, m, devicePollMsg{m.sso.attempt})
if cmd == nil {
t.Fatalf("poll %d: expected a request", i)
}
pend, ok := cmd().(devicePendingMsg)
if !ok {
t.Fatalf("poll %d: expected devicePendingMsg", i)
}
if m, cmd = update(t, m, pend); cmd == nil || !m.sso.active {
t.Fatalf("poll %d: expected to keep waiting", i)
}
}
// The third is approved: the session is saved and it moves on and connects.
m, cmd = update(t, m, devicePollMsg{m.sso.attempt})
done := cmd()
if _, ok := done.(loginDoneMsg); !ok {
t.Fatalf("expected loginDoneMsg, got %#v", done)
}
if got := session.Load(f.URL); got != "tok-sso" {
t.Errorf("session saved for next time: %q", got)
}
m, connect := update(t, m, done)
if m.mode != modeDashboard || m.sso.active || connect == nil {
t.Errorf("expected to move on and connect: mode %v active %v", m.mode, m.sso.active)
}
if f.polls != 3 {
t.Errorf("%d polls, want 3", f.polls)
}
}
func TestSSO_EscCancelsBeforeItQuits(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "AAAA-BBBB", VerificationURL: "u", Interval: 5, ExpiresIn: 600}})
m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEsc})
if cmd != nil {
t.Fatal("the first esc backs out of the wait; it must not quit")
}
if m.sso.active || m.mode != modeLogin || !strings.Contains(m.View(), "Username:") {
t.Errorf("expected the password form back, active %v", m.sso.active)
}
if _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEsc}); cmd == nil {
t.Error("esc on the form quits, as it always did")
} else if _, ok := cmd().(tea.QuitMsg); !ok {
t.Errorf("expected a quit, got %#v", cmd())
}
}
// The answers of an attempt that was cancelled arrive late and must change nothing.
func TestSSO_StaleMessagesAreIgnored(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
old := m.sso.attempt
m = m.cancelSSO()
for name, msg := range map[string]tea.Msg{
"started": deviceStartedMsg{old, api.DeviceLogin{DeviceCode: "d", UserCode: "X", Interval: 5}},
"poll": devicePollMsg{old},
"pending": devicePendingMsg{attempt: old},
"failed": deviceFailedMsg{old, api.ErrDeviceExpired},
} {
next, cmd := update(t, m, msg)
if cmd != nil || next.sso.active || next.sso.login != nil || next.loginErr != "" {
t.Errorf("%s from a cancelled attempt was acted on: %+v err %q", name, next.sso, next.loginErr)
}
}
// A new attempt is not confused by the old one's messages either.
m, _ = update(t, m, ctrlO())
if m.sso.attempt == old {
t.Fatal("a new attempt must have a new number")
}
if _, cmd := update(t, m, devicePollMsg{old}); cmd != nil {
t.Error("the old attempt's poll must not run in the new one")
}
}
func TestSSO_NoPasswordsStartsByItselfAndEnterRestarts(t *testing.T) {
m, cmd := offering(t, "http://test", ssoOnly(), "")
if !m.sso.active || cmd == nil {
t.Fatal("a server with no passwords should start signing in with SSO straight away")
}
m = m.cancelSSO()
view := m.View()
if strings.Contains(view, "Username:") || !strings.Contains(view, "This server signs in with Authentik") {
t.Errorf("no password form on an SSO-only server:\n%s", view)
}
if !strings.Contains(view, "enter·sign in with Authentik") {
t.Errorf("the footer should say what enter does:\n%s", view)
}
if m, cmd = update(t, m, tea.KeyMsg{Type: tea.KeyEnter}); !m.sso.active || cmd == nil {
t.Error("enter should start it again")
}
}
func TestSSO_ConfigPrefersItWhenOffered(t *testing.T) {
if m, cmd := offering(t, "http://test", both(), "sso"); !m.sso.active || cmd == nil {
t.Error("auth: sso should start by itself when the server offers it")
}
// ...and shows the password form when it does not, rather than a dead end.
m, cmd := offering(t, "http://test", api.AuthConfig{PasswordLogin: true}, "sso")
if m.sso.active || cmd != nil || !strings.Contains(m.View(), "Username:") {
t.Error("auth: sso against a server without SSO must fall back to the form")
}
if m, cmd := offering(t, "http://test", both(), "password"); m.sso.active || cmd != nil {
t.Error("auth: password must not start SSO")
}
}
func TestSSO_ExpiredAndRefusedReturnToTheFormWithAReason(t *testing.T) {
for name, tc := range map[string]struct {
err error
want string
}{
"expired": {api.ErrDeviceExpired, "expired"},
"refused": {api.ErrDeviceDenied, "refused"},
"no sso": {&api.StatusError{Code: 404}, "does not offer"},
"limited": {&api.StatusError{Code: 429}, "too many"},
"other": {errors.New("dial tcp: refused"), "Authentik failed"},
} {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m, cmd := update(t, m, deviceFailedMsg{m.sso.attempt, tc.err})
if cmd != nil || m.sso.active || !strings.Contains(m.loginErr, tc.want) {
t.Errorf("%s: active %v err %q, want it to contain %q", name, m.sso.active, m.loginErr, tc.want)
}
if !strings.Contains(m.View(), tc.want) {
t.Errorf("%s: the reason is not shown:\n%s", name, m.View())
}
}
}
func TestSSO_SlowDownLengthensTheInterval(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "A", VerificationURL: "u", Interval: 5, ExpiresIn: 60}})
m, cmd := update(t, m, devicePendingMsg{attempt: m.sso.attempt, slower: true})
if m.sso.interval != 10*time.Second || cmd == nil {
t.Errorf("interval %v, cmd %v; want 10s and another poll", m.sso.interval, cmd != nil)
}
}
func TestSSO_ADeadConnectionEndsTheWaitButABlipDoesNot(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "A", VerificationURL: "u", Interval: 5, ExpiresIn: 60}})
blip := errors.New("connection reset")
// Two failures, then a good answer: the count starts over.
for range 2 {
m, _ = update(t, m, devicePendingMsg{attempt: m.sso.attempt, err: blip})
}
m, _ = update(t, m, devicePendingMsg{attempt: m.sso.attempt})
if !m.sso.active || m.sso.failures != 0 {
t.Fatalf("a good answer should reset the failures: %+v", m.sso)
}
// Three in a row is a dead connection.
var cmd tea.Cmd
for range maxPollFailures {
m, cmd = update(t, m, devicePendingMsg{attempt: m.sso.attempt, err: blip})
}
if m.sso.active || cmd != nil || !strings.Contains(m.loginErr, "connection reset") {
t.Errorf("expected to give up with the reason: active %v err %q", m.sso.active, m.loginErr)
}
}
func TestSSO_TypingGoesNowhereWhileWaiting(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m = typeInto(t, m, "hunter2")
if got := m.loginInputs[m.loginFocus].Value(); got != "" {
t.Errorf("keys typed during the wait ended up in a field: %q", got)
}
if _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEnter}); cmd != nil {
t.Error("enter during the wait must not start anything")
}
}
// After the session ends the form comes back; it must still know what the
// server offers, and follow the config's preference without a second question.
func TestSSO_SessionEndingReturnsToSSOWhenPreferred(t *testing.T) {
m, _ := offering(t, "http://test", both(), "sso")
m = m.cancelSSO()
m.mode = modeDashboard // signed in, as loginDoneMsg leaves it
m, _ = update(t, m, connectedMsg{})
m, cmd := update(t, m, fetchDataErrMsg{&api.StatusError{Code: 401}})
if m.mode != modeLogin || m.authInfo == nil {
t.Fatalf("expected the form with what the server offers kept: mode %v info %v", m.mode, m.authInfo)
}
if !m.sso.active || cmd == nil {
t.Error("with auth: sso an ended session should go straight to SSO")
}
}
func TestSSO_SigningOutDoesNotSignStraightBackIn(t *testing.T) {
m, _ := offering(t, "http://test", both(), "sso")
m = m.cancelSSO()
m.mode = modeDashboard
m, _ = update(t, m, connectedMsg{})
m, cmd := update(t, m, logoutDoneMsg{})
if m.mode != modeLogin || m.sso.active || cmd != nil {
t.Errorf("a deliberate sign-out must wait: mode %v active %v", m.mode, m.sso.active)
}
}
// A session that ends before the server was ever asked (the TUI started on a
// saved session) still has to learn what to offer.
func TestSSO_LearnsWhatIsOfferedWhenTheSessionEndsFirst(t *testing.T) {
c := api.NewClient("http://test")
c.SetSession("saved")
m := NewModel(c, "http://test", time.Minute, signedOut("x").theme)
m.width, m.height = 120, 40
m, cmd := update(t, m, fetchDataErrMsg{&api.StatusError{Code: 401}})
if m.mode != modeLogin || m.authInfo != nil || cmd == nil {
t.Errorf("expected the form and a question to the server: mode %v info %v cmd %v", m.mode, m.authInfo, cmd != nil)
}
}
+500 -27
View File
@@ -1,10 +1,13 @@
package tui package tui
import ( import (
"errors"
"fmt" "fmt"
"net/http"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/api"
"github.com/atotto/clipboard" "github.com/atotto/clipboard"
@@ -13,6 +16,15 @@ import (
) )
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// A 401 from anything means the server no longer honours the session: it
// expired, was ended from the web UI, or the account was disabled. Whatever
// was being done cannot succeed, so go back to the sign-in form and say why,
// rather than leaving every action to fail with "server returned 401".
if err := msgError(msg); api.IsUnauthorized(err) && m.mode != modeLogin {
m, entry := m.requireLogin("your session has ended — sign in again").enterLogin(true)
return m, tea.Batch(forgetSessionCmd(), entry)
}
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
@@ -33,13 +45,106 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// ── Dashboard messages ──────────────────────────────────────────────── // ── Dashboard messages ────────────────────────────────────────────────
case loginDoneMsg:
m.loggingIn = false
m.sso = ssoLogin{attempt: m.sso.attempt + 1}
m.loginErr = ""
m.loginNote = ""
m.loginInputs[loginPassword].Reset()
m.blurLoginForm()
m.mode = modeDashboard
m.err = nil
return m, connectCmd(m.client)
case loginErrMsg:
m.loggingIn = false
m.loginErr = loginErrorText(msg.err)
m.loginInputs[loginPassword].Reset()
m.focusLogin(loginPassword)
return m, nil
case logoutDoneMsg:
// Not auto-started even with auth: sso: somebody who has just signed out
// did not ask to be signed straight back in.
return m.requireLogin("you have signed out").enterLogin(false)
case authConfigMsg:
cfg := msg.cfg
m.authInfo = &cfg
if m.mode == modeLogin && m.autoStartsSSO() {
return m.startSSO()
}
return m, nil
case deviceStartedMsg:
if !m.sso.current(msg.attempt) {
return m, nil
}
login := msg.login
m.sso.login = &login
m.sso.interval = time.Duration(login.Interval) * time.Second
if m.sso.interval <= 0 {
m.sso.interval = defaultDevicePoll
}
return m, devicePollAfter(m.sso.attempt, m.sso.interval)
case devicePollMsg:
if !m.sso.current(msg.attempt) || m.sso.login == nil {
return m, nil
}
return m, pollDeviceCmd(m.client, m.serverURL, m.sso.attempt, m.sso.login.DeviceCode)
case devicePendingMsg:
if !m.sso.current(msg.attempt) {
return m, nil
}
if msg.err != nil {
if m.sso.failures++; m.sso.failures >= maxPollFailures {
return m.failSSO(msg.err), nil
}
} else {
m.sso.failures = 0
}
if msg.slower {
m.sso.interval += defaultDevicePoll
}
return m, devicePollAfter(m.sso.attempt, m.sso.interval)
case deviceFailedMsg:
if !m.sso.current(msg.attempt) {
return m, nil
}
return m.failSSO(msg.err), nil
case connectedMsg: case connectedMsg:
firstConnect := len(m.teams) == 0
m.connected = true m.connected = true
m.err = nil m.err = nil
m.teams = msg.teams
m.meID = msg.me.User.ID
m.isAdmin = msg.me.User.IsAdmin
var statusCmd tea.Cmd
if firstConnect && m.activeTeamID == 0 && m.defaultTeam != "" {
if t, ok := resolveTeam(m.teams, m.defaultTeam); ok {
m.activeTeamID = t.ID
} else {
m.statusMsg = fmt.Sprintf("team %q not found -- showing all teams", m.defaultTeam)
statusCmd = clearStatusCmd()
}
}
m.rebuildIncidentTable()
m.rebuildTable()
m.rebuildArchivedTable()
var tick tea.Cmd
if !m.ticking {
m.ticking = true
tick = tickCmd(m.refreshInterval)
}
return m, tea.Batch( return m, tea.Batch(
tickCmd(m.refreshInterval), tick,
fetchIncidentsCmd(m.client, m.incidentFilter), fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter),
fetchStatsCmd(m.client), fetchStatsCmd(m.client),
statusCmd,
) )
case connectErrMsg: case connectErrMsg:
@@ -93,6 +198,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case incidentDetailFetchedMsg: case incidentDetailFetchedMsg:
m.selectedIncident = msg.incident m.selectedIncident = msg.incident
m.timeline = msg.timeline m.timeline = msg.timeline
m.similar = msg.similar
m.detailLoading = false m.detailLoading = false
if m.noteCursor >= len(noteEvents(m.timeline)) { if m.noteCursor >= len(noteEvents(m.timeline)) {
m.noteCursor = -1 m.noteCursor = -1
@@ -159,6 +265,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.rebuildUserManageTable() m.rebuildUserManageTable()
return m, nil return m, nil
case pickerReadyMsg:
if m.mode != modeUserPicker {
return m, nil // the picker was closed before the lookup came back
}
m.users = msg.users
m.pickerMembers = msg.members
m.usersLoading = false
m.rebuildUserPickerTable()
m.rebuildUserManageTable()
return m, nil
case apiKeyCreatedMsg: case apiKeyCreatedMsg:
m.revealedAPIKey = msg.key m.revealedAPIKey = msg.key
m.mode = modeAPIKeyReveal m.mode = modeAPIKeyReveal
@@ -226,22 +343,32 @@ func (m Model) refreshActiveSection() tea.Cmd {
switch m.activeSection { switch m.activeSection {
case sectionIncidents: case sectionIncidents:
return tea.Batch(fetchIncidentsCmd(m.client, m.incidentFilter), fetchStatsCmd(m.client)) return tea.Batch(fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter), fetchStatsCmd(m.client))
case sectionAlerts: case sectionAlerts:
return tea.Batch(fetchAlertsCmd(m.client, m.alertFilter), fetchStatsCmd(m.client)) return tea.Batch(fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter), fetchStatsCmd(m.client))
case sectionStats: case sectionStats:
// Both: fetchStatsCmd feeds the Incident Response block, the other the charts. // Both: fetchStatsCmd feeds the Incident Response block, the other the charts.
return tea.Batch(fetchStatsCmd(m.client), fetchDetailStatsCmd(m.client)) return tea.Batch(fetchStatsCmd(m.client), fetchDetailStatsCmd(m.client))
case sectionArchived: case sectionArchived:
return fetchArchivedIncidentsCmd(m.client) return fetchArchivedIncidentsCmd(m.client, m.activeTeamID)
case sectionSchedule: case sectionSchedule:
return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) return m.fetchScheduleWindowCmd()
case sectionUsers: case sectionUsers:
return fetchUsersCmd(m.client) return fetchUsersCmd(m.client)
} }
return nil return nil
} }
// fetchScheduleWindowCmd reloads the schedule window for the schedule's team, or
// does nothing when the caller belongs to none.
func (m Model) fetchScheduleWindowCmd() tea.Cmd {
team, ok := m.scheduleTeam()
if !ok {
return nil
}
return fetchScheduleCmd(m.client, team.ID, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
}
// routeKey passes the key to the active component then to our handler. // routeKey passes the key to the active component then to our handler.
func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) { func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch m.mode { switch m.mode {
@@ -300,6 +427,14 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case modeAPIKeyMenu, modeAPIKeyReveal: case modeAPIKeyMenu, modeAPIKeyReveal:
return m.handleKey(msg) return m.handleKey(msg)
case modeLogin:
var inputCmd tea.Cmd
if !m.loggingIn && !m.sso.active && m.offersPasswords() {
m.loginInputs[m.loginFocus], inputCmd = m.loginInputs[m.loginFocus].Update(msg)
}
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modePasswordSet: case modePasswordSet:
var inputCmd tea.Cmd var inputCmd tea.Cmd
if !m.pwLoading { if !m.pwLoading {
@@ -369,6 +504,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m.handleAPIKeyMenuKey(msg) return m.handleAPIKeyMenuKey(msg)
case modePasswordSet: case modePasswordSet:
return m.handlePasswordKey(msg) return m.handlePasswordKey(msg)
case modeLogin:
return m.handleLoginKey(msg)
case modeAPIKeyCreate: case modeAPIKeyCreate:
return m.handleAPIKeyCreateKey(msg) return m.handleAPIKeyCreateKey(msg)
case modeAPIKeyReveal: case modeAPIKeyReveal:
@@ -410,14 +547,27 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case sectionIncidents: case sectionIncidents:
m.incidentFilter = nextFilter(incidentFilters, m.incidentFilter) m.incidentFilter = nextFilter(incidentFilters, m.incidentFilter)
m.loading = true m.loading = true
return m, fetchIncidentsCmd(m.client, m.incidentFilter) return m, fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter)
case sectionAlerts: case sectionAlerts:
m.alertFilter = nextFilter(alertFilters, m.alertFilter) m.alertFilter = nextFilter(alertFilters, m.alertFilter)
m.loading = true m.loading = true
return m, fetchAlertsCmd(m.client, m.alertFilter) return m, fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter)
} }
return m, nil return m, nil
case "L":
if !m.connected {
return m, nil
}
m.statusMsg = "Signing out…"
return m, logoutCmd(m.client)
case "T":
if !m.connected || len(m.teams) == 0 {
return m, nil
}
return m.switchTeam()
case "enter": case "enter":
switch m.activeSection { switch m.activeSection {
case sectionIncidents: case sectionIncidents:
@@ -450,14 +600,14 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.statusMsg = "resolve the incident before archiving it" m.statusMsg = "resolve the incident before archiving it"
return m, clearStatusCmd() return m, clearStatusCmd()
} }
return m, archiveIncidentCmd(m.client, inc.ID, m.incidentFilter) return m, archiveIncidentCmd(m.client, inc.ID, m.activeTeamID, m.incidentFilter)
case sectionArchived: case sectionArchived:
i := m.archivedTable.Cursor() i := m.archivedTable.Cursor()
if i < 0 || i >= len(m.archivedIncidents) { if i < 0 || i >= len(m.archivedIncidents) {
return m, nil return m, nil
} }
m.archivedLoading = true m.archivedLoading = true
return m, unarchiveIncidentCmd(m.client, m.archivedIncidents[i].ID) return m, unarchiveIncidentCmd(m.client, m.archivedIncidents[i].ID, m.activeTeamID)
} }
return m, nil return m, nil
@@ -465,16 +615,18 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case "left", "h": case "left", "h":
if m.activeSection == sectionSchedule { if m.activeSection == sectionSchedule {
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, -7) m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, -7)
m.scheduleLoading = true cmd := m.fetchScheduleWindowCmd()
return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) m.scheduleLoading = cmd != nil
return m, cmd
} }
return m, nil return m, nil
case "right", "l": case "right", "l":
if m.activeSection == sectionSchedule { if m.activeSection == sectionSchedule {
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, 7) m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, 7)
m.scheduleLoading = true cmd := m.fetchScheduleWindowCmd()
return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) m.scheduleLoading = cmd != nil
return m, cmd
} }
return m, nil return m, nil
@@ -482,6 +634,9 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if m.activeSection != sectionSchedule || !m.connected { if m.activeSection != sectionSchedule || !m.connected {
return m, nil return m, nil
} }
if !m.checkScheduleEditable() {
return m, clearStatusCmd()
}
m.pickerAssignWeek = false m.pickerAssignWeek = false
return m.openUserPicker(pickerSchedule) return m.openUserPicker(pickerSchedule)
@@ -489,6 +644,9 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if m.activeSection != sectionSchedule || !m.connected { if m.activeSection != sectionSchedule || !m.connected {
return m, nil return m, nil
} }
if !m.checkScheduleEditable() {
return m, clearStatusCmd()
}
m.pickerAssignWeek = true m.pickerAssignWeek = true
return m.openUserPicker(pickerSchedule) return m.openUserPicker(pickerSchedule)
@@ -518,6 +676,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if cursor < 0 || cursor >= len(m.users) { if cursor < 0 || cursor >= len(m.users) {
return m, nil return m, nil
} }
if !m.isAdmin {
m.statusMsg = "only administrators can delete users"
return m, clearStatusCmd()
}
m.selectedUser = m.users[cursor] m.selectedUser = m.users[cursor]
m.confirmTarget = confirmDeleteUser m.confirmTarget = confirmDeleteUser
m.mode = modeConfirm m.mode = modeConfirm
@@ -528,6 +690,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if m.activeSection != sectionUsers || !m.connected { if m.activeSection != sectionUsers || !m.connected {
return m, nil return m, nil
} }
if !m.isAdmin {
m.statusMsg = "only administrators can create users"
return m, clearStatusCmd()
}
m.userFormInputs[0].Reset() m.userFormInputs[0].Reset()
m.userFormInputs[1].Reset() m.userFormInputs[1].Reset()
m.userFormFocus = 0 m.userFormFocus = 0
@@ -545,6 +711,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
m.selectedUser = m.users[cursor] m.selectedUser = m.users[cursor]
if !m.canManageUser(m.selectedUser) {
cmd := m.refuseUserAction("notification topic")
return m, cmd
}
// Prefilled with what they have, so editing a topic does not mean // Prefilled with what they have, so editing a topic does not mean
// retyping it, and clearing one is a deliberate wipe. // retyping it, and clearing one is a deliberate wipe.
m.ntfyTopicInput.SetValue(m.selectedUser.Topic()) m.ntfyTopicInput.SetValue(m.selectedUser.Topic())
@@ -562,6 +732,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
m.selectedUser = m.users[cursor] m.selectedUser = m.users[cursor]
if !m.canManageUser(m.selectedUser) {
cmd := m.refuseUserAction("API keys")
return m, cmd
}
m.mode = modeAPIKeyMenu m.mode = modeAPIKeyMenu
return m, nil return m, nil
@@ -574,6 +748,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
m.selectedUser = m.users[cursor] m.selectedUser = m.users[cursor]
if !m.canManageUser(m.selectedUser) {
cmd := m.refuseUserAction("password")
return m, cmd
}
for i := range m.pwInputs { for i := range m.pwInputs {
m.pwInputs[i].Reset() m.pwInputs[i].Reset()
m.pwInputs[i].Blur() m.pwInputs[i].Blur()
@@ -589,13 +767,20 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
// refuseUserAction explains a self-or-admin action declined up front, and is what
// the status bar clears afterwards. The server refuses these with a 403 anyway.
func (m *Model) refuseUserAction(what string) tea.Cmd {
m.statusMsg = "only administrators can change another user's " + what
return clearStatusCmd()
}
// loadSectionIfEmpty fetches a section's data the first time it is opened. // loadSectionIfEmpty fetches a section's data the first time it is opened.
func (m *Model) loadSectionIfEmpty() tea.Cmd { func (m *Model) loadSectionIfEmpty() tea.Cmd {
switch m.activeSection { switch m.activeSection {
case sectionAlerts: case sectionAlerts:
if len(m.alerts) == 0 { if len(m.alerts) == 0 {
m.loading = true m.loading = true
return fetchAlertsCmd(m.client, m.alertFilter) return fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter)
} }
case sectionStats: case sectionStats:
if !m.statsLoaded { if !m.statsLoaded {
@@ -605,12 +790,13 @@ func (m *Model) loadSectionIfEmpty() tea.Cmd {
case sectionArchived: case sectionArchived:
if len(m.archivedIncidents) == 0 { if len(m.archivedIncidents) == 0 {
m.archivedLoading = true m.archivedLoading = true
return fetchArchivedIncidentsCmd(m.client) return fetchArchivedIncidentsCmd(m.client, m.activeTeamID)
} }
case sectionSchedule: case sectionSchedule:
if len(m.scheduleDays) == 0 { if len(m.scheduleDays) == 0 {
m.scheduleLoading = true cmd := m.fetchScheduleWindowCmd()
return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) m.scheduleLoading = cmd != nil
return cmd
} }
case sectionUsers: case sectionUsers:
if len(m.users) == 0 { if len(m.users) == 0 {
@@ -641,6 +827,18 @@ func (m Model) openIncident(inc api.Incident) (Model, tea.Cmd) {
func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) { func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
m.pickerTarget = target m.pickerTarget = target
m.mode = modeUserPicker m.mode = modeUserPicker
if target == pickerSchedule {
// Only the team's own members can go on its rota, so who they are has to
// be known before anybody is offered.
team, ok := m.scheduleTeam()
if !ok {
m.mode = modeDashboard
return m, nil
}
m.pickerMembers = nil
m.usersLoading = true
return m, fetchPickerCmd(m.client, team.ID)
}
if len(m.users) == 0 { if len(m.users) == 0 {
m.usersLoading = true m.usersLoading = true
return m, fetchUsersCmd(m.client) return m, fetchUsersCmd(m.client)
@@ -649,6 +847,63 @@ 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
// 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
m.incidents, m.alerts, m.archivedIncidents = nil, nil, nil
m.scheduleEntries, m.scheduleDays, m.currentOnCall = nil, nil, nil
m.loading = true
m.rebuildIncidentTable()
m.rebuildTable()
m.rebuildArchivedTable()
m.rebuildScheduleTable()
label := "all teams"
if t, ok := m.activeTeam(); ok {
label = t.Name
}
m.statusMsg = "Team: " + label
cmds := []tea.Cmd{clearStatusCmd()}
if m.activeSection != sectionIncidents {
cmds = append(cmds, fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter))
}
cmds = append(cmds, m.refreshActiveSection())
if m.activeSection == sectionSchedule {
m.scheduleLoading = true
}
return m, tea.Batch(cmds...)
}
// checkScheduleEditable says why a schedule change is refused, in the status
// bar, and reports whether it may go ahead. The server enforces the same rule.
func (m *Model) checkScheduleEditable() bool {
team, ok := m.scheduleTeam()
switch {
case !ok:
m.statusMsg = "you are not in any team"
case !m.canEditSchedule(team):
m.statusMsg = "only owners of " + team.Name + " can change its schedule"
default:
return true
}
return false
}
// nextFilter advances a filter cycle, wrapping at the end. // nextFilter advances a filter cycle, wrapping at the end.
func nextFilter(cycle []string, current string) string { func nextFilter(cycle []string, current string) string {
for i, f := range cycle { for i, f := range cycle {
@@ -732,13 +987,14 @@ func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if inc.ArchivedAt != nil { if inc.ArchivedAt != nil {
m.archivedLoading = true m.archivedLoading = true
m.mode = modeDashboard m.mode = modeDashboard
return m, unarchiveIncidentCmd(m.client, inc.ID) return m, unarchiveIncidentCmd(m.client, inc.ID, m.activeTeamID)
} }
m.mode = modeDashboard m.mode = modeDashboard
return m, archiveIncidentCmd(m.client, inc.ID, m.incidentFilter) return m, archiveIncidentCmd(m.client, inc.ID, m.activeTeamID, m.incidentFilter)
case "c": case "c", "C":
m.mode = modeNote m.mode = modeNote
m.notePinned = msg.String() == "C"
m.noteInput.Reset() m.noteInput.Reset()
m.noteInput.Focus() m.noteInput.Focus()
m.detailViewport.Height = m.detailViewportHeight() m.detailViewport.Height = m.detailViewportHeight()
@@ -831,7 +1087,7 @@ func (m Model) handleNoteKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeIncidentDetail m.mode = modeIncidentDetail
m.noteInput.Blur() m.noteInput.Blur()
m.detailViewport.Height = m.detailViewportHeight() m.detailViewport.Height = m.detailViewportHeight()
return m, addNoteCmd(m.client, m.selectedIncident.ID, content) return m, addNoteCmd(m.client, m.selectedIncident.ID, content, m.notePinned)
} }
return m, nil return m, nil
@@ -895,7 +1151,8 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeDashboard m.mode = modeDashboard
m.pendingDeleteEntry = nil m.pendingDeleteEntry = nil
m.scheduleLoading = true m.scheduleLoading = true
return m, deleteScheduleEntryCmd(m.client, entry.ID, team, _ := m.scheduleTeam()
return m, deleteScheduleEntryCmd(m.client, team.ID, entry.ID,
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
case confirmDeleteUser: case confirmDeleteUser:
@@ -912,7 +1169,8 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
m.scheduleLoading = true m.scheduleLoading = true
return m, assignScheduleCmd(m.client, p.userID, p.dates, true, team, _ := m.scheduleTeam()
return m, assignScheduleCmd(m.client, team.ID, p.userID, p.dates, true,
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
} }
@@ -933,10 +1191,11 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case "enter": case "enter":
cursor := m.userPickerTable.Cursor() cursor := m.userPickerTable.Cursor()
if cursor < 0 || cursor >= len(m.users) { pickable := m.pickerUsers()
if cursor < 0 || cursor >= len(pickable) {
return m, nil return m, nil
} }
user := m.users[cursor] user := pickable[cursor]
if m.pickerTarget == pickerIncidentAssignee { if m.pickerTarget == pickerIncidentAssignee {
m.mode = modeIncidentDetail m.mode = modeIncidentDetail
@@ -986,7 +1245,8 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
// Nobody else loses anything, but the server rejects any date that // Nobody else loses anything, but the server rejects any date that
// already exists — including days this same person already holds, which // already exists — including days this same person already holds, which
// is a no-op worth letting through silently. // is a no-op worth letting through silently.
return m, assignScheduleCmd(m.client, user.ID, dates, m.scheduleOccupied(dates), team, _ := m.scheduleTeam()
return m, assignScheduleCmd(m.client, team.ID, user.ID, dates, m.scheduleOccupied(dates),
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
} }
@@ -1244,3 +1504,216 @@ func (m Model) handlePasswordKey(msg tea.KeyMsg) (Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
// ── Sign in ───────────────────────────────────────────────────────────────────
// msgError digs the error out of the messages that carry one, so the 401 check
// in Update covers them all in one place.
func msgError(msg tea.Msg) error {
switch msg := msg.(type) {
case connectErrMsg:
return msg.err
case fetchDataErrMsg:
return msg.err
case detailErrMsg:
return msg.err
case actionErrMsg:
return msg.err
case detailStatsErrMsg:
return msg.err
case scheduleFetchErrMsg:
return msg.err
case scheduleActionErrMsg:
return msg.err
case userActionErrMsg:
return msg.err
}
return nil
}
// requireLogin returns to the sign-in form with everything the previous session
// loaded dropped, so a different account never sees the last one's incidents.
func (m Model) requireLogin(note string) Model {
name := m.loginInputs[loginUsername].Value()
fresh := NewModel(m.client, m.serverURL, m.refreshInterval, m.theme)
fresh.width, fresh.height = m.width, m.height
fresh.defaultTeam = m.defaultTeam
fresh.authInfo, fresh.authPref = m.authInfo, m.authPref
fresh.ticking = m.ticking
fresh.mode = modeLogin
fresh.loginInputs[loginUsername].SetValue(name)
fresh.loginNote = note
fresh.focusLogin(loginUsername)
if name != "" {
fresh.focusLogin(loginPassword)
}
fresh.rebuildIncidentTable()
fresh.rebuildTable()
fresh.rebuildArchivedTable()
fresh.rebuildScheduleTable()
fresh.rebuildUserPickerTable()
fresh.rebuildUserManageTable()
return fresh
}
func (m *Model) blurLoginForm() {
for i := range m.loginInputs {
m.loginInputs[i].Blur()
}
}
func (m *Model) focusLogin(field int) {
m.blurLoginForm()
m.loginFocus = field
m.loginInputs[field].Focus()
}
// loginErrorText turns a failed sign-in into something to act on. The server
// answers a wrong password, an unknown user and an account with no password with
// the same 401, so the last one has to be named here or it reads as a typo.
func loginErrorText(err error) string {
var se *api.StatusError
if errors.As(err, &se) {
switch se.Code {
case http.StatusUnauthorized:
return "invalid username or password — an account with no password cannot sign in; set one in the web UI first"
case http.StatusTooManyRequests:
return "too many attempts — wait a few minutes and try again"
}
}
return err.Error()
}
// ── Single sign-on ────────────────────────────────────────────────────────────
// current reports whether a message belongs to the attempt in progress. Anything
// else is the late answer of one that was cancelled, replaced or finished.
func (s ssoLogin) current(attempt int) bool { return s.active && attempt == s.attempt }
// canSSO is whether the server can sign in a client with no browser.
func (m Model) canSSO() bool { return m.authInfo != nil && m.authInfo.DeviceLogin }
// offersPasswords is whether the password form is worth showing. Until the
// server has answered it is: the form is what a server too old to be asked has.
func (m Model) offersPasswords() bool { return m.authInfo == nil || m.authInfo.PasswordLogin }
// autoStartsSSO is whether the form should start a single sign-on login by
// itself: when the config asks for it, and when the server has no passwords, so
// that there is nothing else to show.
func (m Model) autoStartsSSO() bool {
return m.canSSO() && !m.sso.active && (m.authPref == "sso" || !m.offersPasswords())
}
// ssoName is what the provider is called on screen.
func (m Model) ssoName() string {
if m.authInfo != nil && m.authInfo.OIDC.Name != "" {
return m.authInfo.OIDC.Name
}
return "single sign-on"
}
// enterLogin is what to do on arriving at the sign-in form other than by
// starting up: learn how the server can be signed in to if that is not known,
// and, when auto is set, start a single sign-on login if the config or the
// server's lack of passwords calls for one.
func (m Model) enterLogin(auto bool) (Model, tea.Cmd) {
if m.authInfo == nil {
return m, authConfigCmd(m.client)
}
if auto && m.autoStartsSSO() {
return m.startSSO()
}
return m, nil
}
// startSSO begins a device login, replacing any earlier attempt.
func (m Model) startSSO() (Model, tea.Cmd) {
m.sso = ssoLogin{attempt: m.sso.attempt + 1, active: true}
m.loginErr = ""
return m, startDeviceCmd(m.client, m.sso.attempt)
}
// cancelSSO abandons the attempt in progress. The server forgets the login when
// it expires; there is nothing to tell it.
func (m Model) cancelSSO() Model {
m.sso = ssoLogin{attempt: m.sso.attempt + 1}
return m
}
// failSSO ends the attempt and says why on the form.
func (m Model) failSSO(err error) Model {
m = m.cancelSSO()
m.loginErr = ssoErrorText(err, m.ssoName())
return m
}
// ssoErrorText turns a failed single sign-on into something to act on.
func ssoErrorText(err error, name string) string {
var se *api.StatusError
switch {
case errors.Is(err, api.ErrDeviceExpired):
return "the sign-in expired before it was approved — start it again"
case errors.Is(err, api.ErrDeviceDenied):
return "the sign-in was refused in the browser"
case errors.As(err, &se) && se.Code == http.StatusNotFound:
return "this server does not offer sign-in with " + name
case errors.As(err, &se) && se.Code == http.StatusTooManyRequests:
return "too many attempts — wait a few minutes and try again"
}
return "sign-in with " + name + " failed: " + err.Error()
}
func (m Model) handleLoginKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
// Backs out of a single sign-on wait before it quits the program, so a
// wrong turn does not cost the session.
if m.sso.active {
return m.cancelSSO(), nil
}
return m, tea.Quit
}
if m.loggingIn || m.sso.active {
return m, nil
}
if msg.String() == "ctrl+o" && m.canSSO() {
return m.startSSO()
}
// With no password form there is one thing to do, and enter does it.
if msg.String() == "enter" && !m.offersPasswords() {
if m.canSSO() {
return m.startSSO()
}
return m, nil
}
switch msg.String() {
case "tab", "shift+tab", "down", "up":
next := loginPassword
if m.loginFocus == loginPassword {
next = loginUsername
}
m.focusLogin(next)
return m, nil
case "enter":
username := strings.TrimSpace(m.loginInputs[loginUsername].Value())
password := m.loginInputs[loginPassword].Value()
switch {
case username == "":
m.loginErr = "enter your username"
m.focusLogin(loginUsername)
return m, nil
case password == "":
m.loginErr = "enter your password"
m.focusLogin(loginPassword)
return m, nil
}
m.loggingIn = true
m.loginErr = ""
return m, loginCmd(m.client, m.serverURL, username, password)
}
return m, nil
}
+249
View File
@@ -35,6 +35,7 @@ func sized() Model {
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.connected = true m.connected = true
m.isAdmin = true // most handlers are being tested for what they do, not who may
return m return m
} }
@@ -320,6 +321,8 @@ func pickingOnCall(entries []api.ScheduleEntry, dayIndex int, week bool) Model {
{ID: 1, Username: "niklas", Email: "n@example.com"}, {ID: 1, Username: "niklas", Email: "n@example.com"},
{ID: 2, Username: "alex", Email: "a@example.com"}, {ID: 2, Username: "alex", Email: "a@example.com"},
} }
m.pickerTarget = pickerSchedule
m.pickerMembers = map[int64]bool{1: true, 2: true}
m.rebuildUserPickerTable() m.rebuildUserPickerTable()
m.scheduleTable.SetCursor(dayIndex) m.scheduleTable.SetCursor(dayIndex)
m.pickerAssignWeek = week m.pickerAssignWeek = week
@@ -693,6 +696,8 @@ func containsAll(s string, subs ...string) bool {
func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) { func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.connected = true m.connected = true
m.isAdmin = true
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleOwner}}
m.activeSection = sectionSchedule m.activeSection = sectionSchedule
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC) m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
@@ -718,9 +723,253 @@ func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
if m.mode != modeUserPicker { if m.mode != modeUserPicker {
t.Fatalf("W did not open the user picker, got mode %v", m.mode) t.Fatalf("W did not open the user picker, got mode %v", m.mode)
} }
// The picker waits for the team's members before offering anybody.
next, _ = m.Update(pickerReadyMsg{
users: []api.User{{ID: 1, Username: "niklas", Email: "n@example.com"}},
members: map[int64]bool{1: true},
})
m = next.(Model)
m, _ = press(t, m, "enter") // panicked here m, _ = press(t, m, "enter") // panicked here
if m.mode == modeUserPicker { if m.mode == modeUserPicker {
t.Fatal("enter left the picker open; the assignment never went anywhere") t.Fatal("enter left the picker open; the assignment never went anywhere")
} }
} }
// ── Teams ─────────────────────────────────────────────────────────────────
func twoTeams() []api.Team {
return []api.Team{
{ID: 1, Name: "Ops", Role: api.RoleOwner},
{ID: 2, Name: "Dev", Role: api.RoleMember},
}
}
func TestConnected_LoadsTeamsAndWhoIAm(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40
next, _ := m.Update(connectedMsg{
teams: twoTeams(),
me: api.Me{User: api.User{ID: 7, IsAdmin: true}},
})
m = next.(Model)
if len(m.teams) != 2 || m.meID != 7 || !m.isAdmin {
t.Errorf("expected teams, id and admin flag to be kept, got %+v %d %v", m.teams, m.meID, m.isAdmin)
}
if m.activeTeamID != 0 {
t.Errorf("with no default team every team shows, got active %d", m.activeTeamID)
}
}
func TestConnected_DefaultTeamFromConfig(t *testing.T) {
for _, want := range []string{"dev", "2"} { // by name, any case, or by id
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark).WithDefaultTeam(want)
next, _ := m.Update(connectedMsg{teams: twoTeams()})
if got := next.(Model).activeTeamID; got != 2 {
t.Errorf("default team %q: expected team 2, got %d", want, got)
}
}
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark).WithDefaultTeam("nope")
next, cmd := m.Update(connectedMsg{teams: twoTeams()})
m = next.(Model)
if m.activeTeamID != 0 || !strings.Contains(m.statusMsg, "nope") {
t.Errorf("an unknown default should fall back to all teams and say so, got %d %q",
m.activeTeamID, m.statusMsg)
}
if cmd == nil {
t.Error("expected the initial fetches to still be issued")
}
}
func TestSwitchTeam_CyclesAllThenEachTeam(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)
}
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)
}
}
}
func TestSwitchTeam_ClearsRowsFromTheOtherTeam(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")
if len(m.incidents) != 0 {
t.Errorf("the previous team's incidents must not linger, got %d", len(m.incidents))
}
if !strings.Contains(m.statusMsg, "Ops") {
t.Errorf("expected the status bar to name the team, got %q", m.statusMsg)
}
}
func TestSwitchTeam_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")
}
}
func TestScheduleTeam(t *testing.T) {
m := sized()
if _, ok := m.scheduleTeam(); ok {
t.Error("no teams means no schedule")
}
m.teams = []api.Team{{ID: 2, Name: "Dev", Role: api.RoleMember}, {ID: 1, Name: "Ops", Role: api.RoleOwner}}
if tm, _ := m.scheduleTeam(); tm.ID != 1 {
t.Errorf("with all teams showing the one the caller owns is used, got %d", tm.ID)
}
m.activeTeamID = 2
if tm, _ := m.scheduleTeam(); tm.ID != 2 {
t.Errorf("the active team wins, got %d", tm.ID)
}
}
func TestSchedule_OnlyOwnersAndAdminsEdit(t *testing.T) {
m := scheduledWeek(nil)
m.isAdmin = false
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleMember}}
m, cmd := press(t, m, "+")
if m.mode == modeUserPicker {
t.Fatal("a plain member must not get as far as the picker")
}
if !strings.Contains(m.statusMsg, "owners of Ops") {
t.Errorf("expected the reason in the status bar, got %q", m.statusMsg)
}
if cmd == nil {
t.Error("the message should clear itself")
}
m.isAdmin = true // administrators may edit any team's rota
m.statusMsg = ""
m, _ = press(t, m, "+")
if m.mode != modeUserPicker {
t.Errorf("an administrator should reach the picker, got mode %v", m.mode)
}
}
// The picker fetches the team's members, and offers nobody until they arrive:
// the server answers 404 for anyone else.
func TestSchedulePicker_OffersOnlyTeamMembers(t *testing.T) {
m := scheduledWeek(nil)
m, cmd := press(t, m, "+")
if cmd == nil || !m.usersLoading {
t.Fatal("opening the picker should start loading the members")
}
if got := len(m.pickerUsers()); got != 0 {
t.Errorf("nobody should be offered before the members are known, got %d", got)
}
disabled := time.Now()
next, _ := m.Update(pickerReadyMsg{
users: []api.User{
{ID: 1, Username: "niklas"},
{ID: 2, Username: "outsider"},
{ID: 3, Username: "gone", DisabledAt: &disabled},
},
members: map[int64]bool{1: true, 3: true},
})
m = next.(Model)
got := m.pickerUsers()
if len(got) != 1 || got[0].Username != "niklas" {
t.Errorf("expected only the enabled member, got %+v", got)
}
if rows := m.userPickerTable.Rows(); len(rows) != 1 {
t.Errorf("the table should match, got %d rows", len(rows))
}
}
func TestUsers_NonAdminsCannotCreateOrDelete(t *testing.T) {
m := threeUsers()
m.isAdmin = false
m.meID = 1
m, _ = press(t, m, "n")
if m.mode != modeDashboard || !strings.Contains(m.statusMsg, "administrators") {
t.Errorf("n should be refused with a reason, got mode %v %q", m.mode, m.statusMsg)
}
m, _ = press(t, m, "d")
if m.mode != modeDashboard {
t.Errorf("d should not ask to delete for a non-admin, got mode %v", m.mode)
}
}
func TestUsers_NonAdminManagesOnlyThemselves(t *testing.T) {
m := threeUsers() // cursor on erik, id 3
m.isAdmin = false
m.meID = 1
for _, key := range []string{"t", "k", "p"} {
next, _ := press(t, m, key)
if next.mode != modeDashboard {
t.Errorf("%s on somebody else's row should be refused, got mode %v", key, next.mode)
}
if !strings.Contains(next.statusMsg, "another user's") {
t.Errorf("%s: expected the reason, got %q", key, next.statusMsg)
}
}
m.userManageTable.SetCursor(0) // niklas, id 1: themselves
if next, _ := press(t, m, "k"); next.mode != modeAPIKeyMenu {
t.Errorf("a user may manage their own keys, got mode %v", next.mode)
}
}
func TestUserFlags(t *testing.T) {
now := time.Now()
cases := []struct {
u api.User
want string
}{
{api.User{}, "—"},
{api.User{IsAdmin: true}, "admin"},
{api.User{DisabledAt: &now}, "disabled"},
{api.User{IsAdmin: true, DisabledAt: &now}, "admin,disabled"},
}
for _, c := range cases {
if got := userFlags(c.u); got != c.want {
t.Errorf("userFlags(%+v) = %q, want %q", c.u, got, c.want)
}
}
}
// Rebuilding a list on every refresh must not send the cursor back to the top.
func TestRefresh_KeepsTheCursor(t *testing.T) {
m := sized()
m.incidents = []api.Incident{{ID: 1}, {ID: 2}, {ID: 3}}
m.rebuildIncidentTable()
m.incidentTable.SetCursor(2)
next, _ := m.Update(incidentsFetchedMsg{incidents: m.incidents})
if got := next.(Model).incidentTable.Cursor(); got != 2 {
t.Errorf("expected the cursor to stay on row 2, got %d", got)
}
}
// The Team column comes and goes as the team switches, and the table must
// survive its column count changing under rows that are already loaded.
func TestTeamColumn_AppearsWithoutPanicking(t *testing.T) {
m := sized()
m.incidents = []api.Incident{{ID: 1, Title: "a", TeamName: "Ops"}}
m.rebuildIncidentTable()
m.teams = twoTeams()
m.rebuildIncidentTable() // five columns become six over a five-cell row
if got := len(m.incidentTable.Columns()); got != 6 {
t.Errorf("expected a Team column across two teams, got %d columns", got)
}
m.activeTeamID = 1
m.rebuildIncidentTable()
if got := len(m.incidentTable.Columns()); got != 5 {
t.Errorf("expected the Team column to go when one team is chosen, got %d", got)
}
}
+155 -15
View File
@@ -27,10 +27,85 @@ 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 {
title += 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)
} }
// renderLogin is the sign-in form. It is the whole body: nothing else is shown
// until the server has accepted a session.
func (m Model) renderLogin() string {
var b strings.Builder
b.WriteString("\n " + m.styles.Bold.Render("Sign in to "+m.serverURL) + "\n\n")
if m.loginNote != "" {
b.WriteString(m.styles.Status.Render(" "+m.loginNote) + "\n\n")
}
if m.sso.active {
b.WriteString(m.renderSSOWait())
return b.String()
}
if m.offersPasswords() {
b.WriteString(" " + m.styles.Header.Render("Username: ") + m.loginInputs[loginUsername].View() + "\n")
b.WriteString(" " + m.styles.Header.Render("Password: ") + m.loginInputs[loginPassword].View() + "\n\n")
} else {
b.WriteString(m.styles.Muted.Render(" This server signs in with "+m.ssoName()+".") + "\n\n")
}
switch {
case m.loggingIn:
b.WriteString(m.styles.Muted.Render(" Signing in…") + "\n")
case m.loginErr != "":
b.WriteString(m.styles.Error.Render(" "+m.loginErr) + "\n")
}
if m.canSSO() && m.offersPasswords() {
b.WriteString("\n" + m.styles.Muted.Render(" or press ctrl+o to sign in with "+m.ssoName()) + "\n")
}
return b.String()
}
// renderSSOWait is the single sign-on screen: the link to open and the code to
// check against it, while the client waits for the approval.
func (m Model) renderSSOWait() string {
var b strings.Builder
l := m.sso.login
if l == nil {
b.WriteString(m.styles.Muted.Render(" Contacting the server…") + "\n")
return b.String()
}
b.WriteString(" " + m.styles.Header.Render("Sign in with "+m.ssoName()) + "\n\n")
b.WriteString(" Open this link in a browser, on any device, and approve the sign-in:\n\n")
b.WriteString(" " + m.styles.Accent.Render(l.VerificationURL) + "\n\n")
b.WriteString(" " + m.styles.Header.Render("Code: ") + m.styles.Bold.Render(l.UserCode) +
m.styles.Muted.Render(" it should match the code on that page") + "\n\n")
b.WriteString(m.styles.Muted.Render(fmt.Sprintf(" Waiting for approval… good for %d minutes", (l.ExpiresIn+59)/60)) + "\n")
return b.String()
}
// loginHelp is the sign-in footer, which depends on what the server offers.
func (m Model) loginHelp() string {
switch {
case m.sso.active:
return " esc·cancel"
case !m.offersPasswords():
if m.canSSO() {
return " enter·sign in with " + m.ssoName() + " esc·quit"
}
return " esc·quit"
case m.canSSO():
return " tab·next field enter·sign in ctrl+o·" + m.ssoName() + " esc·quit"
}
return " tab·next field enter·sign in esc·quit"
}
// activeTeamLabel names what the lists are narrowed to.
func (m Model) activeTeamLabel() string {
if t, ok := m.activeTeam(); ok {
return t.Name
}
return "all"
}
func (m Model) renderTabs() string { func (m Model) renderTabs() string {
var tabs []string var tabs []string
for i, name := range sectionNames { for i, name := range sectionNames {
@@ -45,6 +120,9 @@ func (m Model) renderTabs() string {
} }
func (m Model) renderBody() string { func (m Model) renderBody() string {
if m.mode == modeLogin {
return m.renderLogin()
}
if m.err != nil { if m.err != nil {
return "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) + return "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) +
"\n" + m.styles.Muted.Render(" Press r to retry.") "\n" + m.styles.Muted.Render(" Press r to retry.")
@@ -57,7 +135,11 @@ func (m Model) renderBody() string {
case modeIncidentDetail, modeAlertDetail: case modeIncidentDetail, modeAlertDetail:
return m.renderDetail() return m.renderDetail()
case modeNote: case modeNote:
return m.renderPrompt(m.styles.Header.Render("Note: ") + m.noteInput.View()) label := "Note: "
if m.notePinned {
label = "What fixed it: "
}
return m.renderPrompt(m.styles.Header.Render(label) + m.noteInput.View())
case modeSnooze: case modeSnooze:
return m.renderPrompt(m.styles.Header.Render("Snooze for: ") + m.snoozeInput.View()) return m.renderPrompt(m.styles.Header.Render("Snooze for: ") + m.snoozeInput.View())
case modeConfirm: case modeConfirm:
@@ -102,7 +184,7 @@ func (m Model) renderFooter() string {
switch m.mode { switch m.mode {
case modeIncidentDetail: case modeIncidentDetail:
if !m.selectedIncident.IsOpen() { if !m.selectedIncident.IsOpen() {
return withStatus(" x·archive c·note [/]·select d·del esc·back") return withStatus(" x·archive c·note C·fix note [/]·select d·del esc·back")
} }
return withStatus(" a·ack A·unack R·resolve s·assign z·snooze Z·unsnooze c·note [/]·select d·del esc·back") return withStatus(" a·ack A·unack R·resolve s·assign z·snooze Z·unsnooze c·note [/]·select d·del esc·back")
@@ -149,25 +231,36 @@ func (m Model) renderFooter() string {
case modePasswordSet: case modePasswordSet:
return withStatus(" tab·next field enter·set password esc·cancel") return withStatus(" tab·next field enter·set password esc·cancel")
case modeLogin:
return "\n" + m.styles.Footer.Render(m.loginHelp())
default: default:
switch m.activeSection { switch m.activeSection {
case sectionIncidents: case sectionIncidents:
return withStatus(" enter·detail x·archive f·filter r·refresh tab·section q·quit") return withStatus(" enter·detail x·archive f·filter " + m.teamHint() + "r·refresh tab·section L·sign out q·quit")
case sectionAlerts: case sectionAlerts:
return withStatus(" enter·detail f·filter r·refresh tab·section q·quit") return withStatus(" enter·detail f·filter " + m.teamHint() + "r·refresh tab·section q·quit")
case sectionStats: case sectionStats:
return withStatus(" ↑/↓·scroll r·refresh tab·section q·quit") return withStatus(" ↑/↓·scroll r·refresh tab·section q·quit")
case sectionArchived: case sectionArchived:
return withStatus(" enter·detail x·unarchive r·refresh tab·section q·quit") return withStatus(" enter·detail x·unarchive " + m.teamHint() + "r·refresh tab·section q·quit")
case sectionSchedule: case sectionSchedule:
return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit") return withStatus(" +·assign day W·assign week d·del ←/→·shift week " + 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 tab·section q·quit") return withStatus(" n·new user t·topic 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()))
} }
} }
// teamHint is the footer's team-switch key, shown only when there is a choice.
func (m Model) teamHint() string {
if len(m.teams) > 1 {
return "T·team "
}
return ""
}
func (m Model) confirmPrompt() string { func (m Model) confirmPrompt() string {
switch m.confirmTarget { switch m.confirmTarget {
case confirmDeleteNote: case confirmDeleteNote:
@@ -332,23 +425,41 @@ func (m Model) renderSchedule() string {
return "\n" + m.styles.Muted.Render(" Loading schedule…") return "\n" + m.styles.Muted.Render(" Loading schedule…")
} }
team, ok := m.scheduleTeam()
if !ok {
return "\n" + m.styles.Muted.Render(" You are not in any team, so there is no schedule to show.")
}
var onCallLine string var onCallLine string
if m.currentOnCall != nil { if len(m.currentOnCall) > 0 {
onCallLine = fmt.Sprintf(" On-call today: %s", onCallLine = " On-call today: " + m.styles.AlertName.Render(m.onCallNames())
m.styles.AlertName.Render(m.currentOnCall.Username))
} else { } else {
onCallLine = m.styles.Muted.Render(" On-call today: nobody scheduled") onCallLine = m.styles.Muted.Render(" On-call today: nobody scheduled")
} }
from := m.scheduleWindow from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 6) to := m.scheduleWindow.AddDate(0, 0, 6)
windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s — %s", windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s: %s — %s",
from.Format("Jan 02"), to.Format("Jan 02, 2006"))) team.Name, from.Format("Jan 02"), to.Format("Jan 02, 2006")))
header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n" header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n"
return header + m.scheduleTable.View() return header + m.scheduleTable.View()
} }
// onCallNames lists who is on call today. With several teams each name carries
// its team, since one person per team is on call and "alice, bob" alone would
// not say whose.
func (m Model) onCallNames() string {
parts := make([]string, len(m.currentOnCall))
for i, e := range m.currentOnCall {
parts[i] = e.Username
if len(m.teams) > 1 && e.TeamName != "" {
parts[i] += " (" + e.TeamName + ")"
}
}
return strings.Join(parts, ", ")
}
func (m Model) renderUserPicker() string { func (m Model) renderUserPicker() string {
if m.usersLoading { if m.usersLoading {
return "\n" + m.styles.Muted.Render(" Loading users…") return "\n" + m.styles.Muted.Render(" Loading users…")
@@ -423,7 +534,7 @@ func line(style lipgloss.Style, s string) string {
// ── Content builders ─────────────────────────────────────────────────────── // ── Content builders ───────────────────────────────────────────────────────
func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string { func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.IncidentEvent, similar []api.SimilarIncident, cursor, width int) string {
now := time.Now() now := time.Now()
var b strings.Builder var b strings.Builder
contentW := width - 4 contentW := width - 4
@@ -441,6 +552,9 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
b.WriteString("\n " + title + strings.Repeat(" ", gap) + status + "\n\n") b.WriteString("\n " + title + strings.Repeat(" ", gap) + status + "\n\n")
// Timing and ownership // Timing and ownership
if inc.TeamName != "" {
b.WriteString(fmt.Sprintf(" Team: %s\n", inc.TeamName))
}
b.WriteString(fmt.Sprintf(" Triggered: %s (%s)\n", b.WriteString(fmt.Sprintf(" Triggered: %s (%s)\n",
inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt))) inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt)))
@@ -461,6 +575,10 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
b.WriteString(line(s.Muted, " Acked: not acknowledged")) b.WriteString(line(s.Muted, " Acked: not acknowledged"))
} }
if inc.EscalationLevel > 0 {
b.WriteString(line(s.Snoozed, fmt.Sprintf(" Escalation: level %d", inc.EscalationLevel)))
}
if inc.IsSnoozed() { if inc.IsSnoozed() {
b.WriteString(line(s.Snoozed, fmt.Sprintf(" Snoozed: until %s (%s)", b.WriteString(line(s.Snoozed, fmt.Sprintf(" Snoozed: until %s (%s)",
inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil)))) inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil))))
@@ -510,6 +628,22 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
} }
b.WriteString("\n") b.WriteString("\n")
// Seen before: earlier incidents of the same kind that someone left notes on.
if len(similar) > 0 {
b.WriteString(divider(s, "Seen before", width))
for _, sim := range similar {
b.WriteString(fmt.Sprintf(" #%-6d %-44s %s\n", sim.ID, truncate(sim.Title, 44),
s.Muted.Render("resolved "+humanAgo(now, sim.ResolvedAt))))
for _, n := range sim.ResolutionNotes {
b.WriteString(" " + s.Resolved.Render("fixed: ") + n.Detail + "\n")
}
if len(sim.ResolutionNotes) == 0 {
b.WriteString(line(s.Muted, fmt.Sprintf(" %d note(s), no resolution note", sim.NoteCount)))
}
}
b.WriteString("\n")
}
// Timeline — the only history the server keeps. // Timeline — the only history the server keeps.
notes := noteEvents(timeline) notes := noteEvents(timeline)
b.WriteString(divider(s, fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width)) b.WriteString(divider(s, fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width))
@@ -519,7 +653,7 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
noteIndex := 0 noteIndex := 0
for _, e := range timeline { for _, e := range timeline {
when := s.Muted.Render(humanAgo(now, e.CreatedAt)) when := s.Muted.Render(humanAgo(now, e.CreatedAt))
if e.Type != api.EventNote { if e.Type != api.EventNote && e.Type != api.EventResolutionNote {
b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when)) b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when))
continue continue
} }
@@ -529,7 +663,11 @@ func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.Incid
marker = s.Selected.Render("> ") marker = s.Selected.Render("> ")
author = s.Selected.Render(e.Username) author = s.Selected.Render(e.Username)
} }
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when)) verb := " wrote"
if e.Type == api.EventResolutionNote {
verb = " noted the fix"
}
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+verb, when))
b.WriteString(" " + e.Detail + "\n") b.WriteString(" " + e.Detail + "\n")
noteIndex++ noteIndex++
} }
@@ -603,6 +741,8 @@ func eventLabel(e api.IncidentEvent) string {
// 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)
case api.EventDeadmanSilent:
return " Dead man's switch went silent"
default: default:
label := " " + e.Type label := " " + e.Type
if e.Detail != "" { if e.Detail != "" {
+25 -8
View File
@@ -57,7 +57,7 @@ func TestIncidentDetail_RendersTheWholeStory(t *testing.T) {
{Type: api.EventNote, Username: "admin", Detail: "draining node-2", CreatedAt: now}, {Type: api.EventNote, Username: "admin", Detail: "draining node-2", CreatedAt: now},
} }
out := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 110) out := buildIncidentDetailContent(testStyles(), inc, timeline, nil, -1, 110)
mustContain(t, out, mustContain(t, out,
"DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL", "DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL",
"Assigned:", "admin", "Assigned:", "admin",
@@ -77,7 +77,7 @@ func TestIncidentDetail_ShowsSnooze(t *testing.T) {
} }
// The exact remaining time is humanUntil's business, not this test's — a few // The exact remaining time is humanUntil's business, not this test's — a few
// microseconds of elapsed clock turn "in 2h" into "in 1h 59m". // microseconds of elapsed clock turn "in 2h" into "in 1h 59m".
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110),
"TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h") "TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h")
} }
@@ -88,7 +88,7 @@ func TestIncidentDetail_HidesExpiredSnooze(t *testing.T) {
Title: "Noisy", Status: api.StatusTriggered, Title: "Noisy", Status: api.StatusTriggered,
TriggeredAt: time.Now(), SnoozedUntil: &past, TriggeredAt: time.Now(), SnoozedUntil: &past,
} }
if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, -1, 110)), "Snoozed:") { if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110)), "Snoozed:") {
t.Error("an expired snooze should not be rendered") t.Error("an expired snooze should not be rendered")
} }
} }
@@ -100,18 +100,18 @@ func TestIncidentDetail_ShowsResolutionSource(t *testing.T) {
Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour), Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour),
ResolvedAt: &now, ResolutionSource: &source, ResolvedAt: &now, ResolutionSource: &source,
} }
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110),
"RESOLVED", "Resolved:", "manual") "RESOLVED", "Resolved:", "manual")
} }
func TestIncidentDetail_UnassignedAndUnacknowledged(t *testing.T) { func TestIncidentDetail_UnassignedAndUnacknowledged(t *testing.T) {
inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()} inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()}
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "nobody", "not acknowledged") mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110), "nobody", "not acknowledged")
} }
func TestIncidentDetail_EmptyTimeline(t *testing.T) { func TestIncidentDetail_EmptyTimeline(t *testing.T) {
inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()} inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()}
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "Nothing recorded yet") mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110), "Nothing recorded yet")
} }
func TestIncidentDetail_MarksSelectedNote(t *testing.T) { func TestIncidentDetail_MarksSelectedNote(t *testing.T) {
@@ -122,7 +122,7 @@ func TestIncidentDetail_MarksSelectedNote(t *testing.T) {
} }
inc := api.Incident{Title: "X", Status: api.StatusTriggered, TriggeredAt: now} inc := api.Incident{Title: "X", Status: api.StatusTriggered, TriggeredAt: now}
out := plain(buildIncidentDetailContent(testStyles(), inc, timeline, 1, 110)) out := plain(buildIncidentDetailContent(testStyles(), inc, timeline, nil, 1, 110))
for _, line := range strings.Split(out, "\n") { for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "alice") && !strings.HasPrefix(line, "> ") { if strings.Contains(line, "alice") && !strings.HasPrefix(line, "> ") {
t.Errorf("expected the selected note marked, got %q", line) t.Errorf("expected the selected note marked, got %q", line)
@@ -212,7 +212,7 @@ func TestIncidentDetail_RendersNotifications(t *testing.T) {
Detail: "reminder: ntfy returned 502", CreatedAt: now}, Detail: "reminder: ntfy returned 502", CreatedAt: now},
} }
got := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 120) got := buildIncidentDetailContent(testStyles(), inc, timeline, nil, -1, 120)
mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed") mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed")
} }
@@ -349,3 +349,20 @@ func TestView_ZeroWidthRendersNothing(t *testing.T) {
type errFixture struct{} type errFixture struct{}
func (errFixture) Error() string { return "connection refused" } func (errFixture) Error() string { return "connection refused" }
func TestIncidentDetail_ShowsSimilarWithResolutionNotes(t *testing.T) {
now := time.Now()
inc := api.Incident{Title: "DiskFull", Status: api.StatusTriggered, TriggeredAt: now}
similar := []api.SimilarIncident{
{ID: 4, Title: "DiskFull (job=node)", ResolvedAt: now.Add(-48 * time.Hour),
ResolutionNotes: []api.IncidentEvent{{Type: api.EventResolutionNote, Detail: "rotated the logs"}}},
{ID: 2, Title: "DiskFull (job=node)", ResolvedAt: now.Add(-96 * time.Hour), NoteCount: 3},
}
out := plain(buildIncidentDetailContent(testStyles(), inc, nil, similar, -1, 110))
mustContain(t, out, "Seen before", "#4", "fixed: rotated the logs", "3 note(s), no resolution note")
// Nothing similar, no section.
if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110)), "Seen before") {
t.Error("expected no Seen before section without similar incidents")
}
}
+13 -2
View File
@@ -7,6 +7,7 @@ import (
"git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/config" "git.ryuvia.com/niklas/terdut-tui/internal/config"
"git.ryuvia.com/niklas/terdut-tui/internal/session"
"git.ryuvia.com/niklas/terdut-tui/internal/theme" "git.ryuvia.com/niklas/terdut-tui/internal/theme"
"git.ryuvia.com/niklas/terdut-tui/internal/tui" "git.ryuvia.com/niklas/terdut-tui/internal/tui"
"git.ryuvia.com/niklas/terdut-tui/internal/updater" "git.ryuvia.com/niklas/terdut-tui/internal/updater"
@@ -45,8 +46,18 @@ func main() {
os.Exit(1) os.Exit(1)
} }
client := api.NewClient(cfg.ServerURL, cfg.APIKey) client := api.NewClient(cfg.ServerURL)
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th) if token := session.Load(cfg.ServerURL); token != "" {
client.SetSession(token)
}
note := ""
if cfg.LegacyAPIKey {
note = "api_key in config.yaml is no longer used: sign in with your username and password"
}
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th).
WithDefaultTeam(cfg.Team).
WithAuth(cfg.Auth).
WithLogin(cfg.Username, note)
p := tea.NewProgram(model, tea.WithAltScreen()) p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {