5 Commits

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

Adds an Identity palette to the theme package (six hues, skipping the ones
that already mean firing/critical), Styles.TeamColor to pick one by team id,
and identity_1..6 as theme-file tokens alongside the existing twelve so a
fully custom theme can still set every token.
2026-09-27 18:14:45 +02:00
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
26 changed files with 1440 additions and 177 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 ./...
+9 -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
+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
+19
View File
@@ -23,6 +23,25 @@ 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+
+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'
+4 -3
View File
@@ -32,8 +32,8 @@ 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 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 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 bar instead of a picker. The picker offers only that team's members, because the
server refuses anybody else. Stats are not team-scoped by the server and always server refuses anybody else. Stats always cover all your teams; the server
cover all your teams. cannot narrow them to one.
Administrators are the only users who can create or delete users, or act on Administrators are the only users who can create or delete users, or act on
someone else's password, topic or API keys. Everyone can manage their own. someone else's password, topic or API keys. Everyone can manage their own.
@@ -131,7 +131,7 @@ accent: "#fabd2f"
A file may shadow a built-in name — `themes/gruvbox-dark.yaml` is how you tweak A file may shadow a built-in name — `themes/gruvbox-dark.yaml` is how you tweak
the default without renaming it. the default without renaming it.
Without `extends`, every token must be set. The twelve are: Without `extends`, every token must be set. The eighteen are:
| Token | Where it shows | | Token | Where it shows |
|---|---| |---|---|
@@ -144,6 +144,7 @@ Without `extends`, every token must be set. The twelve are:
| `resolved` | resolved alerts and incidents, the top-alerts chart | | `resolved` | resolved alerts and incidents, the top-alerts chart |
| `error` | error banners | | `error` | error banners |
| `sev_critical`, `sev_error`, `sev_warning`, `sev_info` | the `severity` label | | `sev_critical`, `sev_error`, `sev_warning`, `sev_info` | the `severity` label |
| `identity_1` .. `identity_6` | a team's colour in the header and the `T` picker — carries no meaning of its own, so pick six colours that just read as distinct from one another (and from `firing`/`sev_critical`, which already mean something) |
Values are hex (`#83a598` or `#abc`) or an ANSI palette index (`0`–`255`) if you Values are hex (`#83a598` or `#abc`) or an ANSI palette index (`0`–`255`) if you
would rather follow your terminal's own colours. Colours are downsampled would rather follow your terminal's own colours. Colours are downsampled
+108 -3
View File
@@ -79,6 +79,98 @@ func (c *Client) Login(username, password string) (string, error) {
return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie) 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. // Logout ends the session on the server and forgets it here.
func (c *Client) Logout() error { func (c *Client) Logout() error {
req, err := c.newRequest(http.MethodPost, "/api/logout") req, err := c.newRequest(http.MethodPost, "/api/logout")
@@ -276,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 {
@@ -357,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
} }
+106 -1
View File
@@ -165,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 },
@@ -582,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")
}
}
+45
View File
@@ -32,6 +32,35 @@ type Alert struct {
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"
@@ -108,6 +137,10 @@ 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. // Written when a team's dead man's switch stops reporting.
EventDeadmanSilent = "deadman_silent" EventDeadmanSilent = "deadman_silent"
@@ -249,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"`
}
+15 -1
View File
@@ -24,6 +24,12 @@ type Config struct {
// Team is the team to start on, by name or id. Empty shows every team the // Team is the team to start on, by name or id. Empty shows every team the
// key's user belongs to. // key's user belongs to.
Team string 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 {
@@ -33,6 +39,7 @@ type rawConfig struct {
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"` Team string `yaml:"team,omitempty"`
Auth string `yaml:"auth,omitempty"`
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -45,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 username: <your-username> # optional, prefills the sign-in form\n theme: gruvbox-dark # optional\n team: Ops # optional, team to start on", 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)
} }
@@ -59,6 +66,12 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("config: 'server_url' is required") return nil, fmt.Errorf("config: 'server_url' 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
if raw.RefreshInterval > 0 { if raw.RefreshInterval > 0 {
interval = time.Duration(raw.RefreshInterval) * time.Second interval = time.Duration(raw.RefreshInterval) * time.Second
@@ -71,5 +84,6 @@ func Load() (*Config, error) {
RefreshInterval: interval, RefreshInterval: interval,
Theme: raw.Theme, Theme: raw.Theme,
Team: raw.Team, Team: raw.Team,
Auth: raw.Auth,
}, nil }, nil
} }
+23
View File
@@ -59,3 +59,26 @@ func TestLoad_NoAPIKeyNeeded(t *testing.T) {
t.Error("expected the leftover api_key to be noted") 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)
}
}
}
+12 -1
View File
@@ -1,6 +1,10 @@
package theme package theme
import "sort" import (
"sort"
"github.com/charmbracelet/lipgloss"
)
// The gruvbox palettes, in the author's original names. Dark uses the bright // The gruvbox palettes, in the author's original names. Dark uses the bright
// variants and light the faded ones, which is what keeps each readable against // variants and light the faded ones, which is what keeps each readable against
@@ -15,6 +19,7 @@ const (
darkBlu = "#83a598" darkBlu = "#83a598"
darkAqua = "#8ec07c" darkAqua = "#8ec07c"
darkOrng = "#fe8019" darkOrng = "#fe8019"
darkPurple = "#d3869b"
lightBg0 = "#fbf1c7" lightBg0 = "#fbf1c7"
lightFg1 = "#3c3836" lightFg1 = "#3c3836"
@@ -25,6 +30,7 @@ const (
lightBlu = "#076678" lightBlu = "#076678"
lightAqua = "#427b58" lightAqua = "#427b58"
lightOrng = "#af3a03" lightOrng = "#af3a03"
lightPurple = "#8f3f71"
) )
// GruvboxDark is the default scheme. It assumes a dark terminal background: // GruvboxDark is the default scheme. It assumes a dark terminal background:
@@ -46,6 +52,9 @@ var GruvboxDark = Theme{
SevError: darkOrng, SevError: darkOrng,
SevWarning: darkYel, SevWarning: darkYel,
SevInfo: darkAqua, SevInfo: darkAqua,
// Red already means an alarm, so it is the one gruvbox hue left out here.
Identity: [6]lipgloss.Color{darkBlu, darkAqua, darkYel, darkGrn, darkOrng, darkPurple},
} }
// GruvboxLight is the same scheme against a light terminal background. // GruvboxLight is the same scheme against a light terminal background.
@@ -66,6 +75,8 @@ var GruvboxLight = Theme{
SevError: lightOrng, SevError: lightOrng,
SevWarning: lightYel, SevWarning: lightYel,
SevInfo: lightAqua, SevInfo: lightAqua,
Identity: [6]lipgloss.Color{lightBlu, lightAqua, lightYel, lightGrn, lightOrng, lightPurple},
} }
// Default is the theme used when the config names none. // Default is the theme used when the config names none.
+13
View File
@@ -32,6 +32,13 @@ type rawTheme struct {
SevError *string `yaml:"sev_error"` SevError *string `yaml:"sev_error"`
SevWarning *string `yaml:"sev_warning"` SevWarning *string `yaml:"sev_warning"`
SevInfo *string `yaml:"sev_info"` SevInfo *string `yaml:"sev_info"`
Identity1 *string `yaml:"identity_1"`
Identity2 *string `yaml:"identity_2"`
Identity3 *string `yaml:"identity_3"`
Identity4 *string `yaml:"identity_4"`
Identity5 *string `yaml:"identity_5"`
Identity6 *string `yaml:"identity_6"`
} }
// binding ties a YAML key to its raw value and the field it fills, so parsing, // binding ties a YAML key to its raw value and the field it fills, so parsing,
@@ -56,6 +63,12 @@ func bindings(r *rawTheme, t *Theme) []binding {
{"sev_error", r.SevError, &t.SevError}, {"sev_error", r.SevError, &t.SevError},
{"sev_warning", r.SevWarning, &t.SevWarning}, {"sev_warning", r.SevWarning, &t.SevWarning},
{"sev_info", r.SevInfo, &t.SevInfo}, {"sev_info", r.SevInfo, &t.SevInfo},
{"identity_1", r.Identity1, &t.Identity[0]},
{"identity_2", r.Identity2, &t.Identity[1]},
{"identity_3", r.Identity3, &t.Identity[2]},
{"identity_4", r.Identity4, &t.Identity[3]},
{"identity_5", r.Identity5, &t.Identity[4]},
{"identity_6", r.Identity6, &t.Identity[5]},
} }
} }
+9
View File
@@ -106,6 +106,12 @@ sev_critical: "#000009"
sev_error: "#00000a" sev_error: "#00000a"
sev_warning: "#00000b" sev_warning: "#00000b"
sev_info: "#00000c" sev_info: "#00000c"
identity_1: "#00000d"
identity_2: "#00000e"
identity_3: "#00000f"
identity_4: "#000010"
identity_5: "#000011"
identity_6: "#000012"
`) `)
got, err := loadFrom(dir, "full") got, err := loadFrom(dir, "full")
@@ -115,6 +121,9 @@ sev_info: "#00000c"
if got.Primary != "#000001" || got.SevInfo != "#00000c" { if got.Primary != "#000001" || got.SevInfo != "#00000c" {
t.Errorf("tokens not applied: %+v", got) t.Errorf("tokens not applied: %+v", got)
} }
if got.Identity[0] != "#00000d" || got.Identity[5] != "#000012" {
t.Errorf("identity tokens not applied: %+v", got.Identity)
}
} }
func TestLoadFrom_Errors(t *testing.T) { func TestLoadFrom_Errors(t *testing.T) {
+4
View File
@@ -29,4 +29,8 @@ type Theme struct {
SevError lipgloss.Color SevError lipgloss.Color
SevWarning lipgloss.Color SevWarning lipgloss.Color
SevInfo lipgloss.Color SevInfo lipgloss.Color
// Identity tells things like teams apart from one another — not a status
// or a severity, so none of the six carries a meaning of its own.
Identity [6]lipgloss.Color
} }
+15 -2
View File
@@ -27,8 +27,21 @@ func TestStartsOnTheFormWithoutASession(t *testing.T) {
if m.mode != modeLogin { if m.mode != modeLogin {
t.Fatalf("expected the sign-in form, got mode %v", m.mode) t.Fatalf("expected the sign-in form, got mode %v", m.mode)
} }
if m.Init() != nil { // Nothing is connected without a session. The one thing started is asking
t.Error("with no session there is nothing to connect with yet") // 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)
} }
} }
+156 -6
View File
@@ -46,6 +46,7 @@ const (
modeSnooze modeSnooze
modeConfirm modeConfirm
modeUserPicker modeUserPicker
modeTeamPicker
modeUserCreate modeUserCreate
modeUserNotifyEdit modeUserNotifyEdit
modeAPIKeyMenu modeAPIKeyMenu
@@ -126,6 +127,48 @@ type connectedMsg struct {
type connectErrMsg struct{ err error } type connectErrMsg struct{ err error }
type loginDoneMsg struct{} type loginDoneMsg struct{}
type loginErrMsg struct{ err error } 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 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 }
@@ -146,6 +189,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 }
@@ -215,6 +259,7 @@ type Model struct {
defaultTeam string // config's `team`, resolved on connect defaultTeam string // config's `team`, resolved on connect
meID int64 meID int64
isAdmin bool isAdmin bool
teamPickerTable table.Model
// Sign-in. Until the server accepts a session the TUI is in modeLogin; // Sign-in. Until the server accepts a session the TUI is in modeLogin;
// loginNote is a line the form shows above the fields (why we are here), and // loginNote is a line the form shows above the fields (why we are here), and
@@ -227,6 +272,12 @@ type Model struct {
loginNote string loginNote string
ticking bool 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
@@ -253,7 +304,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
@@ -345,6 +398,9 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap())) pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
pickerT.SetStyles(ts) pickerT.SetStyles(ts)
teamPickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
teamPickerT.SetStyles(ts)
manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k", "p"))) manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k", "p")))
manageT.SetStyles(ts) manageT.SetStyles(ts)
@@ -448,6 +504,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
scheduleWindow: window, scheduleWindow: window,
scheduleTable: schedT, scheduleTable: schedT,
userPickerTable: pickerT, userPickerTable: pickerT,
teamPickerTable: teamPickerT,
userManageTable: manageT, userManageTable: manageT,
userFormInputs: [2]textinput.Model{usernameIn, emailIn}, userFormInputs: [2]textinput.Model{usernameIn, emailIn},
ntfyTopicInput: topicIn, ntfyTopicInput: topicIn,
@@ -468,6 +525,13 @@ func (m Model) WithDefaultTeam(team string) Model {
return m 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. // WithLogin prefills the sign-in form's username and sets a note shown above it.
func (m Model) WithLogin(username, note string) Model { func (m Model) WithLogin(username, note string) Model {
m.loginInputs[loginUsername].SetValue(username) m.loginInputs[loginUsername].SetValue(username)
@@ -485,7 +549,10 @@ func (m Model) WithLogin(username, note string) Model {
// already showing and there is nothing to do until it is submitted. // 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 { if m.mode == modeLogin {
return nil // 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)
} }
@@ -583,6 +650,20 @@ func (m *Model) rebuildUserPickerTable() {
m.userPickerTable.SetHeight(tableHeight(m.height, 10)) m.userPickerTable.SetHeight(tableHeight(m.height, 10))
} }
// rebuildTeamPickerTable lists "All teams" first, then each of the caller's
// teams in the same order switchTeam / selectTeam use, so a row's position
// always matches its place in m.teams.
func (m *Model) rebuildTeamPickerTable() {
m.teamPickerTable.SetColumns(teamPickerColumns(m.width))
rows := make([]table.Row, 0, len(m.teams)+1)
rows = append(rows, table.Row{m.styles.Muted.Render("●"), "All teams", ""})
for _, t := range m.teams {
rows = append(rows, table.Row{m.styles.TeamColor(t.ID).Render("●"), t.Name, t.Role})
}
setRows(&m.teamPickerTable, rows)
m.teamPickerTable.SetHeight(tableHeight(m.height, 10))
}
func (m *Model) rebuildUserManageTable() { func (m *Model) rebuildUserManageTable() {
m.userManageTable.SetColumns(userManageColumns(m.width)) m.userManageTable.SetColumns(userManageColumns(m.width))
rows := make([]table.Row, len(m.users)) rows := make([]table.Row, len(m.users))
@@ -614,7 +695,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() {
@@ -746,7 +827,7 @@ func userFlags(u api.User) string {
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)
} }
} }
@@ -842,6 +923,20 @@ func userPickerColumns(width int) []table.Column {
} }
} }
func teamPickerColumns(width int) []table.Column {
dotW := 3
roleW := 10
nameW := width - dotW - roleW - 8
if nameW < 15 {
nameW = 15
}
return []table.Column{
{Title: "", Width: dotW},
{Title: "Team", Width: nameW},
{Title: "Role", Width: roleW},
}
}
func userManageColumns(width int) []table.Column { func userManageColumns(width int) []table.Column {
createdW := 12 createdW := 12
usernameW := 25 usernameW := 25
@@ -1050,6 +1145,58 @@ func loginCmd(client *api.Client, serverURL, username, password string) tea.Cmd
} }
} }
// 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 // 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 // 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. // be signed out and a token left on disk would say otherwise.
@@ -1131,7 +1278,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 {
@@ -1185,9 +1335,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
}) })
} }
+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)
}
}
+11
View File
@@ -125,6 +125,17 @@ func (s Styles) IncidentStatus(status string) lipgloss.Style {
} }
} }
// TeamColor picks a stable identity colour for a team id, so the same team
// always reads the same colour without the server needing to store one.
func (s Styles) TeamColor(teamID int64) lipgloss.Style {
n := int64(len(s.theme.Identity))
i := teamID % n
if i < 0 { // ids are never negative in practice, but % can still return one
i += n
}
return lipgloss.NewStyle().Foreground(s.theme.Identity[i])
}
// ── Embedded bubbles components ──────────────────────────────────────────── // ── Embedded bubbles components ────────────────────────────────────────────
// //
// Each ships its own hardcoded palette, so a theme that stopped at this // Each ships its own hardcoded palette, so a theme that stopped at this
+30
View File
@@ -66,6 +66,36 @@ func TestStyles_EachBuiltinIsFullyPopulated(t *testing.T) {
t.Errorf("%s: %s has no foreground", th.Name, what) t.Errorf("%s: %s has no foreground", th.Name, what)
} }
} }
for i := range th.Identity {
if s.TeamColor(int64(i)).GetForeground() == (lipgloss.NoColor{}) {
t.Errorf("%s: identity %d has no foreground", th.Name, i)
}
}
}
}
// TestStyles_TeamColorIsStableAndDistinct checks the property the header badge
// and the team picker both rely on: the same id always reads the same colour,
// and ids that differ (up to the size of the palette) read as different
// colours rather than all collapsing to one.
func TestStyles_TeamColorIsStableAndDistinct(t *testing.T) {
s := newStyles(theme.GruvboxDark)
seen := make(map[lipgloss.Color]bool)
for id := int64(0); id < 6; id++ {
seen[s.TeamColor(id).GetForeground().(lipgloss.Color)] = true
}
if len(seen) != 6 {
t.Errorf("expected 6 distinct colours across 6 ids, got %d", len(seen))
}
// ids repeat past the palette size, and a negative id (never sent by the
// server, but cheap to guard) must not panic.
if s.TeamColor(6).GetForeground() != s.TeamColor(0).GetForeground() {
t.Error("the palette should wrap rather than index out of range")
}
if got := s.TeamColor(-1).GetForeground(); got == (lipgloss.NoColor{}) {
t.Error("a negative id should still resolve to a colour, not panic or fall back to none")
} }
} }
+211 -23
View File
@@ -7,6 +7,7 @@ import (
"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"
@@ -20,7 +21,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// was being done cannot succeed, so go back to the sign-in form and say why, // 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". // rather than leaving every action to fail with "server returned 401".
if err := msgError(msg); api.IsUnauthorized(err) && m.mode != modeLogin { if err := msgError(msg); api.IsUnauthorized(err) && m.mode != modeLogin {
return m.requireLogin("your session has ended — sign in again"), forgetSessionCmd() 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) {
@@ -32,6 +34,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.rebuildArchivedTable() m.rebuildArchivedTable()
m.rebuildScheduleTable() m.rebuildScheduleTable()
m.rebuildUserPickerTable() m.rebuildUserPickerTable()
m.rebuildTeamPickerTable()
m.rebuildUserManageTable() m.rebuildUserManageTable()
m.detailViewport.Width = m.width m.detailViewport.Width = m.width
m.detailViewport.Height = m.detailViewportHeight() m.detailViewport.Height = m.detailViewportHeight()
@@ -45,6 +48,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case loginDoneMsg: case loginDoneMsg:
m.loggingIn = false m.loggingIn = false
m.sso = ssoLogin{attempt: m.sso.attempt + 1}
m.loginErr = "" m.loginErr = ""
m.loginNote = "" m.loginNote = ""
m.loginInputs[loginPassword].Reset() m.loginInputs[loginPassword].Reset()
@@ -61,7 +65,57 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case logoutDoneMsg: case logoutDoneMsg:
return m.requireLogin("you have signed out"), nil // 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 firstConnect := len(m.teams) == 0
@@ -145,6 +199,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
@@ -346,6 +401,12 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m2, ourCmd := m.handleKey(msg) m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd) return m2, tea.Batch(tableCmd, ourCmd)
case modeTeamPicker:
var tableCmd tea.Cmd
m.teamPickerTable, tableCmd = m.teamPickerTable.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
case modeUserCreate: case modeUserCreate:
var inputCmd tea.Cmd var inputCmd tea.Cmd
m.userFormInputs[m.userFormFocus], inputCmd = m.userFormInputs[m.userFormFocus].Update(msg) m.userFormInputs[m.userFormFocus], inputCmd = m.userFormInputs[m.userFormFocus].Update(msg)
@@ -375,7 +436,7 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case modeLogin: case modeLogin:
var inputCmd tea.Cmd var inputCmd tea.Cmd
if !m.loggingIn { if !m.loggingIn && !m.sso.active && m.offersPasswords() {
m.loginInputs[m.loginFocus], inputCmd = m.loginInputs[m.loginFocus].Update(msg) m.loginInputs[m.loginFocus], inputCmd = m.loginInputs[m.loginFocus].Update(msg)
} }
m2, ourCmd := m.handleKey(msg) m2, ourCmd := m.handleKey(msg)
@@ -442,6 +503,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m.handleConfirmKey(msg) return m.handleConfirmKey(msg)
case modeUserPicker: case modeUserPicker:
return m.handleUserPickerKey(msg) return m.handleUserPickerKey(msg)
case modeTeamPicker:
return m.handleTeamPickerKey(msg)
case modeUserCreate: case modeUserCreate:
return m.handleUserCreateKey(msg) return m.handleUserCreateKey(msg)
case modeUserNotifyEdit: case modeUserNotifyEdit:
@@ -512,7 +575,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if !m.connected || len(m.teams) == 0 { if !m.connected || len(m.teams) == 0 {
return m, nil return m, nil
} }
return m.switchTeam() return m.openTeamPicker()
case "enter": case "enter":
switch m.activeSection { switch m.activeSection {
@@ -793,22 +856,21 @@ func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
return m, nil return m, nil
} }
// switchTeam steps the active team through all teams, then each of the caller's // openTeamPicker opens the full list of the caller's teams, plus "All teams",
// teams in turn, and reloads what depends on it. Sections that are not on screen // for the "T" key to choose among rather than blindly cycling through them.
// are emptied rather than fetched, so they load when next opened; the incident // Team data is already loaded at connect time, so no fetch is needed.
// queue is the exception because it is what the caller returns to. func (m Model) openTeamPicker() (Model, tea.Cmd) {
func (m Model) switchTeam() (Model, tea.Cmd) { m.mode = modeTeamPicker
next := int64(0) m.rebuildTeamPickerTable()
if m.activeTeamID == 0 { return m, nil
next = m.teams[0].ID
} else {
for i, t := range m.teams {
if t.ID == m.activeTeamID && i+1 < len(m.teams) {
next = m.teams[i+1].ID
} }
}
} // selectTeam sets the active team to teamID (0 meaning all teams) and reloads
m.activeTeamID = next // what depends on it. Sections that are not on screen are emptied rather than
// fetched, so they load when next opened; the incident queue is the exception
// because it is what the caller returns to.
func (m Model) selectTeam(teamID int64) (Model, tea.Cmd) {
m.activeTeamID = teamID
m.incidents, m.alerts, m.archivedIncidents = nil, nil, nil m.incidents, m.alerts, m.archivedIncidents = nil, nil, nil
m.scheduleEntries, m.scheduleDays, m.currentOnCall = nil, nil, nil m.scheduleEntries, m.scheduleDays, m.currentOnCall = nil, nil, nil
@@ -938,8 +1000,9 @@ func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeDashboard m.mode = modeDashboard
return m, archiveIncidentCmd(m.client, inc.ID, m.activeTeamID, 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()
@@ -1032,7 +1095,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
@@ -1198,6 +1261,34 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
// ── Team picker ───────────────────────────────────────────────────────────
// handleTeamPickerKey reads the cursor's team off the table built by
// rebuildTeamPickerTable, where row 0 is always "All teams" and row i (i>=1)
// is m.teams[i-1].
func (m Model) handleTeamPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.mode = modeDashboard
return m, nil
case "enter":
cursor := m.teamPickerTable.Cursor()
id := int64(0)
if cursor > 0 {
i := cursor - 1
if i < 0 || i >= len(m.teams) {
return m, nil
}
id = m.teams[i].ID
}
m, cmd := m.selectTeam(id)
m.mode = modeDashboard
return m, cmd
}
return m, nil
}
// scheduleConflicts reports which of dates are already held by somebody other // scheduleConflicts reports which of dates are already held by somebody other
// than newUserID, and the distinct names holding them. // than newUserID, and the distinct names holding them.
// //
@@ -1483,6 +1574,7 @@ func (m Model) requireLogin(note string) Model {
fresh := NewModel(m.client, m.serverURL, m.refreshInterval, m.theme) fresh := NewModel(m.client, m.serverURL, m.refreshInterval, m.theme)
fresh.width, fresh.height = m.width, m.height fresh.width, fresh.height = m.width, m.height
fresh.defaultTeam = m.defaultTeam fresh.defaultTeam = m.defaultTeam
fresh.authInfo, fresh.authPref = m.authInfo, m.authPref
fresh.ticking = m.ticking fresh.ticking = m.ticking
fresh.mode = modeLogin fresh.mode = modeLogin
fresh.loginInputs[loginUsername].SetValue(name) fresh.loginInputs[loginUsername].SetValue(name)
@@ -1528,12 +1620,108 @@ func loginErrorText(err error) string {
return err.Error() 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) { func (m Model) handleLoginKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "ctrl+c", "esc": 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 return m, tea.Quit
} }
if m.loggingIn { 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 return m, nil
} }
+59 -19
View File
@@ -630,7 +630,7 @@ func TestAlertDetail_JumpToIncident(t *testing.T) {
// A refresh underneath a prompt would move the ground under the user. // A refresh underneath a prompt would move the ground under the user.
func TestRefreshTick_SkipsModalStates(t *testing.T) { func TestRefreshTick_SkipsModalStates(t *testing.T) {
modal := []mode{modeNote, modeSnooze, modeConfirm, modeUserPicker, modeUserCreate} modal := []mode{modeNote, modeSnooze, modeConfirm, modeUserPicker, modeTeamPicker, modeUserCreate}
for _, md := range modal { for _, md := range modal {
m := sized() m := sized()
m.mode = md m.mode = md
@@ -782,32 +782,38 @@ func TestConnected_DefaultTeamFromConfig(t *testing.T) {
} }
} }
func TestSwitchTeam_CyclesAllThenEachTeam(t *testing.T) { func TestTeamPicker_Opens(t *testing.T) {
m := sized() m := sized()
m.teams = twoTeams() m.teams = twoTeams()
var seen []int64 m, cmd := press(t, m, "T")
for i := 0; i < 4; i++ { if m.mode != modeTeamPicker {
var cmd tea.Cmd t.Fatalf("T should open the team picker, got mode %v", m.mode)
m, cmd = press(t, m, "T")
if cmd == nil {
t.Fatal("switching team should reload")
}
seen = append(seen, m.activeTeamID)
}
want := []int64{1, 2, 0, 1}
for i := range want {
if seen[i] != want[i] {
t.Fatalf("expected the cycle %v, got %v", want, seen)
} }
if cmd != nil {
t.Error("opening the picker should not itself trigger a reload")
} }
} }
func TestSwitchTeam_ClearsRowsFromTheOtherTeam(t *testing.T) { // Row 0 of the picker table is always "All teams"; row i (i>=1) is
// m.teams[i-1] — see rebuildTeamPickerTable.
func TestTeamPicker_SelectTeamClearsRowsFromTheOtherTeam(t *testing.T) {
m := sized() m := sized()
m.teams = twoTeams() m.teams = twoTeams()
m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}} m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}}
m.rebuildIncidentTable() m.rebuildIncidentTable()
m, _ = press(t, m, "T") m, _ = press(t, m, "T")
m.teamPickerTable.SetCursor(1) // twoTeams()[0] is Ops
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("selecting a team should reload")
}
if m.mode != modeDashboard {
t.Fatalf("enter should close the picker, got mode %v", m.mode)
}
if m.activeTeamID != 1 {
t.Fatalf("expected team 1 (Ops) active, got %d", m.activeTeamID)
}
if len(m.incidents) != 0 { if len(m.incidents) != 0 {
t.Errorf("the previous team's incidents must not linger, got %d", len(m.incidents)) t.Errorf("the previous team's incidents must not linger, got %d", len(m.incidents))
} }
@@ -816,10 +822,44 @@ func TestSwitchTeam_ClearsRowsFromTheOtherTeam(t *testing.T) {
} }
} }
func TestSwitchTeam_NoTeamsDoesNothing(t *testing.T) { func TestTeamPicker_SelectAllTeams(t *testing.T) {
m := sized()
m.teams = twoTeams()
m.activeTeamID = 1
m, _ = press(t, m, "T")
m.teamPickerTable.SetCursor(0) // "All teams"
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("selecting all teams should reload")
}
if m.activeTeamID != 0 {
t.Fatalf("expected all teams (0), got %d", m.activeTeamID)
}
}
func TestTeamPicker_EscCancelsWithoutChangingTeam(t *testing.T) {
m := sized()
m.teams = twoTeams()
m.activeTeamID = 1
m, _ = press(t, m, "T")
m, cmd := press(t, m, "esc")
if cmd != nil {
t.Error("cancelling should not reload")
}
if m.mode != modeDashboard {
t.Fatalf("esc should close the picker, got mode %v", m.mode)
}
if m.activeTeamID != 1 {
t.Fatalf("cancelling must not change the active team, got %d", m.activeTeamID)
}
}
func TestTeamPicker_NoTeamsDoesNothing(t *testing.T) {
m, cmd := press(t, sized(), "T") m, cmd := press(t, sized(), "T")
if cmd != nil || m.activeTeamID != 0 { if cmd != nil || m.mode == modeTeamPicker {
t.Errorf("without teams T has nothing to switch to") t.Errorf("without teams T has nothing to open")
} }
} }
+90 -7
View File
@@ -28,7 +28,11 @@ func (m Model) View() string {
func (m Model) renderHeader() string { func (m Model) renderHeader() string {
title := m.styles.Header.Render("terdut-tui") title := m.styles.Header.Render("terdut-tui")
if len(m.teams) > 0 { if len(m.teams) > 0 {
title += m.styles.Muted.Render(" team: " + m.activeTeamLabel()) dot := m.styles.Muted.Render("●")
if t, ok := m.activeTeam(); ok {
dot = m.styles.TeamColor(t.ID).Render("●")
}
title += " " + dot + m.styles.Muted.Render(" team: "+m.activeTeamLabel())
} }
right := m.styles.Muted.Render(m.serverURL) right := m.styles.Muted.Render(m.serverURL)
return spread(title, right, m.width) return spread(title, right, m.width)
@@ -42,17 +46,62 @@ func (m Model) renderLogin() string {
if m.loginNote != "" { if m.loginNote != "" {
b.WriteString(m.styles.Status.Render(" "+m.loginNote) + "\n\n") 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("Username: ") + m.loginInputs[loginUsername].View() + "\n")
b.WriteString(" " + m.styles.Header.Render("Password: ") + m.loginInputs[loginPassword].View() + "\n\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 { switch {
case m.loggingIn: case m.loggingIn:
b.WriteString(m.styles.Muted.Render(" Signing in…") + "\n") b.WriteString(m.styles.Muted.Render(" Signing in…") + "\n")
case m.loginErr != "": case m.loginErr != "":
b.WriteString(m.styles.Error.Render(" "+m.loginErr) + "\n") 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() 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. // activeTeamLabel names what the lists are narrowed to.
func (m Model) activeTeamLabel() string { func (m Model) activeTeamLabel() string {
if t, ok := m.activeTeam(); ok { if t, ok := m.activeTeam(); ok {
@@ -90,7 +139,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:
@@ -104,6 +157,8 @@ func (m Model) renderBody() string {
} }
case modeUserPicker: case modeUserPicker:
return m.renderUserPicker() return m.renderUserPicker()
case modeTeamPicker:
return m.renderTeamPicker()
case modeUserCreate: case modeUserCreate:
return m.renderUserCreate() return m.renderUserCreate()
case modeUserNotifyEdit: case modeUserNotifyEdit:
@@ -135,7 +190,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")
@@ -161,6 +216,9 @@ func (m Model) renderFooter() string {
} }
return withStatus(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", scope)) return withStatus(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", scope))
case modeTeamPicker:
return withStatus(" j/k·navigate enter·select esc·cancel")
case modeUserCreate: case modeUserCreate:
return withStatus(" tab·next field enter·create esc·cancel") return withStatus(" tab·next field enter·create esc·cancel")
@@ -183,7 +241,7 @@ func (m Model) renderFooter() string {
return withStatus(" tab·next field enter·set password esc·cancel") return withStatus(" tab·next field enter·set password esc·cancel")
case modeLogin: case modeLogin:
return "\n" + m.styles.Footer.Render(" tab·next field enter·sign in esc·quit") return "\n" + m.styles.Footer.Render(m.loginHelp())
default: default:
switch m.activeSection { switch m.activeSection {
@@ -447,6 +505,11 @@ func (m Model) renderUserPicker() string {
return header + m.userPickerTable.View() return header + m.userPickerTable.View()
} }
func (m Model) renderTeamPicker() string {
header := "\n " + m.styles.Bold.Render("Select a team:") + "\n\n"
return header + m.teamPickerTable.View()
}
// ── Detail ───────────────────────────────────────────────────────────────── // ── Detail ─────────────────────────────────────────────────────────────────
func (m Model) renderDetail() string { func (m Model) renderDetail() string {
@@ -485,7 +548,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
@@ -579,6 +642,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))
@@ -588,7 +667,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
} }
@@ -598,7 +677,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++
} }
+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")
}
}
+1
View File
@@ -56,6 +56,7 @@ func main() {
} }
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th). model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th).
WithDefaultTeam(cfg.Team). WithDefaultTeam(cfg.Team).
WithAuth(cfg.Auth).
WithLogin(cfg.Username, note) WithLogin(cfg.Username, note)
p := tea.NewProgram(model, tea.WithAltScreen()) p := tea.NewProgram(model, tea.WithAltScreen())