8 Commits

Author SHA1 Message Date
Niklas Ye 27008086b0 Follow terdut-server into teams: switch team, per-team schedule
CI / test (push) Successful in 12s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 14s
terdut-server v0.12 made everything team-scoped and v0.20 is what this
client now targets. Against it the old client was wrong in three ways:
the schedule moved to /api/teams/{id}/schedule, GET /api/schedule/current
became a list with one entry per team, and users, incidents, alerts and
schedule entries all grew fields the client ignored.

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

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

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

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

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

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

Breaking: requires terdut-server v0.20.0 or later. Use terdut-tui v0.9.x
with servers before v0.12.
2026-09-23 21:57:28 +02:00
Niklas Ye e0c5a5cba3 Set a user's web UI password from the Users section
CI / test (push) Successful in 21s
Release / test (push) Successful in 3s
Release / binaries (push) Successful in 24s
terdut-server v0.10.2 serves a web UI you sign in to with a password,
and every user starts without one. Until now the only way to give
somebody their first password was a curl call with an API key. p in
Users sets the selected user's password.

The form asks for the current password only in the one case the server
checks it: you are changing your own password and already have one. The
client has no other way to know who its key belongs to, so opening the
form calls GET /api/me first and shows the fields once that answers.
Setting someone else's password sends no current_password at all,
rather than an empty one.

Length (at least 10) and the repeated entry are checked before anything
is sent, mirroring the server's rule so a typo costs no round trip. The
server stays authoritative: a wrong current password comes back as its
own 403 message on the dashboard. The status line says the user's other
web sessions were signed out, because the server does that on every
password change. API keys are not affected.

Older servers have no /api/me. The client now returns a typed
StatusError carrying the status code, so a 404 there reads as "needs
terdut-server v0.10.2 or later" rather than a bare "server returned
404". Its Error() text is unchanged, so every existing message reads as
before.

Requires terdut-server v0.10.2 only for this form. Everything else works
against the same servers as before.
2026-09-19 21:09:56 +02:00
Niklas Ye 0006424eaf Act on the selected row, not the one the table moved to
In Users, pressing k on a user opened API keys for the user above it.
The dashboard hands every key to the section's table before the
section's own handler reads the cursor, and bubbles' table claims
several letters for navigation: k is up, d half a page down, f a page
down. A letter that is also an action therefore moved the cursor first,
and the action landed on the row it had moved to. With your own user
first in the list, k looked like it only ever showed your own keys.

The same collision hit two destructive keys:

- d in Users asked to delete a user half a page below the selected one.
  The confirmation names the user, and that was the only thing standing
  between a keypress and deleting the wrong person.
- d in Schedule targeted a different day's assignment the same way.

f in Incidents and Alerts cycled the filter and paged the cursor down
too, which was harmless but wrong.

Each table now gives up exactly the letters its section acts on,
through tableKeyMap. The arrow keys and every other default binding are
untouched. The cost is that k no longer moves up in Users, where it
means API keys, as the README has always said. The up arrow still
works, and the README now says to use it there.

users_test.go reproduces all four. With tableKeyMap reverted to the
defaults, each of them fails exactly as reported.
2026-09-19 21:09:06 +02:00
niklas 9a510ecc77 Merge pull request 'Colour themes, defaulting to gruvbox dark' (#1) from color-themes into main
CI / test (push) Successful in 2s
Release / test (push) Successful in 2s
Release / binaries (push) Successful in 9s
Reviewed-on: #1
2026-08-20 09:11:13 +00:00
Niklas Ye 4a579bdbc6 Colour themes, defaulting to gruvbox dark
CI / test (pull_request) Successful in 4s
Every colour was a 256-colour ANSI index hardcoded in styles.go, so changing
the palette meant editing the styles themselves. This puts a semantic token set
between the two: styles name roles, a theme supplies the colours.

internal/theme holds the twelve tokens, the two built-ins (gruvbox-dark, the
new default, and gruvbox-light) and the loader for user themes in
~/.config/terdut-tui/themes/. A user file may 'extends:' a built-in and
override only what it cares about, and may shadow a built-in name to tweak it
in place. Unknown keys, malformed colours and incomplete themes are refused
with a message naming what went wrong.

Colours are truecolor hex now: lipgloss downsamples for 256- and 16-colour
terminals and honours NO_COLOR, so themes carry no fallbacks of their own.
An ANSI index is still accepted for anyone who would rather follow their
terminal's own palette.

The 21 package-level style vars become a Styles struct on the Model, which is
what rule 3 asked for all along; the four free functions in view.go take one as
their first argument. The embedded bubbles components are restyled from the
same tokens — otherwise a theme would leave a pink selected row and grey help
text behind. Note that the table's Cell style deliberately keeps no foreground:
bubbles renders cells before wrapping the row in Selected, so a colour there
cuts the selection highlight short.
2026-08-20 11:06:36 +02:00
Niklas Ye dc53d49c3e ci: fail on code that is not gofmt'd
CI / test (push) Successful in 2s
go vet says nothing about import order, so when the move to git.ryuvia.com
rewrote every import path without re-sorting them -- the new path sorts before
github.com/..., where the old one sorted after -- both repos went through a
green CI run and a release unformatted.

Added to the release workflow as well as CI, so the two keep running the same
checks; ci.yaml's header claims exactly that, and a check in one but not the
other would quietly make it false.

The step handles gofmt's two failure modes separately because they do not look
alike: a misformatted file is listed on stdout with exit 0, so the failure has
to be raised by hand, while a file that does not parse prints nothing to stdout
and exits 2 -- which a plain emptiness test reads as success. Verified against
all three cases (clean, misformatted, unparseable) before committing.
2026-08-19 21:29:36 +02:00
Niklas Ye d6c0f7508c Stop a table cursor from getting stuck at -1
CI / test (push) Successful in 4s
Release / test (push) Successful in 2s
Release / binaries (push) Successful in 9s
Assigning an on-call week panicked with "index out of range [-1]" on an
ordinary schedule. The index came from scheduleTable.Cursor().

The cursor is not ours. bubbles' SetRows clamps it down when rows shrink
(`if m.cursor > len(rows)-1`) but never back up, so setting zero rows drives it
to -1 and filling the table afterwards leaves it there -- -1 is not greater
than len-1, so nothing corrects it. Every table in this package is rebuilt from
empty exactly once, when the first WindowSizeMsg arrives before any fetch has
returned, so every cursor started at -1 and stayed there until the user pressed
up or down. Pressing a direction key first is why this was survivable at all.

setRows restores the invariant the rest of the package already assumes: a table
with rows has a usable cursor. Every rebuild goes through it.

Five call sites also bounds-checked only the top of the range, and are now
consistent with their siblings, which already had `i < 0 ||`. They were the
same latent panic: deleting a schedule entry, deleting a user, editing a topic,
opening the API key menu, and the week assignment that actually fired.

The regression test deliberately never calls SetCursor. That is what the
existing schedule tests do, and SetCursor clamps, which is exactly how this got
past them. It drives the real order instead: size, then data, then keys.

Also carries a gofmt pass, which is why untouched files appear in the diff.
The move to git.ryuvia.com rewrote import paths without re-sorting them, and
the new path sorts before github.com/charmbracelet/..., where the old one
sorted after. go vet does not look at import order, so CI had nothing to say.
2026-08-19 21:23:13 +02:00
Niklas Ye 6fdb4bbbf8 Move to Gitea: git.ryuvia.com/niklas/terdut-tui
CI / test (push) Successful in 13s
Release / test (push) Successful in 14s
Release / binaries (push) Successful in 1m37s
The module path, the CI pipeline and the self-updater all named GitHub. They now
name the Gitea instance everything else already runs on.

The workflows are rewritten rather than translated, for the reason recorded in
ci.yaml: Gitea's runner image is ubuntu:22.04, whose nodejs is Node 12, so no JS
action runs there -- actions/checkout@v4 dies with a SyntaxError before doing
anything. Every step is shell and checkout is a plain clone, which this public
repo needs no credential for. upload-artifact/download-artifact are JS actions
too, and there is no artifact store here, so the job that builds the binaries is
the job that publishes them.

internal/updater keeps its release and asset types unchanged: Gitea's release
payload carries the same tag_name, and its attachments the same name and
browser_download_url, so only the URL, the Accept header and one error string
move. The asset naming in release.yaml is load-bearing for that matching.

This does strand already-installed binaries, which still poll api.github.com.
The GitHub repository is left in place and untouched, so they report themselves
up to date rather than erroring; its last release is the bridge, and crossing it
is a one-time manual download.
2026-08-19 20:41:05 +02:00
27 changed files with 2979 additions and 548 deletions
+89
View File
@@ -0,0 +1,89 @@
name: CI
# The release workflow gates a tag, which is late: a broken commit sits green until
# somebody decides to publish. This runs the same checks on the way in.
#
# push is scoped to main so that a branch pushed as part of a pull request is not checked
# twice.
#
# No actions/checkout, deliberately -- same as the terdut-server, letsvisit and charts
# workflows. The runner image is ubuntu:22.04 whose `nodejs` package is Node 12, and
# actions/checkout@v4 is built with ES2022 static initialiser blocks, so it dies with
# `SyntaxError: Unexpected token '{'` before running. Cloning with git directly avoids JS
# actions entirely. This repo is public, so the clone needs no credential at all.
#
# `${{ }}` values are passed through `env:` and referenced as quoted shell variables: a
# ref name is attacker-influenced by anyone who can push a branch or open a PR, and
# expanding one straight into `run:` is a shell-injection vector.
on:
push:
branches: [main]
pull_request:
# A rapid series of pushes only needs the last one checked.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
REPO_URL: https://git.ryuvia.com/niklas/terdut-tui.git
jobs:
test:
runs-on: ubuntu-latest
container:
image: golang:1.26.6-bookworm
# act_runner destroys a job's own volumes when it finishes, so without these every
# run re-downloads the whole module graph. The names must appear in the runner's
# container.valid_volumes allowlist (charts/act-runner in the k8s repo); unlisted
# volumes are dropped silently, so a workflow that looks correct can still be
# running uncached.
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- gobin-cache:/go/bin
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ -n "$HEAD_SHA" ]; then
# A pull_request ref_name is "<n>/merge", which is not a fetchable branch.
git clone "$REPO_URL" .
git checkout -q "$HEAD_SHA"
else
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
fi
# 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 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 ./...
+142
View File
@@ -0,0 +1,142 @@
name: Release
# Checkout, interpolation and caching conventions match ci.yaml -- see the header there
# for why there are no JS actions and why every `${{ }}` goes through `env:`.
#
# There is no upload-artifact/download-artifact equivalent here (both are JS actions, and
# this Gitea has no artifact store wired up), so the job that builds the binaries is also
# the job that publishes them. Nothing is handed between jobs.
#
# 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.
on:
push:
tags:
- 'v*'
workflow_dispatch:
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
env:
REPO_URL: https://git.ryuvia.com/niklas/terdut-tui.git
API: https://git.ryuvia.com/api/v1/repos/niklas/terdut-tui
jobs:
# Gates the build, so a tag that fails here publishes no binaries.
test:
runs-on: ubuntu-latest
container:
image: golang:1.26.6-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- gobin-cache:/go/bin
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
# 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 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:
needs: test
runs-on: ubuntu-latest
container:
image: golang:1.26.6-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- gobin-cache:/go/bin
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
- name: Build every target
env:
REF_NAME: ${{ github.ref_name }}
run: |
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
# failed release must not die on the release that already exists. Assets are
# replaced the same way, so a re-run repairs a partial upload.
- name: Publish the release
env:
REF_NAME: ${{ github.ref_name }}
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
auth="Authorization: token $TOKEN"
body=$(curl -sf -H "$auth" "$API/releases/tags/$REF_NAME" || true)
if [ -z "$body" ]; then
body=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$REF_NAME\",\"name\":\"$REF_NAME\"}" \
"$API/releases")
fi
# The release object serialises `id` first, so the first match is the release's
# own id and not one of the nested author/asset ids.
release_id=$(printf '%s' "$body" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
[ -n "$release_id" ] || { echo "::error::could not determine release id"; exit 1; }
echo "release id $release_id"
for f in dist/*; do
name=$(basename "$f")
# Drop an existing asset of the same name first: Gitea happily stores two
# attachments with one name, and the updater matches by name.
old=$(curl -sf -H "$auth" "$API/releases/$release_id/assets" \
| tr '}' '\n' | grep "\"name\":\"$name\"" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -n "$old" ]; then
curl -sf -X DELETE -H "$auth" "$API/releases/$release_id/assets/$old" || true
fi
echo "uploading $name"
curl -sf -X POST -H "$auth" -F "attachment=@$f" \
"$API/releases/$release_id/assets?name=$name" > /dev/null
done
-32
View File
@@ -1,32 +0,0 @@
name: CI
# The release workflow gates a tag, which is late: a broken commit sits green
# until somebody decides to publish. This runs the same checks on the way in.
#
# push is scoped to main so that a branch pushed as part of a pull request is
# not checked twice.
on:
push:
branches: [main]
pull_request:
# A rapid series of pushes only needs the last one checked.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
-76
View File
@@ -1,76 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
# Gates the build, so a tag that fails here publishes no binaries. The suite
# 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.
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
build:
needs: test
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: |
go build \
-ldflags "-X main.version=${{ github.ref_name }}" \
-o terdut-tui-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }} \
.
- uses: actions/upload-artifact@v4
with:
name: terdut-tui-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
path: terdut-tui-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
merge-multiple: true
- uses: softprops/action-gh-release@v2
with:
files: 'terdut-tui-*'
+23 -4
View File
@@ -1,6 +1,6 @@
# terdut-tui # terdut-tui
TUI client for [terdut-server](https://github.com/terdut-server), a Prometheus Alertmanager receiver and incident manager. Requires server **v0.4.0+**. TUI client for [terdut-server](https://git.ryuvia.com/niklas/terdut-server), a Prometheus Alertmanager receiver and incident manager. Requires server **v0.20.0+** (team-scoped API).
## Domain model ## Domain model
@@ -11,6 +11,14 @@ The server splits alerts from incidents, and this client mirrors it:
assignee, snooze, notes and an append-only timeline. Many alerts to one incident, assignee, snooze, notes and an append-only timeline. Many alerts to one incident,
correlated by Alertmanager's `groupKey`. correlated by Alertmanager's `groupKey`.
The server is multi-team: incidents, alerts and the schedule belong to a team, and
the caller only sees their own teams. `Model.activeTeamID` (0 = all) narrows the
incident and alert lists via `team_id`; the schedule is per team and uses
`Model.scheduleTeam()`. Users have `is_admin`, and the TUI mirrors the server's
permission rules up front (`canEditSchedule`, `canManageUser`, `isAdmin`) so a 403 is
explained before the round trip, not after. The server has no version endpoint;
an old one is recognised by `GET /api/teams` answering 404 (`errServerTooOld`).
All user actions target incidents. Two server behaviours the UI has to respect: All user actions target incidents. Two server behaviours the UI has to respect:
manual resolve is **terminal** (hence the confirmation prompt), and snooze is the manual resolve is **terminal** (hence the confirmation prompt), and snooze is the
non-destructive "not now" alternative. non-destructive "not now" alternative.
@@ -25,16 +33,17 @@ non-destructive "not now" alternative.
## Project layout ## Project layout
``` ```
main.go CLI entry point: flags, config load, health check, start TUI main.go CLI entry point: flags, config load, start TUI
internal/api/client.go REST API client — one method per endpoint internal/api/client.go REST API client — one method per endpoint
internal/config/config.go Config loader (~/.config/terdut-tui/config.yaml) internal/config/config.go Config loader (~/.config/terdut-tui/config.yaml)
internal/theme/ Colour themes: semantic tokens, built-ins, user file loader
internal/tui/ Bubbletea UI internal/tui/ Bubbletea UI
model.go Model struct, mode/section constants, Init(), tea.Cmd constructors model.go Model struct, mode/section constants, Init(), tea.Cmd constructors
update.go Update() — dispatch only, no API calls inline update.go Update() — dispatch only, no API calls inline
view.go View() — pure rendering view.go View() — pure rendering
keys.go keyMap (bubbles/key pattern) keys.go keyMap (bubbles/key pattern)
styles.go All lipgloss styles styles.go Styles struct — every lipgloss style, built from a theme
internal/updater/updater.go Self-update via GitHub Releases internal/updater/updater.go Self-update via Gitea releases
``` ```
## Architecture rules ## Architecture rules
@@ -43,6 +52,9 @@ internal/updater/updater.go Self-update via GitHub Releases
2. **`View()` is pure** — no side effects, no state mutations. 2. **`View()` is pure** — no side effects, no state mutations.
3. **All state in `Model`** — no globals. 3. **All state in `Model`** — no globals.
4. **All styles in `styles.go`** — never use lipgloss inline in `view.go`. 4. **All styles in `styles.go`** — never use lipgloss inline in `view.go`.
Styles live on `Model.styles`, built once by `newStyles(theme.Theme)`; the
handful of free functions in `view.go` take a `Styles` as their first
argument. No colour literal appears outside `internal/theme`.
## Config ## Config
@@ -52,8 +64,14 @@ Location: `~/.config/terdut-tui/config.yaml`
server_url: https://terdut.example.com server_url: https://terdut.example.com
api_key: <64-char hex key> api_key: <64-char hex key>
refresh_interval: 30 # seconds, optional, default 30 refresh_interval: 30 # seconds, optional, default 30
theme: gruvbox-dark # optional, default gruvbox-dark
team: Ops # optional, team name or id to start on, default all
``` ```
Built-in themes are `gruvbox-dark` and `gruvbox-light`; user themes are YAML
files in `~/.config/terdut-tui/themes/`, optionally `extends:`-ing a built-in.
See the README for the token list.
The API key is a one-time secret generated by terdut-server (`POST /api/users/{id}/api-keys`). The API key is a one-time secret generated by terdut-server (`POST /api/users/{id}/api-keys`).
## Running ## Running
@@ -86,6 +104,7 @@ go build -ldflags="-X main.version=v0.1.0" -o terdut-tui .
| 4 | On-call schedule calendar view | | 4 | On-call schedule calendar view |
| 5 | User management and API key lifecycle | | 5 | User management and API key lifecycle |
| 6 | Incidents: queue, timeline, ack/assign/snooze/resolve, MTTA/MTTR | | 6 | Incidents: queue, timeline, ack/assign/snooze/resolve, MTTA/MTTR |
| 7 | Teams: `T` switcher, per-team schedule, admin/disabled markers (server v0.20) |
<!-- graymatter:instructions:begin — managed by `graymatter init`; edits inside this block are overwritten --> <!-- graymatter:instructions:begin — managed by `graymatter init`; edits inside this block are overwritten -->
## Memory (GrayMatter) ## Memory (GrayMatter)
+76 -12
View File
@@ -1,6 +1,6 @@
# terdut-tui # terdut-tui
A terminal user interface for [terdut-server](https://github.com/terdut-server). Communicates with the server over its REST API. A terminal user interface for [terdut-server](https://git.ryuvia.com/niklas/terdut-server). Communicates with the server over its REST API.
Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea). Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea).
@@ -10,12 +10,33 @@ Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea).
- **Incident actions** — acknowledge, assign, snooze, note, resolve and archive - **Incident actions** — acknowledge, assign, snooze, note, resolve and archive
- **Timeline** — the full history of an incident, system events, pages and notes together - **Timeline** — the full history of an incident, system events, pages and notes together
- **Alert feed** — the raw read-only alerts underneath, each linked to its incident - **Alert feed** — the raw read-only alerts underneath, each linked to its incident
- **On-call schedule** — visual calendar of who is on duty, assign and remove entries - **Teams** — switch between your teams, or see all of them at once
- **On-call schedule** — visual calendar of who is on duty in a team, assign and remove entries
- **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day - **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day
- **User management** — add and remove users, manage API keys, set each user's ntfy topic - **User management** — add and remove users, manage API keys, set each user's ntfy topic
> Requires terdut-server **v0.4.0 or later**. Earlier servers have no incidents API; > Requires terdut-server **v0.20.0 or later**. The server became team-scoped in
> use terdut-tui v0.3.x with those. > v0.12 and this client follows it; earlier servers answer 404 for `/api/teams`
> and the TUI says so on start. Use terdut-tui v0.9.x with servers before v0.12.
> Escalation ladders, invites, integrations and the admin settings stay in the
> server's web UI.
## Teams
Everything the server returns is scoped to the teams your key's user belongs
to. The header shows which are on screen, and `T` steps through *all* → each of
your teams in turn. With several teams showing, incident and alert rows carry a
Team column.
The schedule is one team's rota, so the Schedule section shows the active team,
or with *all* showing the first team you own. Only a team's owners, and
administrators, can change its rota; anyone else gets the reason in the status
bar instead of a picker. The picker offers only that team's members, because the
server refuses anybody else. Stats are not team-scoped by the server and always
cover all your teams.
Administrators are the only users who can create or delete users, or act on
someone else's password, topic or API keys. Everyone can manage their own.
## Alerts and incidents ## Alerts and incidents
@@ -49,16 +70,13 @@ Every delivery lands on the incident's timeline: `Notified <user> (triggered)`
when ntfy accepted the page, and `Notification to <user> failed` when it ran out when ntfy accepted the page, and `Notification to <user> failed` when it ran out
of retries. That second one is the one to look for when nobody's phone rang. of retries. That second one is the one to look for when nobody's phone rang.
Editing topics needs terdut-server **v0.6.0 or later**; the timeline entries need
**v0.7.0 or later**. Against an older server the topic column stays empty and
editing one reports the server's 404.
## Installation ## Installation
Download the latest release binary for your platform from the [releases page](https://github.com/yeniklas/terdut-tui/releases), or build from source: Download the latest release binary for your platform from the [releases page](https://git.ryuvia.com/niklas/terdut-tui/releases), or build from source:
```bash ```bash
go install github.com/yeniklas/terdut-tui@latest go install git.ryuvia.com/niklas/terdut-tui@latest
``` ```
## Configuration ## Configuration
@@ -69,10 +87,50 @@ Create `~/.config/terdut-tui/config.yaml`:
server_url: https://terdut.example.com server_url: https://terdut.example.com
api_key: <your-api-key> api_key: <your-api-key>
refresh_interval: 30 # seconds, optional refresh_interval: 30 # seconds, optional
theme: gruvbox-dark # optional, this is the default
team: Ops # optional, a team name or id to start on; default is all
``` ```
The API key is generated in terdut-server. See the server documentation for how to bootstrap a user and issue an API key. The API key is generated in terdut-server. See the server documentation for how to bootstrap a user and issue an API key.
## Themes
Two themes ship with the client: `gruvbox-dark` (the default) and
`gruvbox-light`. Both colour foregrounds only — the terminal supplies the
background — so pick the one that matches the background you already run.
To make your own, drop a file in `~/.config/terdut-tui/themes/` and name it in
`theme:`. `extends` inherits a built-in, so a file only has to list what it
changes:
```yaml
# ~/.config/terdut-tui/themes/mine.yaml
extends: gruvbox-dark
primary: "#d3869b"
accent: "#fabd2f"
```
A file may shadow a built-in name — `themes/gruvbox-dark.yaml` is how you tweak
the default without renaming it.
Without `extends`, every token must be set. The twelve are:
| Token | Where it shows |
|---|---|
| `primary` | header, active tab, selected row, cursors |
| `on_primary` | text drawn *on* `primary` — the active tab and selected row |
| `text` | incident titles and other emphasis |
| `muted` | secondary text, dividers, footer, table headers |
| `accent` | status line, acknowledged incidents, the by-day chart |
| `firing` | firing alerts, triggered incidents, the by-hour chart |
| `resolved` | resolved alerts and incidents, the top-alerts chart |
| `error` | error banners |
| `sev_critical`, `sev_error`, `sev_warning`, `sev_info` | the `severity` label |
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
automatically on 256- and 16-colour terminals, and `NO_COLOR` is honoured.
## Usage ## Usage
``` ```
@@ -94,6 +152,7 @@ Global:
| `esc` | Go back | | `esc` | Go back |
| `r` | Refresh | | `r` | Refresh |
| `f` | Cycle filter | | `f` | Cycle filter |
| `T` | Switch team: all → each of your teams (when you have more than one) |
| `q` | Quit | | `q` | Quit |
The sections, in `tab` order: Incidents · Alerts · Stats · Archived · Schedule · Users. The sections, in `tab` order: Incidents · Alerts · Stats · Archived · Schedule · Users.
@@ -142,9 +201,8 @@ Schedule section:
One person holds a given day. Assigning over days somebody else already has One person holds a given day. Assigning over days somebody else already has
asks first — naming them and how many days are being taken — and moves the whole asks first — naming them and how many days are being taken — and moves the whole
selection at once when you accept, so reassigning a week is one confirmation selection at once when you accept, so reassigning a week is one confirmation
rather than seven deletions. Taking somebody's shift needs terdut-server rather than seven deletions. The header line names the team whose rota this is,
**v0.8.0 or later**; against an older server the assignment is refused with and "On-call today" lists everyone on call across your teams.
`date already assigned`.
Users section: Users section:
@@ -154,3 +212,9 @@ Users section:
| `t` | Edit the user's ntfy topic — submit empty to clear it | | `t` | Edit the user's ntfy topic — submit empty to clear it |
| `d` | Delete a user | | `d` | Delete a user |
| `k` | API keys for the selected user | | `k` | API keys for the selected user |
| `p` | Set the selected user's web UI password — asks for the current one when it is your own |
In Users, `k` and `d` act on the selected row, so move with `↑`/`↓` there rather
than `k`. The Flags column marks administrators and disabled accounts. `n` and `d`
are for administrators; `t`, `k` and `p` work on your own row, or on anyone's if you
are one.
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/yeniklas/terdut-tui module git.ryuvia.com/niklas/terdut-tui
go 1.25.9 go 1.25.9
+98 -41
View File
@@ -37,6 +37,20 @@ func (c *Client) newRequest(method, path string) (*http.Request, error) {
return req, nil return req, nil
} }
// StatusError is a response the server answered with a 4xx or 5xx. Message is
// the server's own {"error": ...} text, empty when the body carried none.
type StatusError struct {
Code int
Message string
}
func (e *StatusError) Error() string {
if e.Message != "" {
return fmt.Sprintf("server returned %d: %s", e.Code, e.Message)
}
return fmt.Sprintf("server returned %d", e.Code)
}
func (c *Client) do(req *http.Request, out any) error { func (c *Client) do(req *http.Request, out any) error {
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -49,10 +63,7 @@ func (c *Client) do(req *http.Request, out any) error {
Error string `json:"error"` Error string `json:"error"`
} }
_ = json.NewDecoder(resp.Body).Decode(&e) _ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error != "" { return &StatusError{Code: resp.StatusCode, Message: e.Error}
return fmt.Errorf("server returned %d: %s", resp.StatusCode, e.Error)
}
return fmt.Errorf("server returned %d", resp.StatusCode)
} }
if out != nil { if out != nil {
@@ -61,14 +72,18 @@ func (c *Client) do(req *http.Request, out any) error {
return nil return nil
} }
// ListAlerts fetches alerts. status may be "firing", "resolved", or "" for all. // ListAlerts fetches alerts. teamID limits them to one team; 0 means every team
// the caller belongs to. status may be "firing", "resolved", or "" for all.
// Set archived=true to fetch only archived alerts; false returns only non-archived. // Set archived=true to fetch only archived alerts; false returns only non-archived.
// //
// Alerts are read-only on the server — there is nothing to acknowledge or // Alerts are read-only on the server — there is nothing to acknowledge or
// archive here. This is the raw feed, useful for checking what Alertmanager is // archive here. This is the raw feed, useful for checking what Alertmanager is
// actually sending; the work queue is ListIncidents. // actually sending; the work queue is ListIncidents.
func (c *Client) ListAlerts(status string, archived bool, limit int) ([]Alert, error) { func (c *Client) ListAlerts(teamID int64, status string, archived bool, limit int) ([]Alert, error) {
q := url.Values{} q := url.Values{}
if teamID > 0 {
q.Set("team_id", strconv.FormatInt(teamID, 10))
}
if status != "" { if status != "" {
q.Set("status", status) q.Set("status", status)
} }
@@ -127,12 +142,16 @@ func (c *Client) GetAlert(id int64) (*Alert, error) {
// ── Incidents ────────────────────────────────────────────────────────────── // ── Incidents ──────────────────────────────────────────────────────────────
// ListIncidents fetches the work queue. status may be "triggered", // ListIncidents fetches the work queue. teamID limits it to one team; 0 means
// every team the caller belongs to. status may be "triggered",
// "acknowledged", "resolved", or "" for the server default of open incidents // "acknowledged", "resolved", or "" for the server default of open incidents
// only. archived and snoozed each switch the list to that set rather than // only. archived and snoozed each switch the list to that set rather than
// adding to it, matching the server's filters. // adding to it, matching the server's filters.
func (c *Client) ListIncidents(status string, archived, snoozed bool, limit int) ([]Incident, error) { func (c *Client) ListIncidents(teamID int64, status string, archived, snoozed bool, limit int) ([]Incident, error) {
q := url.Values{} q := url.Values{}
if teamID > 0 {
q.Set("team_id", strconv.FormatInt(teamID, 10))
}
if status != "" { if status != "" {
q.Set("status", status) q.Set("status", status)
} }
@@ -318,8 +337,37 @@ func (c *Client) GetStatsByDay() ([]DayStat, error) {
return result, c.do(req, &result) return result, c.do(req, &result)
} }
func (c *Client) GetSchedule(from, to string) ([]ScheduleEntry, error) { // ── Teams ──────────────────────────────────────────────────────────────────
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/schedule?from=%s&to=%s", from, to))
// ListTeams returns the teams the caller belongs to, with the caller's role in
// each. Everything else the server returns is scoped to these. A server that
// predates teams (v0.12) answers 404, which is how the TUI spots one.
func (c *Client) ListTeams() ([]Team, error) {
req, err := c.newRequest(http.MethodGet, "/api/teams")
if err != nil {
return nil, err
}
var teams []Team
return teams, c.do(req, &teams)
}
// ListTeamMembers returns who belongs to a team. A schedule can only be given to
// its own members, so this is the assignee list for one.
func (c *Client) ListTeamMembers(teamID int64) ([]TeamMember, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/teams/%d/members", teamID))
if err != nil {
return nil, err
}
var members []TeamMember
return members, c.do(req, &members)
}
// ── Schedule ───────────────────────────────────────────────────────────────
// GetSchedule returns a team's on-call entries between two YYYY-MM-DD dates.
func (c *Client) GetSchedule(teamID int64, from, to string) ([]ScheduleEntry, error) {
q := url.Values{"from": {from}, "to": {to}}
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/teams/%d/schedule?%s", teamID, q.Encode()))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -327,49 +375,30 @@ func (c *Client) GetSchedule(from, to string) ([]ScheduleEntry, error) {
return entries, c.do(req, &entries) return entries, c.do(req, &entries)
} }
// GetCurrentOnCall returns today's on-call entry, or nil if nobody is scheduled. // GetCurrentOnCall returns today's on-call entries, one per team that has
func (c *Client) GetCurrentOnCall() (*ScheduleEntry, error) { // somebody scheduled. It is empty, not an error, when nobody is.
func (c *Client) GetCurrentOnCall() ([]ScheduleEntry, error) {
req, err := c.newRequest(http.MethodGet, "/api/schedule/current") req, err := c.newRequest(http.MethodGet, "/api/schedule/current")
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err := c.httpClient.Do(req) var entries []ScheduleEntry
if err != nil { return entries, c.do(req, &entries)
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode >= 400 {
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error != "" {
return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, e.Error)
}
return nil, fmt.Errorf("server returned %d", resp.StatusCode)
}
var entry ScheduleEntry
if err := json.NewDecoder(resp.Body).Decode(&entry); err != nil {
return nil, err
}
return &entry, nil
} }
// AssignSchedule puts one user on call for the given dates. // AssignSchedule puts one team member on call for the given dates. Only a team
// owner or an administrator may.
// //
// The server holds one person per day and refuses a date somebody already has, // The server holds one person per day and refuses a date somebody already has,
// so replace is what takes a shift off its current holder. It is all-or-nothing // so replace is what takes a shift off its current holder. It is all-or-nothing
// either way: a week of free and taken days moves as a unit, or not at all. // either way: a week of free and taken days moves as a unit, or not at all.
func (c *Client) AssignSchedule(userID int64, dates []string, replace bool) ([]ScheduleEntry, error) { func (c *Client) AssignSchedule(teamID, userID int64, dates []string, replace bool) ([]ScheduleEntry, error) {
body := struct { body := struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Dates []string `json:"dates"` Dates []string `json:"dates"`
Replace bool `json:"replace,omitempty"` Replace bool `json:"replace,omitempty"`
}{UserID: userID, Dates: dates, Replace: replace} }{UserID: userID, Dates: dates, Replace: replace}
req, err := c.newRequestWithBody(http.MethodPost, "/api/schedule", body) req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/teams/%d/schedule", teamID), body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -377,14 +406,16 @@ func (c *Client) AssignSchedule(userID int64, dates []string, replace bool) ([]S
return entries, c.do(req, &entries) return entries, c.do(req, &entries)
} }
func (c *Client) DeleteScheduleEntry(id int64) error { func (c *Client) DeleteScheduleEntry(teamID, id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/schedule/%d", id)) req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/teams/%d/schedule/%d", teamID, id))
if err != nil { if err != nil {
return err return err
} }
return c.do(req, nil) return c.do(req, nil)
} }
// ── Users ──────────────────────────────────────────────────────────────────
func (c *Client) ListUsers() ([]User, error) { func (c *Client) ListUsers() ([]User, error) {
req, err := c.newRequest(http.MethodGet, "/api/users") req, err := c.newRequest(http.MethodGet, "/api/users")
if err != nil { if err != nil {
@@ -452,7 +483,33 @@ func (c *Client) DeleteAPIKey(userID, keyID int64) error {
return c.do(req, nil) return c.do(req, nil)
} }
// HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway). // Me returns the user the API key belongs to, and whether they have a web UI
// password.
func (c *Client) Me() (*Me, error) {
req, err := c.newRequest(http.MethodGet, "/api/me")
if err != nil {
return nil, err
}
var me Me
return &me, c.do(req, &me)
}
// SetPassword sets a user's web UI password. current is only checked by the
// server when a user changes their own existing password; pass "" otherwise.
func (c *Client) SetPassword(userID int64, password, current string) error {
body := struct {
Password string `json:"password"`
CurrentPassword string `json:"current_password,omitempty"`
}{Password: password, CurrentPassword: current}
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/password", userID), body)
if err != nil {
return err
}
return c.do(req, nil)
}
// HealthCheck calls GET /healthz, which is unauthenticated and does no database
// check, so it says the process is up, not that the API key works.
func (c *Client) HealthCheck() error { func (c *Client) HealthCheck() error {
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil) req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
if err != nil { if err != nil {
+149 -17
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -39,7 +40,7 @@ func stub(t *testing.T, status int, response string) (*Client, *call) {
func TestClient_SendsBearerToken(t *testing.T) { func TestClient_SendsBearerToken(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`) c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListIncidents("", false, false, 0); err != nil { if _, err := c.ListIncidents(0, "", false, false, 0); err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
if got.auth != "Bearer test-key" { if got.auth != "Bearer test-key" {
@@ -107,23 +108,26 @@ func TestClient_IncidentEndpoints(t *testing.T) {
func TestListIncidents_Filters(t *testing.T) { func TestListIncidents_Filters(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
teamID int64
status string status string
archived bool archived bool
snoozed bool snoozed bool
limit int limit int
want string want string
}{ }{
{"default is the open queue", "", false, false, 0, ""}, {"default is the open queue", 0, "", false, false, 0, ""},
{"status", "triggered", false, false, 0, "status=triggered"}, {"status", 0, "triggered", false, false, 0, "status=triggered"},
{"archived", "resolved", true, false, 0, "archived=true&status=resolved"}, {"archived", 0, "resolved", true, false, 0, "archived=true&status=resolved"},
{"snoozed", "", false, true, 0, "snoozed=true"}, {"snoozed", 0, "", false, true, 0, "snoozed=true"},
{"limit", "", false, false, 500, "limit=500"}, {"limit", 0, "", false, false, 500, "limit=500"},
{"one team", 4, "", false, false, 0, "team_id=4"},
{"no team means all of them", 0, "triggered", false, false, 0, "status=triggered"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`) c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListIncidents(tt.status, tt.archived, tt.snoozed, tt.limit); err != nil { if _, err := c.ListIncidents(tt.teamID, tt.status, tt.archived, tt.snoozed, tt.limit); err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
if got.query != tt.want { if got.query != tt.want {
@@ -170,9 +174,12 @@ func TestClient_RequestBodies(t *testing.T) {
// wire when asked for — and stay off it when not. // wire when asked for — and stay off it when not.
t.Run("assign schedule", func(t *testing.T) { t.Run("assign schedule", func(t *testing.T) {
c, got := stub(t, http.StatusCreated, `[]`) c, got := stub(t, http.StatusCreated, `[]`)
if _, err := c.AssignSchedule(3, []string{"2026-07-27"}, false); err != nil { if _, err := c.AssignSchedule(9, 3, []string{"2026-07-27"}, false); err != nil {
t.Fatalf("assign: %v", err) t.Fatalf("assign: %v", err)
} }
if got.method != "POST" || got.path != "/api/teams/9/schedule" {
t.Errorf("expected POST /api/teams/9/schedule, got %s %s", got.method, got.path)
}
if got.body != `{"user_id":3,"dates":["2026-07-27"]}` { if got.body != `{"user_id":3,"dates":["2026-07-27"]}` {
t.Errorf("unexpected body %q", got.body) t.Errorf("unexpected body %q", got.body)
} }
@@ -180,7 +187,7 @@ func TestClient_RequestBodies(t *testing.T) {
t.Run("assign schedule with replace", func(t *testing.T) { t.Run("assign schedule with replace", func(t *testing.T) {
c, got := stub(t, http.StatusCreated, `[]`) c, got := stub(t, http.StatusCreated, `[]`)
if _, err := c.AssignSchedule(3, []string{"2026-07-27"}, true); err != nil { if _, err := c.AssignSchedule(9, 3, []string{"2026-07-27"}, true); err != nil {
t.Fatalf("assign: %v", err) t.Fatalf("assign: %v", err)
} }
if got.body != `{"user_id":3,"dates":["2026-07-27"],"replace":true}` { if got.body != `{"user_id":3,"dates":["2026-07-27"],"replace":true}` {
@@ -246,15 +253,91 @@ func TestClient_ErrorWithoutBody(t *testing.T) {
} }
} }
// Nobody on call is a normal state, not a failure. // Nobody on call is a normal state, not a failure: the server answers with an
func TestGetCurrentOnCall_404IsNotAnError(t *testing.T) { // empty list, one entry per team that has somebody scheduled.
c, _ := stub(t, http.StatusNotFound, `{"error":"no one is on call today"}`) func TestGetCurrentOnCall_ListsOnePerTeam(t *testing.T) {
entry, err := c.GetCurrentOnCall() c, got := stub(t, http.StatusOK, `[
{"id":1,"team_id":1,"team_name":"Ops","user_id":5,"username":"alice","date":"2026-09-23"},
{"id":2,"team_id":2,"team_name":"Dev","user_id":6,"username":"bob","date":"2026-09-23"}]`)
entries, err := c.GetCurrentOnCall()
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("on call: %v", err)
} }
if entry != nil { if got.path != "/api/schedule/current" {
t.Errorf("expected nil entry, got %+v", entry) t.Errorf("unexpected path %q", got.path)
}
if len(entries) != 2 || entries[0].TeamName != "Ops" || entries[1].Username != "bob" {
t.Errorf("unexpected entries %+v", entries)
}
c, _ = stub(t, http.StatusOK, `[]`)
if entries, err := c.GetCurrentOnCall(); err != nil || len(entries) != 0 {
t.Errorf("expected no entries and no error, got %v, %v", entries, err)
}
}
// Schedules belong to a team, so every call for one has to say which.
func TestSchedule_IsPerTeam(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.GetSchedule(7, "2026-09-21", "2026-09-27"); err != nil {
t.Fatalf("get schedule: %v", err)
}
if got.path != "/api/teams/7/schedule" || got.query != "from=2026-09-21&to=2026-09-27" {
t.Errorf("unexpected request %s?%s", got.path, got.query)
}
c, got = stub(t, http.StatusNoContent, ``)
if err := c.DeleteScheduleEntry(7, 12); err != nil {
t.Fatalf("delete: %v", err)
}
if got.method != "DELETE" || got.path != "/api/teams/7/schedule/12" {
t.Errorf("unexpected request %s %s", got.method, got.path)
}
}
func TestTeams(t *testing.T) {
c, got := stub(t, http.StatusOK, `[{"id":3,"name":"Ops","created_at":"2026-09-20T10:00:00Z","role":"owner"}]`)
teams, err := c.ListTeams()
if err != nil {
t.Fatalf("list teams: %v", err)
}
if got.path != "/api/teams" || len(teams) != 1 || teams[0].Role != RoleOwner || teams[0].Name != "Ops" {
t.Errorf("unexpected %s %+v", got.path, teams)
}
c, got = stub(t, http.StatusOK, `[{"team_id":3,"user_id":5,"username":"alice","role":"member"}]`)
members, err := c.ListTeamMembers(3)
if err != nil {
t.Fatalf("list members: %v", err)
}
if got.path != "/api/teams/3/members" || len(members) != 1 || members[0].UserID != 5 {
t.Errorf("unexpected %s %+v", got.path, members)
}
}
// A server that predates teams has no /api/teams, and the TUI recognises one by
// that 404, so it must come back as a StatusError carrying the code.
func TestListTeams_OldServerIs404(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `{"error":"not found"}`)
_, err := c.ListTeams()
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Errorf("expected a 404 StatusError, got %v", err)
}
}
func TestUser_DecodesAdminAndDisabled(t *testing.T) {
var u User
if err := json.Unmarshal([]byte(
`{"id":1,"username":"a","is_admin":true,"disabled_at":"2026-09-22T08:00:00Z"}`), &u); err != nil {
t.Fatalf("decode: %v", err)
}
if !u.IsAdmin || !u.IsDisabled() {
t.Errorf("expected an admin who is disabled, got %+v", u)
}
var other User
if err := json.Unmarshal([]byte(`{"id":2,"username":"b","is_admin":false}`), &other); err != nil || other.IsDisabled() {
t.Errorf("a user with no disabled_at must not be disabled")
} }
} }
@@ -339,7 +422,7 @@ func TestAlert_DecodesIncidentLink(t *testing.T) {
func TestListAlerts_ArchivedFilter(t *testing.T) { func TestListAlerts_ArchivedFilter(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`) c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListAlerts("", true, 50); err != nil { if _, err := c.ListAlerts(0, "", true, 50); err != nil {
t.Fatalf("list alerts: %v", err) t.Fatalf("list alerts: %v", err)
} }
if got.path != "/api/alerts" || got.query != "archived=true&limit=50" { if got.path != "/api/alerts" || got.query != "archived=true&limit=50" {
@@ -363,3 +446,52 @@ func TestIncidentStats_NullAveragesStayNil(t *testing.T) {
t.Errorf("expected nil averages, got %v / %v", stats.MTTASeconds, stats.MTTRSeconds) t.Errorf("expected nil averages, got %v / %v", stats.MTTASeconds, stats.MTTRSeconds)
} }
} }
func TestClient_Me(t *testing.T) {
c, got := stub(t, http.StatusOK, `{"user":{"id":3,"username":"erik"},"has_password":true}`)
me, err := c.Me()
if err != nil {
t.Fatalf("me: %v", err)
}
if got.method != http.MethodGet || got.path != "/api/me" {
t.Errorf("expected GET /api/me, got %s %s", got.method, got.path)
}
if me.User.ID != 3 || !me.HasPassword {
t.Errorf("unexpected decode %+v", me)
}
}
func TestClient_SetPassword(t *testing.T) {
c, got := stub(t, http.StatusNoContent, ``)
if err := c.SetPassword(2, "a brand new secret", ""); err != nil {
t.Fatalf("set password: %v", err)
}
if got.method != http.MethodPut || got.path != "/api/users/2/password" {
t.Errorf("expected PUT /api/users/2/password, got %s %s", got.method, got.path)
}
// Setting someone else's password carries no current_password at all,
// rather than an empty one.
if got.body != `{"password":"a brand new secret"}` {
t.Errorf("unexpected body %s", got.body)
}
c, got = stub(t, http.StatusNoContent, ``)
c.SetPassword(1, "a brand new secret", "the old one")
if !strings.Contains(got.body, `"current_password":"the old one"`) {
t.Errorf("current password missing from %s", got.body)
}
}
// Older servers have no /api/me; the caller tells that apart by the status
// code, so the typed error has to carry it.
func TestClient_StatusErrorKeepsCodeAndMessage(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `404 page not found`)
_, err := c.Me()
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Fatalf("expected a 404 StatusError, got %v", err)
}
if err.Error() != "server returned 404" {
t.Errorf("message changed: %q", err.Error())
}
}
+62 -3
View File
@@ -7,6 +7,8 @@ import "time"
// alert belongs to. // alert belongs to.
type Alert struct { type Alert struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
Fingerprint string `json:"fingerprint"` Fingerprint string `json:"fingerprint"`
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
@@ -25,7 +27,8 @@ type Alert struct {
// ResolutionSource records why a resolved alert left the firing state: // ResolutionSource records why a resolved alert left the firing state:
// "alertmanager" for a real resolved webhook, "expiry" when the server // "alertmanager" for a real resolved webhook, "expiry" when the server
// inferred it after the alert stopped being refreshed. // inferred it after the alert stopped being refreshed, "deadman" for a
// dead man's switch that came back. Treat the value set as open.
ResolutionSource *string `json:"resolution_source,omitempty"` ResolutionSource *string `json:"resolution_source,omitempty"`
} }
@@ -41,6 +44,8 @@ const (
// groupKey Alertmanager computed from the operator's group_by configuration. // groupKey Alertmanager computed from the operator's group_by configuration.
type Incident struct { type Incident struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
GroupKey string `json:"group_key"` GroupKey string `json:"group_key"`
Title string `json:"title"` Title string `json:"title"`
GroupLabels map[string]string `json:"group_labels"` GroupLabels map[string]string `json:"group_labels"`
@@ -63,10 +68,17 @@ type Incident struct {
ResolvedAt *time.Time `json:"resolved_at,omitempty"` ResolvedAt *time.Time `json:"resolved_at,omitempty"`
// ResolutionSource is "alerts" when every alert stopped firing, or "manual" // ResolutionSource is "alerts" when every alert stopped firing, "manual"
// when a person closed it. Treat the value set as open. // when a person closed it, or "recovered" when a dead man's switch came back.
// Treat the value set as open.
ResolutionSource *string `json:"resolution_source,omitempty"` ResolutionSource *string `json:"resolution_source,omitempty"`
// EscalationLevel is how far up the team's escalation ladder the incident has
// climbed (0 = not escalated). EscalationDueAt is when the next step fires,
// and is nil once the ladder is exhausted or the incident is acknowledged.
EscalationLevel int `json:"escalation_level"`
EscalationDueAt *time.Time `json:"escalation_due_at,omitempty"`
ArchivedAt *time.Time `json:"archived_at,omitempty"` ArchivedAt *time.Time `json:"archived_at,omitempty"`
// Alerts is populated by GET /api/incidents/{id} only. // Alerts is populated by GET /api/incidents/{id} only.
@@ -96,6 +108,9 @@ const (
EventResolved = "resolved" EventResolved = "resolved"
EventNote = "note" EventNote = "note"
// Written when a team's dead man's switch stops reporting.
EventDeadmanSilent = "deadman_silent"
// Written by the server's notifier from the delivery result, not at enqueue. // Written by the server's notifier from the delivery result, not at enqueue.
// Detail carries the notification kind ("triggered", "reminder", "resolved"), // Detail carries the notification kind ("triggered", "reminder", "resolved"),
// and on a failure the reason after it. An absent user means the page went to // and on a failure the reason after it. An absent user means the page went to
@@ -154,6 +169,8 @@ type DayStat struct {
type ScheduleEntry struct { type ScheduleEntry struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
Date string `json:"date"` // YYYY-MM-DD Date string `json:"date"` // YYYY-MM-DD
@@ -166,6 +183,14 @@ type User struct {
Email string `json:"email"` Email string `json:"email"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
// IsAdmin marks a system administrator: the only kind of user who can create
// or delete users and act on other people's passwords and keys.
IsAdmin bool `json:"is_admin"`
// DisabledAt is set when an administrator has disabled the account. A
// disabled user cannot sign in or use their keys.
DisabledAt *time.Time `json:"disabled_at,omitempty"`
// NtfyTopic is where this user's push notifications go. Nil and empty mean // NtfyTopic is where this user's push notifications go. Nil and empty mean
// the same thing — no topic of their own — because the server stores a blank // the same thing — no topic of their own — because the server stores a blank
// string as NULL. Their incidents fall back to the server's shared fallback // string as NULL. Their incidents fall back to the server's shared fallback
@@ -182,6 +207,40 @@ func (u User) Topic() string {
return *u.NtfyTopic return *u.NtfyTopic
} }
// IsDisabled reports whether the account has been disabled.
func (u User) IsDisabled() bool { return u.DisabledAt != nil }
// Me is GET /api/me: the caller, and whether they can sign in to the web UI.
type Me struct {
User User `json:"user"`
HasPassword bool `json:"has_password"`
}
// Team roles.
const (
RoleOwner = "owner"
RoleMember = "member"
)
// Team is a group that owns integrations, incidents, a schedule and an
// escalation ladder. Role is the caller's role in it, and is only present on
// the caller's own team lists (GET /api/teams).
type Team struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
Role string `json:"role,omitempty"`
}
// TeamMember is one person's membership of a team.
type TeamMember struct {
TeamID int64 `json:"team_id"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
}
type APIKey struct { type APIKey struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
+10 -1
View File
@@ -15,12 +15,19 @@ type Config struct {
ServerURL string ServerURL string
APIKey string APIKey string
RefreshInterval time.Duration RefreshInterval time.Duration
Theme string
// Team is the team to start on, by name or id. Empty shows every team the
// key's user belongs to.
Team string
} }
type rawConfig struct { type rawConfig struct {
ServerURL string `yaml:"server_url"` ServerURL string `yaml:"server_url"`
APIKey string `yaml:"api_key"` APIKey string `yaml:"api_key"`
RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds
Theme string `yaml:"theme,omitempty"`
Team string `yaml:"team,omitempty"`
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -33,7 +40,7 @@ func Load() (*Config, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n api_key: <your-api-key>", path) return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n api_key: <your-api-key>\n theme: gruvbox-dark # optional\n team: Ops # optional, team to start on", path)
} }
return nil, fmt.Errorf("cannot read config file: %w", err) return nil, fmt.Errorf("cannot read config file: %w", err)
} }
@@ -59,5 +66,7 @@ func Load() (*Config, error) {
ServerURL: raw.ServerURL, ServerURL: raw.ServerURL,
APIKey: raw.APIKey, APIKey: raw.APIKey,
RefreshInterval: interval, RefreshInterval: interval,
Theme: raw.Theme,
Team: raw.Team,
}, nil }, nil
} }
+39
View File
@@ -0,0 +1,39 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeConfig(t *testing.T, body string) {
t.Helper()
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
if err := os.MkdirAll(filepath.Join(dir, "terdut-tui"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "terdut-tui", "config.yaml"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
func TestLoad_TeamIsOptional(t *testing.T) {
writeConfig(t, "server_url: https://terdut.example.com\napi_key: k\n")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Team != "" {
t.Errorf("expected no default team, got %q", cfg.Team)
}
writeConfig(t, "server_url: https://terdut.example.com\napi_key: k\nteam: Ops\n")
cfg, err = Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Team != "Ops" {
t.Errorf("expected team Ops, got %q", cfg.Team)
}
}
+88
View File
@@ -0,0 +1,88 @@
package theme
import "sort"
// The gruvbox palettes, in the author's original names. Dark uses the bright
// variants and light the faded ones, which is what keeps each readable against
// its own background.
const (
darkBg0 = "#282828"
darkFg1 = "#ebdbb2"
darkGray = "#928374"
darkRed = "#fb4934"
darkGrn = "#b8bb26"
darkYel = "#fabd2f"
darkBlu = "#83a598"
darkAqua = "#8ec07c"
darkOrng = "#fe8019"
lightBg0 = "#fbf1c7"
lightFg1 = "#3c3836"
lightFg4 = "#7c6f64"
lightRed = "#9d0006"
lightGrn = "#79740e"
lightYel = "#b57614"
lightBlu = "#076678"
lightAqua = "#427b58"
lightOrng = "#af3a03"
)
// GruvboxDark is the default scheme. It assumes a dark terminal background:
// themes colour foregrounds only, so the terminal supplies the canvas.
var GruvboxDark = Theme{
Name: "gruvbox-dark",
Primary: darkBlu,
OnPrimary: darkBg0,
Text: darkFg1,
Muted: darkGray,
Accent: darkOrng,
Firing: darkRed,
Resolved: darkGrn,
Error: darkRed,
SevCritical: darkRed,
SevError: darkOrng,
SevWarning: darkYel,
SevInfo: darkAqua,
}
// GruvboxLight is the same scheme against a light terminal background.
var GruvboxLight = Theme{
Name: "gruvbox-light",
Primary: lightBlu,
OnPrimary: lightBg0,
Text: lightFg1,
Muted: lightFg4,
Accent: lightOrng,
Firing: lightRed,
Resolved: lightGrn,
Error: lightRed,
SevCritical: lightRed,
SevError: lightOrng,
SevWarning: lightYel,
SevInfo: lightAqua,
}
// Default is the theme used when the config names none.
var Default = GruvboxDark
var builtins = map[string]Theme{
GruvboxDark.Name: GruvboxDark,
GruvboxLight.Name: GruvboxLight,
}
// BuiltinNames lists the compiled-in themes, sorted, for error messages and
// documentation.
func BuiltinNames() []string {
names := make([]string, 0, len(builtins))
for name := range builtins {
names = append(names, name)
}
sort.Strings(names)
return names
}
+196
View File
@@ -0,0 +1,196 @@
package theme
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/charmbracelet/lipgloss"
"gopkg.in/yaml.v3"
)
// rawTheme is the on-disk form. Every token is a pointer so an absent key is
// distinguishable from an empty one, which is what lets 'extends' overwrite
// only what the file actually mentions.
type rawTheme struct {
Extends *string `yaml:"extends"`
Primary *string `yaml:"primary"`
OnPrimary *string `yaml:"on_primary"`
Text *string `yaml:"text"`
Muted *string `yaml:"muted"`
Accent *string `yaml:"accent"`
Firing *string `yaml:"firing"`
Resolved *string `yaml:"resolved"`
Error *string `yaml:"error"`
SevCritical *string `yaml:"sev_critical"`
SevError *string `yaml:"sev_error"`
SevWarning *string `yaml:"sev_warning"`
SevInfo *string `yaml:"sev_info"`
}
// binding ties a YAML key to its raw value and the field it fills, so parsing,
// merging and the missing-token report all walk the same list.
type binding struct {
key string
src *string
dst *lipgloss.Color
}
func bindings(r *rawTheme, t *Theme) []binding {
return []binding{
{"primary", r.Primary, &t.Primary},
{"on_primary", r.OnPrimary, &t.OnPrimary},
{"text", r.Text, &t.Text},
{"muted", r.Muted, &t.Muted},
{"accent", r.Accent, &t.Accent},
{"firing", r.Firing, &t.Firing},
{"resolved", r.Resolved, &t.Resolved},
{"error", r.Error, &t.Error},
{"sev_critical", r.SevCritical, &t.SevCritical},
{"sev_error", r.SevError, &t.SevError},
{"sev_warning", r.SevWarning, &t.SevWarning},
{"sev_info", r.SevInfo, &t.SevInfo},
}
}
// tokenKeys lists the colour keys a theme file may set, in the order they are
// documented.
func tokenKeys() []string {
bs := bindings(&rawTheme{}, &Theme{})
keys := make([]string, len(bs))
for i, b := range bs {
keys[i] = b.key
}
return keys
}
func isTokenKey(k string) bool {
if k == "extends" {
return true
}
for _, want := range tokenKeys() {
if k == want {
return true
}
}
return false
}
// Load resolves a theme by name. An empty name is the default; otherwise a file
// in the user's themes directory wins over a built-in of the same name, so the
// documented way to tweak a built-in is to shadow it rather than rename it.
func Load(name string) (Theme, error) {
if name == "" {
return Default, nil
}
dir, err := os.UserConfigDir()
if err != nil {
// Built-ins do not need the disk, so a missing config directory only
// matters for user themes.
if t, ok := builtins[name]; ok {
return t, nil
}
return Theme{}, fmt.Errorf("cannot determine config directory: %w", err)
}
return loadFrom(filepath.Join(dir, "terdut-tui", "themes"), name)
}
func loadFrom(dir, name string) (Theme, error) {
if strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
return Theme{}, fmt.Errorf("invalid theme name %q: a theme is a bare name, not a path", name)
}
path := filepath.Join(dir, name+".yaml")
data, err := os.ReadFile(path)
switch {
case err == nil:
return parse(name, data)
case !os.IsNotExist(err):
return Theme{}, fmt.Errorf("cannot read theme file %s: %w", path, err)
}
if t, ok := builtins[name]; ok {
return t, nil
}
return Theme{}, fmt.Errorf("unknown theme %q\n\nBuilt-in themes: %s\nOr define your own at %s",
name, strings.Join(BuiltinNames(), ", "), path)
}
func parse(name string, data []byte) (Theme, error) {
// Check the keys before decoding, so a typo'd token reports itself by name
// alongside the ones that would have worked rather than surfacing yaml's
// message about an internal Go type.
var keys map[string]yaml.Node
if err := yaml.Unmarshal(data, &keys); err != nil {
return Theme{}, fmt.Errorf("invalid theme %q: %w", name, err)
}
for k := range keys {
if !isTokenKey(k) {
return Theme{}, fmt.Errorf("theme %q: unknown key %q\n\nValid keys: extends, %s",
name, k, strings.Join(tokenKeys(), ", "))
}
}
var raw rawTheme
if err := yaml.Unmarshal(data, &raw); err != nil {
return Theme{}, fmt.Errorf("invalid theme %q: %w", name, err)
}
t := Theme{Name: name}
if raw.Extends != nil {
base, ok := builtins[*raw.Extends]
if !ok {
return Theme{}, fmt.Errorf("theme %q: 'extends' names unknown theme %q (built-ins: %s)",
name, *raw.Extends, strings.Join(BuiltinNames(), ", "))
}
t = base
t.Name = name
}
var missing []string
for _, b := range bindings(&raw, &t) {
if b.src == nil {
if raw.Extends == nil {
missing = append(missing, b.key)
}
continue
}
c, err := parseColor(*b.src)
if err != nil {
return Theme{}, fmt.Errorf("theme %q: %s: %w", name, b.key, err)
}
*b.dst = c
}
if len(missing) > 0 {
return Theme{}, fmt.Errorf("theme %q is missing %s\n\nEither set every token or add 'extends: %s' to inherit the rest",
name, strings.Join(missing, ", "), Default.Name)
}
return t, nil
}
var hexColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// parseColor accepts what lipgloss can actually render: a hex value, or an ANSI
// palette index for people who would rather follow their terminal's colours.
func parseColor(s string) (lipgloss.Color, error) {
if hexColor.MatchString(s) {
return lipgloss.Color(s), nil
}
// strconv.Itoa round-trips to reject "+7" and "007", which lipgloss would
// pass to the terminal verbatim.
if n, err := strconv.Atoi(s); err == nil && n >= 0 && n <= 255 && strconv.Itoa(n) == s {
return lipgloss.Color(s), nil
}
return "", fmt.Errorf("invalid colour %q, want a hex value like \"#83a598\" or an ANSI index 0-255", s)
}
+180
View File
@@ -0,0 +1,180 @@
package theme
import (
"os"
"path/filepath"
"strings"
"testing"
)
// write drops a theme file into dir and returns the directory, so each test
// works against its own themes directory rather than the user's.
func write(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name+".yaml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoad_EmptyNameIsTheDefault(t *testing.T) {
got, err := Load("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Name != Default.Name {
t.Errorf("got theme %q, want %q", got.Name, Default.Name)
}
}
func TestLoadFrom_BuiltinsResolveWithoutAFile(t *testing.T) {
dir := t.TempDir()
for _, name := range BuiltinNames() {
got, err := loadFrom(dir, name)
if err != nil {
t.Fatalf("%s: unexpected error: %v", name, err)
}
if got.Name != name {
t.Errorf("got theme %q, want %q", got.Name, name)
}
if got.Primary == "" {
t.Errorf("%s: primary is unset", name)
}
}
}
func TestLoadFrom_ExtendsOverridesOnlyWhatIsNamed(t *testing.T) {
dir := t.TempDir()
write(t, dir, "mine", "extends: gruvbox-dark\nprimary: \"#d3869b\"\n")
got, err := loadFrom(dir, "mine")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Name != "mine" {
t.Errorf("got name %q, want %q", got.Name, "mine")
}
if got.Primary != "#d3869b" {
t.Errorf("got primary %q, want the override", got.Primary)
}
if got.Muted != GruvboxDark.Muted {
t.Errorf("got muted %q, want inherited %q", got.Muted, GruvboxDark.Muted)
}
if got.SevInfo != GruvboxDark.SevInfo {
t.Errorf("got sev_info %q, want inherited %q", got.SevInfo, GruvboxDark.SevInfo)
}
}
func TestLoadFrom_UserFileShadowsABuiltin(t *testing.T) {
dir := t.TempDir()
write(t, dir, "gruvbox-dark", "extends: gruvbox-dark\naccent: \"#fabd2f\"\n")
got, err := loadFrom(dir, "gruvbox-dark")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Accent != "#fabd2f" {
t.Errorf("got accent %q, want the shadowing file's value", got.Accent)
}
}
func TestLoadFrom_WithoutExtendsEveryTokenIsRequired(t *testing.T) {
dir := t.TempDir()
write(t, dir, "partial", "primary: \"#83a598\"\n")
_, err := loadFrom(dir, "partial")
if err == nil {
t.Fatal("expected an error for a theme missing tokens")
}
for _, want := range []string{"muted", "sev_info", "extends"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error should mention %q, got: %v", want, err)
}
}
}
func TestLoadFrom_CompleteThemeNeedsNoExtends(t *testing.T) {
dir := t.TempDir()
write(t, dir, "full", `primary: "#000001"
on_primary: "#000002"
text: "#000003"
muted: "#000004"
accent: "#000005"
firing: "#000006"
resolved: "#000007"
error: "#000008"
sev_critical: "#000009"
sev_error: "#00000a"
sev_warning: "#00000b"
sev_info: "#00000c"
`)
got, err := loadFrom(dir, "full")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Primary != "#000001" || got.SevInfo != "#00000c" {
t.Errorf("tokens not applied: %+v", got)
}
}
func TestLoadFrom_Errors(t *testing.T) {
tests := []struct {
name string
file string // empty means: write no file at all
body string
want string
}{
{"unknown name", "", "", "unknown theme"},
{"unknown key", "typo", "extends: gruvbox-dark\nprimry: \"#83a598\"\n", `unknown key "primry"`},
{"unknown key lists the valid ones", "typo2", "primry: \"#83a598\"\n", "Valid keys: extends, primary,"},
{"bad colour", "bad", "extends: gruvbox-dark\nprimary: notacolour\n", "invalid colour"},
{"bad colour names the token", "badkey", "extends: gruvbox-dark\nsev_warning: \"#gggggg\"\n", "sev_warning"},
{"unknown base", "orphan", "extends: solarized\nprimary: \"#83a598\"\n", "unknown theme \"solarized\""},
{"malformed yaml", "broken", "extends: [\n", "invalid theme"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
name := tt.file
if name == "" {
name = "missing"
} else {
write(t, dir, name, tt.body)
}
_, err := loadFrom(dir, name)
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), tt.want) {
t.Errorf("error should mention %q, got: %v", tt.want, err)
}
})
}
}
func TestLoadFrom_RejectsPathsAsNames(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"../secrets", "sub/theme", ".."} {
if _, err := loadFrom(dir, name); err == nil {
t.Errorf("%q: expected an error", name)
}
}
}
func TestParseColor(t *testing.T) {
ok := []string{"#83a598", "#FFF", "#abc", "0", "15", "255"}
for _, s := range ok {
if _, err := parseColor(s); err != nil {
t.Errorf("parseColor(%q) = %v, want no error", s, err)
}
}
bad := []string{"", "83a598", "#ab", "#abcd", "#gggggg", "256", "-1", "+7", "007", "red"}
for _, s := range bad {
if _, err := parseColor(s); err == nil {
t.Errorf("parseColor(%q) = nil, want an error", s)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
// Package theme resolves the named colour scheme the TUI renders with. A theme
// is a flat set of semantic tokens — roles like "muted" or "firing", never hues
// — so a new scheme is a table of colours rather than a change to the views.
package theme
import "github.com/charmbracelet/lipgloss"
// Theme is the palette the UI draws from. Every token is a foreground except
// OnPrimary, which is the text colour for the two places that invert: the
// active tab and the selected table row.
//
// Colours are truecolor hex; lipgloss downsamples them for 256- and 16-colour
// terminals and drops them entirely under NO_COLOR, so themes do not carry
// fallbacks of their own.
type Theme struct {
Name string
Primary lipgloss.Color // header, tab highlight, selection
OnPrimary lipgloss.Color // text drawn on a Primary background
Text lipgloss.Color // default emphasis foreground
Muted lipgloss.Color // secondary text, dividers, borders
Accent lipgloss.Color // status line, acknowledged, by-day chart
Firing lipgloss.Color
Resolved lipgloss.Color
Error lipgloss.Color
SevCritical lipgloss.Color
SevError lipgloss.Color
SevWarning lipgloss.Color
SevInfo lipgloss.Color
}
+424 -86
View File
@@ -1,16 +1,22 @@
package tui package tui
import ( import (
"errors"
"fmt" "fmt"
"net/http"
"slices"
"strconv"
"strings"
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api"
) )
// ── Enums ────────────────────────────────────────────────────────────────── // ── Enums ──────────────────────────────────────────────────────────────────
@@ -45,8 +51,21 @@ const (
modeAPIKeyCreate modeAPIKeyCreate
modeAPIKeyReveal modeAPIKeyReveal
modeAPIKeyRevokeByID modeAPIKeyRevokeByID
modePasswordSet
) )
// Fields of the set-password form, in tab order.
const (
pwCurrent = iota
pwNew
pwRepeat
pwFieldCount
)
// minPasswordLen mirrors the server's rule, so a short password is refused
// here rather than after a round trip.
const minPasswordLen = 10
type confirmTarget int type confirmTarget int
const ( const (
@@ -91,7 +110,10 @@ func filterLabel(filter string) string {
// ── Messages ─────────────────────────────────────────────────────────────── // ── Messages ───────────────────────────────────────────────────────────────
// dashboard // dashboard
type connectedMsg struct{} type connectedMsg struct {
teams []api.Team
me api.Me
}
type connectErrMsg struct{ err error } type connectErrMsg struct{ err error }
type incidentsFetchedMsg struct{ incidents []api.Incident } type incidentsFetchedMsg struct{ incidents []api.Incident }
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident } type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
@@ -126,7 +148,11 @@ type detailStatsErrMsg struct{ err error }
// schedule // schedule
type scheduleFetchedMsg struct { type scheduleFetchedMsg struct {
entries []api.ScheduleEntry entries []api.ScheduleEntry
current *api.ScheduleEntry current []api.ScheduleEntry
}
type pickerReadyMsg struct {
users []api.User
members map[int64]bool
} }
type scheduleFetchErrMsg struct{ err error } type scheduleFetchErrMsg struct{ err error }
type scheduleActionErrMsg struct{ err error } type scheduleActionErrMsg struct{ err error }
@@ -136,6 +162,8 @@ type usersFetchedMsg struct{ users []api.User }
type apiKeyCreatedMsg struct{ key api.APIKey } type apiKeyCreatedMsg struct{ key api.APIKey }
type apiKeyRevokedMsg struct{} type apiKeyRevokedMsg struct{}
type userActionErrMsg struct{ err error } type userActionErrMsg struct{ err error }
type meFetchedMsg struct{ me api.Me }
type passwordSetMsg struct{ username string }
// ── Model ────────────────────────────────────────────────────────────────── // ── Model ──────────────────────────────────────────────────────────────────
@@ -166,6 +194,15 @@ type Model struct {
width int width int
height int height int
// Teams. The server scopes everything to the caller's teams; activeTeamID
// narrows the incident and alert lists to one of them, 0 meaning all. The
// schedule is per team and always needs a concrete one, see scheduleTeam.
teams []api.Team
activeTeamID int64
defaultTeam string // config's `team`, resolved on connect
meID int64
isAdmin bool
// Connection & dashboard // Connection & dashboard
connected bool connected bool
loading bool loading bool
@@ -223,7 +260,7 @@ type Model struct {
scheduleWindow time.Time scheduleWindow time.Time
scheduleEntries []api.ScheduleEntry scheduleEntries []api.ScheduleEntry
scheduleDays []scheduleDay scheduleDays []scheduleDay
currentOnCall *api.ScheduleEntry currentOnCall []api.ScheduleEntry
scheduleLoading bool scheduleLoading bool
scheduleTable table.Model scheduleTable table.Model
@@ -233,6 +270,9 @@ type Model struct {
userPickerTable table.Model userPickerTable table.Model
pickerTarget pickerTarget pickerTarget pickerTarget
pickerAssignWeek bool pickerAssignWeek bool
// pickerMembers is who belongs to the schedule's team, so the schedule picker
// offers only people the server will accept. Nil until fetched.
pickerMembers map[int64]bool
// User management section // User management section
userManageTable table.Model userManageTable table.Model
@@ -244,29 +284,44 @@ type Model struct {
apiKeyRevokeInput textinput.Model apiKeyRevokeInput textinput.Model
revealedAPIKey api.APIKey revealedAPIKey api.APIKey
// Set-password form. The current-password field is shown only when the
// target is the key's own user and already has a password, which is the
// one case the server asks for it; pwLoading covers the /api/me lookup
// that decides it.
pwInputs [pwFieldCount]textinput.Model
pwFocus int
pwNeedCurrent bool
pwLoading bool
help help.Model help help.Model
keys keyMap keys keyMap
styles Styles
} }
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model { func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration, th theme.Theme) Model {
ts := defaultTableStyles() st := newStyles(th)
ts := st.Table()
incidentT := table.New(table.WithFocused(true)) // Each table sees a key before the section's own handler does, so any
// key a section uses as an action must be taken out of that table's
// navigation bindings, or the cursor moves first and the action lands on
// a different row. See tableKeyMap.
incidentT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("f")))
incidentT.SetStyles(ts) incidentT.SetStyles(ts)
alertT := table.New(table.WithFocused(true)) alertT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("f")))
alertT.SetStyles(ts) alertT.SetStyles(ts)
archivedT := table.New(table.WithFocused(true)) archivedT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
archivedT.SetStyles(ts) archivedT.SetStyles(ts)
schedT := table.New(table.WithFocused(true)) schedT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d")))
schedT.SetStyles(ts) schedT.SetStyles(ts)
pickerT := table.New(table.WithFocused(true)) pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
pickerT.SetStyles(ts) pickerT.SetStyles(ts)
manageT := table.New(table.WithFocused(true)) manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k", "p")))
manageT.SetStyles(ts) manageT.SetStyles(ts)
// Sized by the first tea.WindowSizeMsg; built here so it carries the default // Sized by the first tea.WindowSizeMsg; built here so it carries the default
@@ -301,6 +356,27 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
revokeIn.Placeholder = "integer key ID" revokeIn.Placeholder = "integer key ID"
revokeIn.CharLimit = 20 revokeIn.CharLimit = 20
var pwIn [pwFieldCount]textinput.Model
for i, placeholder := range [pwFieldCount]string{"current password", "new password (min. 10 characters)", "repeat new password"} {
pwIn[i] = textinput.New()
pwIn[i].Placeholder = placeholder
pwIn[i].EchoMode = textinput.EchoPassword
pwIn[i].EchoCharacter = '•'
pwIn[i].CharLimit = 72 // bcrypt's limit; the server refuses longer
}
for _, in := range []*textinput.Model{
&noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn,
} {
*in = st.Input(*in)
}
for i := range pwIn {
pwIn[i] = st.Input(pwIn[i])
}
helpModel := help.New()
helpModel.Styles = st.Help()
now := time.Now().UTC() now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
weekday := int(today.Weekday()) weekday := int(today.Weekday())
@@ -333,58 +409,115 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
ntfyTopicInput: topicIn, ntfyTopicInput: topicIn,
apiKeyNameInput: keyNameIn, apiKeyNameInput: keyNameIn,
apiKeyRevokeInput: revokeIn, apiKeyRevokeInput: revokeIn,
help: help.New(), pwInputs: pwIn,
help: helpModel,
keys: keys, keys: keys,
styles: st,
} }
} }
// WithDefaultTeam names the team to start on, by name or id. It is resolved
// against the caller's teams once connected; an unknown one is reported and the
// TUI starts on all teams.
func (m Model) WithDefaultTeam(team string) Model {
m.defaultTeam = strings.TrimSpace(team)
return m
}
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return connectCmd(m.client) return connectCmd(m.client)
} }
// ── Table rebuilders ─────────────────────────────────────────────────────── // ── Table rebuilders ───────────────────────────────────────────────────────
func defaultTableStyles() table.Styles { // tableKeyMap is the bubbles table keymap without the given keys.
s := table.DefaultStyles() //
s.Header = s.Header.Bold(true) // The table's defaults claim several letters -- k up, d half a page down, f a
s.Selected = s.Selected. // page down -- and the dashboard hands every key to the table before the
Foreground(lipgloss.Color("0")). // section's own handler reads the cursor. A letter that is both, like k for
Background(colorPrimary). // API keys in Users, therefore moved the cursor and then acted on the row it
Bold(true) // had moved to. Each table gives up the letters its section acts on; the
return s // arrow keys and the rest of the defaults are untouched.
func tableKeyMap(reserved ...string) table.KeyMap {
km := table.DefaultKeyMap()
for _, b := range []*key.Binding{
&km.LineUp, &km.LineDown, &km.PageUp, &km.PageDown,
&km.HalfPageUp, &km.HalfPageDown, &km.GotoTop, &km.GotoBottom,
} {
var keep []string
for _, k := range b.Keys() {
if !slices.Contains(reserved, k) {
keep = append(keep, k)
}
}
b.SetKeys(keep...)
}
return km
}
// setRows replaces a table's rows and keeps its cursor in a state the rest of
// this package can rely on: valid whenever the table has any rows at all.
//
// bubbles does not do that on its own. SetRows only clamps the cursor *down*
// (`if m.cursor > len(rows)-1`), so setting zero rows drives it to -1 and
// nothing ever brings it back — filling the table later leaves -1 in place,
// because -1 is not greater than len-1. Every table here is rebuilt from empty
// once at startup, when the first WindowSizeMsg arrives before any fetch has
// returned, so without this every cursor is -1 until the user happens to press
// up or down. Indexing a slice with that panics, which is exactly what
// assigning an on-call week did.
func setRows(t *table.Model, rows []table.Row) {
t.SetRows(rows)
if len(rows) > 0 && t.Cursor() < 0 {
t.SetCursor(0)
}
}
// setTable replaces a table's columns and rows together, for tables whose column
// count can change (the Team column comes and goes). bubbles re-renders the
// existing rows as soon as SetColumns is called, and a row with a different
// number of cells than the new columns indexes past the end and panics, so the
// old rows have to go first. The cursor is put back afterwards, since a refresh
// must not send it to the top.
func setTable(t *table.Model, cols []table.Column, rows []table.Row) {
cursor := t.Cursor()
t.SetRows(nil)
t.SetColumns(cols)
setRows(t, rows)
if cursor > 0 && cursor < len(rows) {
t.SetCursor(cursor)
}
} }
func (m *Model) rebuildIncidentTable() { func (m *Model) rebuildIncidentTable() {
m.incidentTable.SetColumns(incidentColumns(m.width)) setTable(&m.incidentTable, incidentColumns(m.width, m.showTeamColumn()), incidentRows(m.incidents, m.showTeamColumn()))
m.incidentTable.SetRows(incidentRows(m.incidents))
m.incidentTable.SetHeight(tableHeight(m.height, 8)) m.incidentTable.SetHeight(tableHeight(m.height, 8))
} }
func (m *Model) rebuildTable() { func (m *Model) rebuildTable() {
m.alertTable.SetColumns(alertColumns(m.width)) setTable(&m.alertTable, alertColumns(m.width, m.showTeamColumn()), alertRows(m.alerts, m.showTeamColumn()))
m.alertTable.SetRows(alertRows(m.alerts))
m.alertTable.SetHeight(tableHeight(m.height, 8)) m.alertTable.SetHeight(tableHeight(m.height, 8))
} }
func (m *Model) rebuildArchivedTable() { func (m *Model) rebuildArchivedTable() {
m.archivedTable.SetColumns(incidentColumns(m.width)) setTable(&m.archivedTable, incidentColumns(m.width, m.showTeamColumn()), incidentRows(m.archivedIncidents, m.showTeamColumn()))
m.archivedTable.SetRows(incidentRows(m.archivedIncidents))
m.archivedTable.SetHeight(tableHeight(m.height, 8)) m.archivedTable.SetHeight(tableHeight(m.height, 8))
} }
func (m *Model) rebuildScheduleTable() { func (m *Model) rebuildScheduleTable() {
m.scheduleTable.SetColumns(scheduleColumns(m.width)) m.scheduleTable.SetColumns(scheduleColumns(m.width))
m.scheduleTable.SetRows(scheduleRows(m.scheduleDays)) setRows(&m.scheduleTable, scheduleRows(m.scheduleDays))
m.scheduleTable.SetHeight(tableHeight(m.height, 10)) m.scheduleTable.SetHeight(tableHeight(m.height, 10))
} }
func (m *Model) rebuildUserPickerTable() { func (m *Model) rebuildUserPickerTable() {
m.userPickerTable.SetColumns(userPickerColumns(m.width)) m.userPickerTable.SetColumns(userPickerColumns(m.width))
rows := make([]table.Row, len(m.users)) pickable := m.pickerUsers()
for i, u := range m.users { rows := make([]table.Row, len(pickable))
for i, u := range pickable {
rows[i] = table.Row{u.Username, u.Email} rows[i] = table.Row{u.Username, u.Email}
} }
m.userPickerTable.SetRows(rows) setRows(&m.userPickerTable, rows)
m.userPickerTable.SetHeight(tableHeight(m.height, 10)) m.userPickerTable.SetHeight(tableHeight(m.height, 10))
} }
@@ -396,9 +529,9 @@ func (m *Model) rebuildUserManageTable() {
if topic == "" { if topic == "" {
topic = "—" topic = "—"
} }
rows[i] = table.Row{u.Username, u.Email, topic, u.CreatedAt.UTC().Format("2006-01-02")} rows[i] = table.Row{u.Username, u.Email, topic, userFlags(u), u.CreatedAt.UTC().Format("2006-01-02")}
} }
m.userManageTable.SetRows(rows) setRows(&m.userManageTable, rows)
m.userManageTable.SetHeight(tableHeight(m.height, 10)) m.userManageTable.SetHeight(tableHeight(m.height, 10))
} }
@@ -415,16 +548,16 @@ func (m *Model) refreshDetailContent() {
return return
} }
if m.mode == modeAlertDetail { if m.mode == modeAlertDetail {
m.detailViewport.SetContent(buildAlertDetailContent(m.selectedAlert, m.width)) m.detailViewport.SetContent(buildAlertDetailContent(m.styles, m.selectedAlert, m.width))
return return
} }
m.detailViewport.SetContent( m.detailViewport.SetContent(
buildIncidentDetailContent(m.selectedIncident, m.timeline, m.noteCursor, m.width)) buildIncidentDetailContent(m.styles, m.selectedIncident, m.timeline, m.noteCursor, m.width))
} }
func (m *Model) refreshStatsContent() { func (m *Model) refreshStatsContent() {
m.statsViewport.SetContent( m.statsViewport.SetContent(
buildStatsContent(m.incidentStats, m.topAlerts, m.hourStats, m.dayStats, m.width)) buildStatsContent(m.styles, m.incidentStats, m.topAlerts, m.hourStats, m.dayStats, m.width))
} }
func (m Model) statsViewportHeight() int { func (m Model) statsViewportHeight() int {
@@ -446,6 +579,106 @@ func (m Model) detailViewportHeight() int {
return h return h
} }
// showTeamColumn is whether list rows need saying which team they belong to:
// only when they can come from more than one.
func (m Model) showTeamColumn() bool {
return m.activeTeamID == 0 && len(m.teams) > 1
}
// activeTeam returns the team the lists are narrowed to.
func (m Model) activeTeam() (api.Team, bool) {
return m.teamByID(m.activeTeamID)
}
func (m Model) teamByID(id int64) (api.Team, bool) {
for _, t := range m.teams {
if t.ID == id {
return t, true
}
}
return api.Team{}, false
}
// scheduleTeam is the team whose schedule the Schedule section shows. That is
// the active team; with all teams showing it is the first one the caller owns,
// else their first, because a rota belongs to one team and there is no
// meaningful union to display.
func (m Model) scheduleTeam() (api.Team, bool) {
if t, ok := m.activeTeam(); ok {
return t, true
}
for _, t := range m.teams {
if t.Role == api.RoleOwner {
return t, true
}
}
if len(m.teams) > 0 {
return m.teams[0], true
}
return api.Team{}, false
}
// canEditSchedule mirrors the server: writes need a team owner or an
// administrator. Saying so up front beats a 403 after picking a user.
func (m Model) canEditSchedule(t api.Team) bool {
return m.isAdmin || t.Role == api.RoleOwner
}
// canManageUser mirrors the server's self-or-admin rule for a user's password,
// ntfy topic and API keys.
func (m Model) canManageUser(u api.User) bool {
return m.isAdmin || u.ID == m.meID
}
// resolveTeam finds a team by id or, failing that, by name.
func resolveTeam(teams []api.Team, want string) (api.Team, bool) {
if id, err := strconv.ParseInt(want, 10, 64); err == nil {
for _, t := range teams {
if t.ID == id {
return t, true
}
}
}
for _, t := range teams {
if strings.EqualFold(t.Name, want) {
return t, true
}
}
return api.Team{}, false
}
// pickerUsers is who the user picker offers. Disabled accounts are never worth
// assigning to. For the schedule it is also limited to the team's members: the
// server answers 404 for anyone else, and shows nothing until they are known.
func (m Model) pickerUsers() []api.User {
out := make([]api.User, 0, len(m.users))
for _, u := range m.users {
if u.IsDisabled() {
continue
}
if m.pickerTarget == pickerSchedule && !m.pickerMembers[u.ID] {
continue
}
out = append(out, u)
}
return out
}
// userFlags is the Users table's marker column.
func userFlags(u api.User) string {
var flags []string
if u.IsAdmin {
flags = append(flags, "admin")
}
if u.IsDisabled() {
flags = append(flags, "disabled")
}
if len(flags) == 0 {
return "—"
}
return strings.Join(flags, ",")
}
// noteEvents filters a timeline down to the deletable entries, which is what // noteEvents filters a timeline down to the deletable entries, which is what
// the [ and ] cursor walks. // the [ and ] cursor walks.
func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent { func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
@@ -460,44 +693,67 @@ func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
// ── Column definitions ───────────────────────────────────────────────────── // ── Column definitions ─────────────────────────────────────────────────────
func incidentColumns(width int) []table.Column { // teamW is the width of the Team column shown when rows can span teams.
const teamW = 14
func incidentColumns(width int, showTeam bool) []table.Column {
const sevW, statusW, timeW = 9, 15, 12 const sevW, statusW, timeW = 9, 15, 12
titleW := width/2 - 10 titleW := width/2 - 10
if showTeam {
titleW -= teamW + 2
}
if titleW < 20 { if titleW < 20 {
titleW = 20 titleW = 20
} }
// 10 = bubbles' Padding(0, 1) on each of the five cells. // 10 = bubbles' Padding(0, 1) on each of the five cells.
assigneeW := width - sevW - titleW - statusW - timeW - 10 assigneeW := width - sevW - titleW - statusW - timeW - 10
if showTeam {
assigneeW -= teamW + 2
}
if assigneeW < 8 { if assigneeW < 8 {
assigneeW = 8 assigneeW = 8
} }
return []table.Column{ cols := []table.Column{
{Title: "Sev", Width: sevW}, {Title: "Sev", Width: sevW},
{Title: "Incident", Width: titleW}, {Title: "Incident", Width: titleW},
{Title: "Status", Width: statusW},
{Title: "Assignee", Width: assigneeW},
{Title: "Triggered", Width: timeW},
} }
if showTeam {
cols = append(cols, table.Column{Title: "Team", Width: teamW})
}
return append(cols,
table.Column{Title: "Status", Width: statusW},
table.Column{Title: "Assignee", Width: assigneeW},
table.Column{Title: "Triggered", Width: timeW},
)
} }
func alertColumns(width int) []table.Column { func alertColumns(width int, showTeam bool) []table.Column {
const statusW, timeW = 10, 12 const statusW, timeW = 10, 12
nameW := width/2 - 14 nameW := width/2 - 14
if showTeam {
nameW -= teamW + 2
}
if nameW < 20 { if nameW < 20 {
nameW = 20 nameW = 20
} }
// 10 = bubbles' Padding(0, 1) on each of the five cells. // 10 = bubbles' Padding(0, 1) on each of the five cells.
incW := width - nameW - statusW - 2*timeW - 10 incW := width - nameW - statusW - 2*timeW - 10
if showTeam {
incW -= teamW + 2
}
if incW < 8 { if incW < 8 {
incW = 8 incW = 8
} }
return []table.Column{ cols := []table.Column{{Title: "Name", Width: nameW}}
{Title: "Name", Width: nameW}, if showTeam {
{Title: "Status", Width: statusW}, cols = append(cols, table.Column{Title: "Team", Width: teamW})
{Title: "Started", Width: timeW},
{Title: "Last Seen", Width: timeW},
{Title: "Incident", Width: incW},
} }
return append(cols,
table.Column{Title: "Status", Width: statusW},
table.Column{Title: "Started", Width: timeW},
table.Column{Title: "Last Seen", Width: timeW},
table.Column{Title: "Incident", Width: incW},
)
} }
func scheduleColumns(width int) []table.Column { func scheduleColumns(width int) []table.Column {
@@ -528,8 +784,9 @@ func userManageColumns(width int) []table.Column {
createdW := 12 createdW := 12
usernameW := 25 usernameW := 25
topicW := 22 topicW := 22
// 8 = bubbles' Padding(0, 1) on each of the four cells. flagsW := 14
emailW := width - usernameW - topicW - createdW - 8 // 10 = bubbles' Padding(0, 1) on each of the five cells.
emailW := width - usernameW - topicW - flagsW - createdW - 10
if emailW < 15 { if emailW < 15 {
emailW = 15 emailW = 15
} }
@@ -537,13 +794,14 @@ func userManageColumns(width int) []table.Column {
{Title: "Username", Width: usernameW}, {Title: "Username", Width: usernameW},
{Title: "Email", Width: emailW}, {Title: "Email", Width: emailW},
{Title: "Ntfy Topic", Width: topicW}, {Title: "Ntfy Topic", Width: topicW},
{Title: "Flags", Width: flagsW},
{Title: "Created", Width: createdW}, {Title: "Created", Width: createdW},
} }
} }
// ── Row builders ─────────────────────────────────────────────────────────── // ── Row builders ───────────────────────────────────────────────────────────
func incidentRows(incidents []api.Incident) []table.Row { func incidentRows(incidents []api.Incident, showTeam bool) []table.Row {
now := time.Now() now := time.Now()
rows := make([]table.Row, len(incidents)) rows := make([]table.Row, len(incidents))
for i, inc := range incidents { for i, inc := range incidents {
@@ -561,12 +819,16 @@ func incidentRows(incidents []api.Incident) []table.Row {
if assignee == "" { if assignee == "" {
assignee = "—" assignee = "—"
} }
if showTeam {
rows[i] = table.Row{severity, inc.Title, teamLabel(inc.TeamName), status, assignee, humanAgo(now, inc.TriggeredAt)}
continue
}
rows[i] = table.Row{severity, inc.Title, status, assignee, humanAgo(now, inc.TriggeredAt)} rows[i] = table.Row{severity, inc.Title, status, assignee, humanAgo(now, inc.TriggeredAt)}
} }
return rows return rows
} }
func alertRows(alerts []api.Alert) []table.Row { func alertRows(alerts []api.Alert, showTeam bool) []table.Row {
now := time.Now() now := time.Now()
rows := make([]table.Row, len(alerts)) rows := make([]table.Row, len(alerts))
for i, a := range alerts { for i, a := range alerts {
@@ -574,11 +836,23 @@ func alertRows(alerts []api.Alert) []table.Row {
if a.IncidentID != nil { if a.IncidentID != nil {
incident = fmt.Sprintf("#%d", *a.IncidentID) incident = fmt.Sprintf("#%d", *a.IncidentID)
} }
if showTeam {
rows[i] = table.Row{a.Name, teamLabel(a.TeamName), a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), incident}
continue
}
rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), incident} rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), incident}
} }
return rows return rows
} }
// teamLabel is a team name for a table cell, with a dash when the server sent none.
func teamLabel(name string) string {
if name == "" {
return "—"
}
return name
}
func scheduleRows(days []scheduleDay) []table.Row { func scheduleRows(days []scheduleDay) []table.Row {
today := time.Now().UTC().Format("2006-01-02") today := time.Now().UTC().Format("2006-01-02")
rows := make([]table.Row, len(days)) rows := make([]table.Row, len(days))
@@ -673,19 +947,38 @@ func humanSeconds(secs *float64) string {
// ── Commands ─────────────────────────────────────────────────────────────── // ── Commands ───────────────────────────────────────────────────────────────
// errServerTooOld is what connecting to a server without teams looks like: the
// server has no version endpoint, so its missing /api/teams is the tell.
var errServerTooOld = errors.New("this server predates teams -- terdut-tui needs terdut-server v0.20 or later")
// connectCmd checks the server is up, then loads the caller's teams and identity
// with their key. /healthz is unauthenticated, so this is also the first thing
// to notice a wrong key.
func connectCmd(client *api.Client) tea.Cmd { func connectCmd(client *api.Client) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := client.HealthCheck(); err != nil { if err := client.HealthCheck(); err != nil {
return connectErrMsg{err} return connectErrMsg{err}
} }
return connectedMsg{} teams, err := client.ListTeams()
var se *api.StatusError
if errors.As(err, &se) && se.Code == http.StatusNotFound {
return connectErrMsg{errServerTooOld}
}
if err != nil {
return connectErrMsg{err}
}
me, err := client.Me()
if err != nil {
return connectErrMsg{err}
}
return connectedMsg{teams: teams, me: *me}
} }
} }
func fetchIncidentsCmd(client *api.Client, filter string) tea.Cmd { func fetchIncidentsCmd(client *api.Client, teamID int64, filter string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
status, snoozed := incidentQuery(filter) status, snoozed := incidentQuery(filter)
incidents, err := client.ListIncidents(status, false, snoozed, 500) incidents, err := client.ListIncidents(teamID, status, false, snoozed, 500)
if err != nil { if err != nil {
return fetchDataErrMsg{err} return fetchDataErrMsg{err}
} }
@@ -693,11 +986,11 @@ func fetchIncidentsCmd(client *api.Client, filter string) tea.Cmd {
} }
} }
func fetchArchivedIncidentsCmd(client *api.Client) tea.Cmd { func fetchArchivedIncidentsCmd(client *api.Client, teamID int64) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
// Archived incidents are all resolved, so the status filter has to be // Archived incidents are all resolved, so the status filter has to be
// widened past the server's open-only default or nothing comes back. // widened past the server's open-only default or nothing comes back.
incidents, err := client.ListIncidents(api.StatusResolved, true, false, 500) incidents, err := client.ListIncidents(teamID, api.StatusResolved, true, false, 500)
if err != nil { if err != nil {
return fetchDataErrMsg{err} return fetchDataErrMsg{err}
} }
@@ -705,13 +998,13 @@ func fetchArchivedIncidentsCmd(client *api.Client) tea.Cmd {
} }
} }
func fetchAlertsCmd(client *api.Client, filter string) tea.Cmd { func fetchAlertsCmd(client *api.Client, teamID int64, filter string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
status, archived := filter, false status, archived := filter, false
if filter == "archived" { if filter == "archived" {
status, archived = "", true status, archived = "", true
} }
alerts, err := client.ListAlerts(status, archived, 500) alerts, err := client.ListAlerts(teamID, status, archived, 500)
if err != nil { if err != nil {
return fetchDataErrMsg{err} return fetchDataErrMsg{err}
} }
@@ -811,13 +1104,13 @@ func deleteNoteCmd(client *api.Client, id, eventID int64) tea.Cmd {
// archiveIncidentCmd archives from the list view, so it reloads the list rather // archiveIncidentCmd archives from the list view, so it reloads the list rather
// than a detail pane. // than a detail pane.
func archiveIncidentCmd(client *api.Client, id int64, filter string) tea.Cmd { func archiveIncidentCmd(client *api.Client, id, teamID int64, filter string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if _, err := client.ArchiveIncident(id); err != nil { if _, err := client.ArchiveIncident(id); err != nil {
return actionErrMsg{err} return actionErrMsg{err}
} }
status, snoozed := incidentQuery(filter) status, snoozed := incidentQuery(filter)
incidents, err := client.ListIncidents(status, false, snoozed, 500) incidents, err := client.ListIncidents(teamID, status, false, snoozed, 500)
if err != nil { if err != nil {
return actionErrMsg{err} return actionErrMsg{err}
} }
@@ -825,12 +1118,12 @@ func archiveIncidentCmd(client *api.Client, id int64, filter string) tea.Cmd {
} }
} }
func unarchiveIncidentCmd(client *api.Client, id int64) tea.Cmd { func unarchiveIncidentCmd(client *api.Client, id, teamID int64) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := client.UnarchiveIncident(id); err != nil { if err := client.UnarchiveIncident(id); err != nil {
return actionErrMsg{err} return actionErrMsg{err}
} }
incidents, err := client.ListIncidents(api.StatusResolved, true, false, 500) incidents, err := client.ListIncidents(teamID, api.StatusResolved, true, false, 500)
if err != nil { if err != nil {
return actionErrMsg{err} return actionErrMsg{err}
} }
@@ -866,51 +1159,73 @@ func fetchDetailStatsCmd(client *api.Client) tea.Cmd {
} }
} }
func fetchScheduleCmd(client *api.Client, from, to time.Time) tea.Cmd { // loadSchedule reads one team's window and everyone's on-call today, the way
// every schedule command ends so the view reflects what the server now holds.
func loadSchedule(client *api.Client, teamID int64, from, to time.Time) (scheduleFetchedMsg, error) {
entries, err := client.GetSchedule(teamID, from.Format("2006-01-02"), to.Format("2006-01-02"))
if err != nil {
return scheduleFetchedMsg{}, err
}
current, err := client.GetCurrentOnCall()
if err != nil {
return scheduleFetchedMsg{}, err
}
return scheduleFetchedMsg{entries: entries, current: current}, nil
}
func fetchScheduleCmd(client *api.Client, teamID int64, from, to time.Time) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02")) msg, err := loadSchedule(client, teamID, from, to)
if err != nil { if err != nil {
return scheduleFetchErrMsg{err} return scheduleFetchErrMsg{err}
} }
current, err := client.GetCurrentOnCall() return msg
if err != nil {
return scheduleFetchErrMsg{err}
}
return scheduleFetchedMsg{entries: entries, current: current}
} }
} }
func assignScheduleCmd(client *api.Client, userID int64, dates []string, replace bool, from, to time.Time) tea.Cmd { func assignScheduleCmd(client *api.Client, teamID, userID int64, dates []string, replace bool, from, to time.Time) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if _, err := client.AssignSchedule(userID, dates, replace); err != nil { if _, err := client.AssignSchedule(teamID, userID, dates, replace); err != nil {
return scheduleActionErrMsg{err} return scheduleActionErrMsg{err}
} }
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02")) msg, err := loadSchedule(client, teamID, from, to)
if err != nil { if err != nil {
return scheduleActionErrMsg{err} return scheduleActionErrMsg{err}
} }
current, err := client.GetCurrentOnCall() return msg
if err != nil {
return scheduleActionErrMsg{err}
}
return scheduleFetchedMsg{entries: entries, current: current}
} }
} }
func deleteScheduleEntryCmd(client *api.Client, entryID int64, from, to time.Time) tea.Cmd { func deleteScheduleEntryCmd(client *api.Client, teamID, entryID int64, from, to time.Time) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := client.DeleteScheduleEntry(entryID); err != nil { if err := client.DeleteScheduleEntry(teamID, entryID); err != nil {
return scheduleActionErrMsg{err} return scheduleActionErrMsg{err}
} }
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02")) msg, err := loadSchedule(client, teamID, from, to)
if err != nil { if err != nil {
return scheduleActionErrMsg{err} return scheduleActionErrMsg{err}
} }
current, err := client.GetCurrentOnCall() return msg
if err != nil {
return scheduleActionErrMsg{err}
} }
return scheduleFetchedMsg{entries: entries, current: current} }
// fetchPickerCmd loads what the schedule's user picker offers: everyone, and who
// belongs to the team, since only members can be put on its rota.
func fetchPickerCmd(client *api.Client, teamID int64) tea.Cmd {
return func() tea.Msg {
users, err := client.ListUsers()
if err != nil {
return userActionErrMsg{err}
}
members, err := client.ListTeamMembers(teamID)
if err != nil {
return userActionErrMsg{err}
}
ids := make(map[int64]bool, len(members))
for _, mem := range members {
ids[mem.UserID] = true
}
return pickerReadyMsg{users: users, members: ids}
} }
} }
@@ -976,6 +1291,29 @@ func createAPIKeyCmd(client *api.Client, userID int64, name string) tea.Cmd {
} }
} }
func fetchMeCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
me, err := client.Me()
var se *api.StatusError
if errors.As(err, &se) && se.Code == http.StatusNotFound {
return userActionErrMsg{errors.New("this server has no passwords -- needs terdut-server v0.10.2 or later")}
}
if err != nil {
return userActionErrMsg{err}
}
return meFetchedMsg{me: *me}
}
}
func setPasswordCmd(client *api.Client, user api.User, password, current string) tea.Cmd {
return func() tea.Msg {
if err := client.SetPassword(user.ID, password, current); err != nil {
return userActionErrMsg{err}
}
return passwordSetMsg{username: user.Username}
}
}
func deleteAPIKeyCmd(client *api.Client, userID, keyID int64) tea.Cmd { func deleteAPIKeyCmd(client *api.Client, userID, keyID int64) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := client.DeleteAPIKey(userID, keyID); err != nil { if err := client.DeleteAPIKey(userID, keyID); err != nil {
+47 -22
View File
@@ -4,8 +4,9 @@ import (
"testing" "testing"
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/table"
"github.com/yeniklas/terdut-tui/internal/api"
) )
func TestNextFilter(t *testing.T) { func TestNextFilter(t *testing.T) {
@@ -142,7 +143,7 @@ func TestIncidentRows(t *testing.T) {
{Title: "Unowned", Status: api.StatusTriggered, TriggeredAt: time.Now()}, {Title: "Unowned", Status: api.StatusTriggered, TriggeredAt: time.Now()},
{Title: "Quiet", Status: api.StatusTriggered, Severity: "info", {Title: "Quiet", Status: api.StatusTriggered, Severity: "info",
AssignedTo: "alice", SnoozedUntil: &future, TriggeredAt: time.Now()}, AssignedTo: "alice", SnoozedUntil: &future, TriggeredAt: time.Now()},
}) }, false)
if len(rows) != 3 { if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows)) t.Fatalf("expected 3 rows, got %d", len(rows))
} }
@@ -158,12 +159,35 @@ func TestIncidentRows(t *testing.T) {
} }
} }
// Rows only carry a team cell when the columns have a Team header for it, or
// every cell after it would sit under the wrong heading.
func TestRows_TeamCellMatchesTeamColumn(t *testing.T) {
now := time.Now()
incRows := incidentRows([]api.Incident{{Title: "DiskFull", TeamName: "Ops", Status: api.StatusTriggered, TriggeredAt: now}}, true)
if got, want := len(incRows[0]), len(incidentColumns(120, true)); got != want {
t.Errorf("incident row has %d cells for %d columns", got, want)
}
if incRows[0][2] != "Ops" {
t.Errorf("expected the team after the title, got %v", incRows[0])
}
alRows := alertRows([]api.Alert{{Name: "DiskFull", StartsAt: now, ReceivedAt: now}}, true)
if got, want := len(alRows[0]), len(alertColumns(120, true)); got != want {
t.Errorf("alert row has %d cells for %d columns", got, want)
}
if alRows[0][1] != "—" {
t.Errorf("a missing team name should show an em dash, got %v", alRows[0])
}
if got, want := len(incidentRows([]api.Incident{{}}, false)[0]), len(incidentColumns(120, false)); got != want {
t.Errorf("incident row has %d cells for %d columns without teams", got, want)
}
}
func TestAlertRows_ShowIncidentLink(t *testing.T) { func TestAlertRows_ShowIncidentLink(t *testing.T) {
id := int64(7) id := int64(7)
rows := alertRows([]api.Alert{ rows := alertRows([]api.Alert{
{Name: "DiskFull", Status: "firing", IncidentID: &id}, {Name: "DiskFull", Status: "firing", IncidentID: &id},
{Name: "Orphan", Status: "resolved"}, {Name: "Orphan", Status: "resolved"},
}) }, false)
if rows[0][4] != "#7" { if rows[0][4] != "#7" {
t.Errorf("expected #7, got %q", rows[0][4]) t.Errorf("expected #7, got %q", rows[0][4])
} }
@@ -177,7 +201,7 @@ func TestAlertRows_ShowIncidentLink(t *testing.T) {
func TestUserManageRows_ShowMissingTopic(t *testing.T) { func TestUserManageRows_ShowMissingTopic(t *testing.T) {
topic := "terdut-niklas" topic := "terdut-niklas"
empty := "" empty := ""
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40 m.width, m.height = 120, 40
m.users = []api.User{ m.users = []api.User{
{ID: 1, Username: "niklas", NtfyTopic: &topic}, {ID: 1, Username: "niklas", NtfyTopic: &topic},
@@ -203,31 +227,31 @@ func TestUserManageRows_ShowMissingTopic(t *testing.T) {
// the window width. // the window width.
func TestColumnWidthsFitTheTerminal(t *testing.T) { func TestColumnWidthsFitTheTerminal(t *testing.T) {
for _, width := range []int{100, 110, 140, 200} { for _, width := range []int{100, 110, 140, 200} {
for name, cols := range map[string][]int{ // Five cells each, or six once a Team column is added. userManageColumns
"incident": widths(incidentColumns(width)), // is five cells as well: username, email, topic, flags, created.
"alert": widths(alertColumns(width)), for name, tc := range map[string]struct {
cols []table.Column
cells int
}{
"incident": {incidentColumns(width, false), 5},
"incident with team": {incidentColumns(width, true), 6},
"alert": {alertColumns(width, false), 5},
"alert with team": {alertColumns(width, true), 6},
"user": {userManageColumns(width), 5},
} { } {
sum := 0 sum := 0
for _, w := range cols { for _, w := range widths(tc.cols) {
sum += w sum += w
} }
const padding = 10 // bubbles applies Padding(0, 1) to each of five cells padding := 2 * tc.cells // bubbles applies Padding(0, 1) to each cell
if len(tc.cols) != tc.cells {
t.Errorf("%s has %d columns, expected %d", name, len(tc.cols), tc.cells)
}
if sum+padding != width { if sum+padding != width {
t.Errorf("%s columns at width %d sum to %d+%d = %d", t.Errorf("%s columns at width %d sum to %d+%d = %d",
name, width, sum, padding, sum+padding) name, width, sum, padding, sum+padding)
} }
} }
// The users table is four cells, so its padding budget differs.
sum := 0
for _, w := range widths(userManageColumns(width)) {
sum += w
}
const userPadding = 8
if sum+userPadding != width {
t.Errorf("user columns at width %d sum to %d+%d = %d",
width, sum, userPadding, sum+userPadding)
}
} }
} }
@@ -235,7 +259,7 @@ func TestColumnWidthsFitTheTerminal(t *testing.T) {
// what must not happen is a negative or zero column. // what must not happen is a negative or zero column.
func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) { func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) {
for _, width := range []int{20, 40, 60} { for _, width := range []int{20, 40, 60} {
cols := append(widths(incidentColumns(width)), widths(alertColumns(width))...) cols := append(widths(incidentColumns(width, true)), widths(alertColumns(width, true))...)
cols = append(cols, widths(userManageColumns(width))...) cols = append(cols, widths(userManageColumns(width))...)
for _, w := range cols { for _, w := range cols {
if w < 1 { if w < 1 {
@@ -283,9 +307,10 @@ func TestBuildScheduleDays(t *testing.T) {
// scheduledWeek builds a model showing the week of 2026-07-27 with the given // scheduledWeek builds a model showing the week of 2026-07-27 with the given
// entries already on the rota. // entries already on the rota.
func scheduledWeek(entries []api.ScheduleEntry) Model { func scheduledWeek(entries []api.ScheduleEntry) Model {
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40 m.width, m.height = 120, 40
m.connected = true m.connected = true
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleOwner}}
m.activeSection = sectionSchedule m.activeSection = sectionSchedule
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC) m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
m.scheduleEntries = entries m.scheduleEntries = entries
+143 -67
View File
@@ -3,97 +3,173 @@ package tui
import ( import (
"strings" "strings"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api"
) )
var ( // Styles is every style the views draw with, built once from a theme and held
colorPrimary = lipgloss.Color("69") // blue // on the Model. Nothing here reads a colour literal: the theme is the only
colorMuted = lipgloss.Color("240") // gray // place a colour is named.
colorFiring = lipgloss.Color("196") // red type Styles struct {
colorResolved = lipgloss.Color("70") // green Header lipgloss.Style
colorAccent = lipgloss.Color("214") // orange TabActive lipgloss.Style
TabInactive lipgloss.Style
Footer lipgloss.Style
Status lipgloss.Style
colorSevCritical = lipgloss.Color("196") // red Error lipgloss.Style
colorSevError = lipgloss.Color("202") // dark orange Firing lipgloss.Style
colorSevWarning = lipgloss.Color("214") // orange Resolved lipgloss.Style
colorSevInfo = lipgloss.Color("39") // cyan Muted lipgloss.Style
Accent lipgloss.Style
styleHeader = lipgloss.NewStyle(). AlertName lipgloss.Style
Bold(true). Bold lipgloss.Style
Foreground(colorPrimary). Selected lipgloss.Style
Padding(0, 1)
styleTabActive = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("0")).
Background(colorPrimary).
Padding(0, 2)
styleTabInactive = lipgloss.NewStyle().
Foreground(colorMuted).
Padding(0, 2)
styleFooter = lipgloss.NewStyle().
Foreground(colorMuted)
styleStatus = lipgloss.NewStyle().
Foreground(colorAccent).
Bold(true)
styleError = lipgloss.NewStyle().
Foreground(colorFiring).
Bold(true)
styleFiring = lipgloss.NewStyle().Foreground(colorFiring).Bold(true)
styleResolved = lipgloss.NewStyle().Foreground(colorResolved)
styleMuted = lipgloss.NewStyle().Foreground(colorMuted)
styleAlertName = lipgloss.NewStyle().Bold(true)
styleBold = lipgloss.NewStyle().Bold(true)
styleSelected = lipgloss.NewStyle().Foreground(colorPrimary).Bold(true)
styleAccent = lipgloss.NewStyle().Foreground(colorAccent)
// Incident status. Triggered is unclaimed work and reads as loudly as a // Incident status. Triggered is unclaimed work and reads as loudly as a
// firing alert; acknowledged means somebody has it. // firing alert; acknowledged means somebody has it.
styleTriggered = lipgloss.NewStyle().Foreground(colorFiring).Bold(true) Triggered lipgloss.Style
styleAcknowledged = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) Acknowledged lipgloss.Style
styleSnoozed = lipgloss.NewStyle().Foreground(colorMuted).Italic(true) Snoozed lipgloss.Style
// Severity, over the conventional Alertmanager label values. // Severity, over the conventional Alertmanager label values.
styleSevCritical = lipgloss.NewStyle().Foreground(colorSevCritical).Bold(true) SevCritical lipgloss.Style
styleSevError = lipgloss.NewStyle().Foreground(colorSevError).Bold(true) SevError lipgloss.Style
styleSevWarning = lipgloss.NewStyle().Foreground(colorSevWarning) SevWarning lipgloss.Style
styleSevInfo = lipgloss.NewStyle().Foreground(colorSevInfo) SevInfo lipgloss.Style
)
// severityStyle picks the style for a severity label, falling back to muted for theme theme.Theme
}
func newStyles(t theme.Theme) Styles {
return Styles{
Header: lipgloss.NewStyle().
Bold(true).
Foreground(t.Primary).
Padding(0, 1),
TabActive: lipgloss.NewStyle().
Bold(true).
Foreground(t.OnPrimary).
Background(t.Primary).
Padding(0, 2),
TabInactive: lipgloss.NewStyle().
Foreground(t.Muted).
Padding(0, 2),
Footer: lipgloss.NewStyle().Foreground(t.Muted),
Status: lipgloss.NewStyle().
Foreground(t.Accent).
Bold(true),
Error: lipgloss.NewStyle().
Foreground(t.Error).
Bold(true),
Firing: lipgloss.NewStyle().Foreground(t.Firing).Bold(true),
Resolved: lipgloss.NewStyle().Foreground(t.Resolved),
Muted: lipgloss.NewStyle().Foreground(t.Muted),
Accent: lipgloss.NewStyle().Foreground(t.Accent),
AlertName: lipgloss.NewStyle().Foreground(t.Text).Bold(true),
Bold: lipgloss.NewStyle().Foreground(t.Text).Bold(true),
Selected: lipgloss.NewStyle().Foreground(t.Primary).Bold(true),
Triggered: lipgloss.NewStyle().Foreground(t.Firing).Bold(true),
Acknowledged: lipgloss.NewStyle().Foreground(t.Accent).Bold(true),
Snoozed: lipgloss.NewStyle().Foreground(t.Muted).Italic(true),
SevCritical: lipgloss.NewStyle().Foreground(t.SevCritical).Bold(true),
SevError: lipgloss.NewStyle().Foreground(t.SevError).Bold(true),
SevWarning: lipgloss.NewStyle().Foreground(t.SevWarning),
SevInfo: lipgloss.NewStyle().Foreground(t.SevInfo),
theme: t,
}
}
// Severity picks the style for a severity label, falling back to muted for
// values this client does not recognise rather than dropping them. // values this client does not recognise rather than dropping them.
func severityStyle(severity string) lipgloss.Style { func (s Styles) Severity(severity string) lipgloss.Style {
switch strings.ToLower(severity) { switch strings.ToLower(severity) {
case "critical": case "critical":
return styleSevCritical return s.SevCritical
case "error": case "error":
return styleSevError return s.SevError
case "warning": case "warning":
return styleSevWarning return s.SevWarning
case "info": case "info":
return styleSevInfo return s.SevInfo
default: default:
return styleMuted return s.Muted
} }
} }
// incidentStatusStyle picks the style for an incident status, falling back to // IncidentStatus picks the style for an incident status, falling back to muted
// muted for statuses added after this client was built. // for statuses added after this client was built.
func incidentStatusStyle(status string) lipgloss.Style { func (s Styles) IncidentStatus(status string) lipgloss.Style {
switch status { switch status {
case api.StatusTriggered: case api.StatusTriggered:
return styleTriggered return s.Triggered
case api.StatusAcknowledged: case api.StatusAcknowledged:
return styleAcknowledged return s.Acknowledged
case api.StatusResolved: case api.StatusResolved:
return styleResolved return s.Resolved
default: default:
return styleMuted return s.Muted
} }
} }
// ── Embedded bubbles components ────────────────────────────────────────────
//
// Each ships its own hardcoded palette, so a theme that stopped at this
// package's own styles would leave a pink selected row and grey help text
// behind. These three restyle them from the same tokens.
// Table styles the six tables. Padding comes from the bubbles defaults; only
// the colours are ours.
func (s Styles) Table() table.Styles {
ts := table.DefaultStyles()
ts.Header = ts.Header.Foreground(s.theme.Muted).Bold(true)
// Cell deliberately keeps no foreground: bubbles renders each cell before
// wrapping the whole row in Selected, so a colour here would emit a reset
// mid-row and cut the selection highlight short.
ts.Selected = ts.Selected.
Foreground(s.theme.OnPrimary).
Background(s.theme.Primary).
Bold(true)
return ts
}
// Help styles the key hints in the footer.
func (s Styles) Help() help.Styles {
key := lipgloss.NewStyle().Foreground(s.theme.Text)
desc := lipgloss.NewStyle().Foreground(s.theme.Muted)
sep := lipgloss.NewStyle().Foreground(s.theme.Muted)
return help.Styles{
Ellipsis: sep,
ShortKey: key,
ShortDesc: desc,
ShortSeparator: sep,
FullKey: key,
FullDesc: desc,
FullSeparator: sep,
}
}
// Input styles a text input and returns it, so NewModel can wrap each one as
// it is built.
func (s Styles) Input(ti textinput.Model) textinput.Model {
ti.PromptStyle = lipgloss.NewStyle().Foreground(s.theme.Primary)
ti.TextStyle = lipgloss.NewStyle().Foreground(s.theme.Text)
ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(s.theme.Muted)
ti.CompletionStyle = lipgloss.NewStyle().Foreground(s.theme.Muted)
ti.Cursor.Style = lipgloss.NewStyle().Foreground(s.theme.Primary)
return ti
}
+131
View File
@@ -0,0 +1,131 @@
package tui
import (
"testing"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss"
)
// Assertions here read the colours off the styles rather than off rendered
// output: under `go test` stdout is not a TTY, so lipgloss strips every escape
// sequence and rendered strings would all compare equal.
func wantFg(t *testing.T, s lipgloss.Style, want lipgloss.Color, what string) {
t.Helper()
if got := s.GetForeground(); got != want {
t.Errorf("%s foreground = %v, want %v", what, got, want)
}
}
func TestStyles_TokensReachTheStyles(t *testing.T) {
th := theme.GruvboxDark
s := newStyles(th)
wantFg(t, s.Header, th.Primary, "header")
wantFg(t, s.Firing, th.Firing, "firing")
wantFg(t, s.Resolved, th.Resolved, "resolved")
wantFg(t, s.Muted, th.Muted, "muted")
wantFg(t, s.Status, th.Accent, "status")
wantFg(t, s.Error, th.Error, "error")
wantFg(t, s.SevWarning, th.SevWarning, "sev warning")
// The two inverted spots need the pair, not just a foreground.
wantFg(t, s.TabActive, th.OnPrimary, "active tab")
if got := s.TabActive.GetBackground(); got != th.Primary {
t.Errorf("active tab background = %v, want %v", got, th.Primary)
}
// Attributes the old package-level styles carried must survive.
if !s.Firing.GetBold() {
t.Error("firing lost its bold")
}
if !s.Snoozed.GetItalic() {
t.Error("snoozed lost its italic")
}
if top, right, bottom, left := s.TabActive.GetPadding(); top != 0 || right != 2 || bottom != 0 || left != 2 {
t.Errorf("active tab padding = %d %d %d %d, want 0 2 0 2", top, right, bottom, left)
}
}
func TestStyles_EachBuiltinIsFullyPopulated(t *testing.T) {
for _, th := range []theme.Theme{theme.GruvboxDark, theme.GruvboxLight} {
s := newStyles(th)
for what, style := range map[string]lipgloss.Style{
"header": s.Header, "tab inactive": s.TabInactive, "footer": s.Footer,
"status": s.Status, "error": s.Error, "firing": s.Firing,
"resolved": s.Resolved, "muted": s.Muted, "accent": s.Accent,
"alert name": s.AlertName, "bold": s.Bold, "selected": s.Selected,
"triggered": s.Triggered, "acknowledged": s.Acknowledged, "snoozed": s.Snoozed,
"sev critical": s.SevCritical, "sev error": s.SevError,
"sev warning": s.SevWarning, "sev info": s.SevInfo,
} {
if style.GetForeground() == (lipgloss.NoColor{}) {
t.Errorf("%s: %s has no foreground", th.Name, what)
}
}
}
}
func TestStyles_SeverityFallsBackToMuted(t *testing.T) {
s := newStyles(theme.GruvboxDark)
for _, sev := range []string{"critical", "CRITICAL", "error", "warning", "info"} {
if s.Severity(sev).GetForeground() == s.Muted.GetForeground() {
t.Errorf("severity %q should have its own colour", sev)
}
}
for _, sev := range []string{"", "page", "unknown"} {
if s.Severity(sev).GetForeground() != s.Muted.GetForeground() {
t.Errorf("severity %q should fall back to muted", sev)
}
}
}
func TestStyles_IncidentStatusFallsBackToMuted(t *testing.T) {
s := newStyles(theme.GruvboxDark)
for _, status := range []string{api.StatusTriggered, api.StatusAcknowledged, api.StatusResolved} {
if s.IncidentStatus(status).GetForeground() == s.Muted.GetForeground() {
t.Errorf("status %q should have its own colour", status)
}
}
if s.IncidentStatus("invented-later").GetForeground() != s.Muted.GetForeground() {
t.Error("an unknown status should fall back to muted")
}
}
// The bubbles components ship their own palettes — a pink selected row, grey
// help text, a 240 placeholder. These check we replaced them.
func TestStyles_BubblesComponentsFollowTheTheme(t *testing.T) {
th := theme.GruvboxDark
s := newStyles(th)
ts := s.Table()
wantFg(t, ts.Selected, th.OnPrimary, "table selection")
if got := ts.Selected.GetBackground(); got != th.Primary {
t.Errorf("table selection background = %v, want %v", got, th.Primary)
}
wantFg(t, ts.Header, th.Muted, "table header")
if _, right, _, left := ts.Cell.GetPadding(); right != 1 || left != 1 {
t.Error("table cell padding was lost")
}
// A foreground on Cell would emit a reset mid-row and truncate the
// selection highlight, so it must stay unset.
if ts.Cell.GetForeground() != (lipgloss.NoColor{}) {
t.Error("table cells must not carry a foreground")
}
h := s.Help()
wantFg(t, h.ShortKey, th.Text, "help key")
wantFg(t, h.ShortDesc, th.Muted, "help description")
wantFg(t, h.FullSeparator, th.Muted, "help separator")
in := s.Input(textinput.New())
wantFg(t, in.PlaceholderStyle, th.Muted, "input placeholder")
wantFg(t, in.TextStyle, th.Text, "input text")
wantFg(t, in.PromptStyle, th.Primary, "input prompt")
wantFg(t, in.Cursor.Style, th.Primary, "input cursor")
}
+301 -31
View File
@@ -1,13 +1,15 @@
package tui package tui
import ( import (
"fmt"
"slices"
"strconv" "strconv"
"strings" "strings"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"github.com/atotto/clipboard" "github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
) )
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -32,12 +34,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// ── Dashboard messages ──────────────────────────────────────────────── // ── Dashboard messages ────────────────────────────────────────────────
case connectedMsg: case connectedMsg:
firstConnect := len(m.teams) == 0
m.connected = true m.connected = true
m.err = nil m.err = nil
m.teams = msg.teams
m.meID = msg.me.User.ID
m.isAdmin = msg.me.User.IsAdmin
var statusCmd tea.Cmd
if firstConnect && m.activeTeamID == 0 && m.defaultTeam != "" {
if t, ok := resolveTeam(m.teams, m.defaultTeam); ok {
m.activeTeamID = t.ID
} else {
m.statusMsg = fmt.Sprintf("team %q not found -- showing all teams", m.defaultTeam)
statusCmd = clearStatusCmd()
}
}
m.rebuildIncidentTable()
m.rebuildTable()
m.rebuildArchivedTable()
return m, tea.Batch( return m, tea.Batch(
tickCmd(m.refreshInterval), tickCmd(m.refreshInterval),
fetchIncidentsCmd(m.client, m.incidentFilter), fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter),
fetchStatsCmd(m.client), fetchStatsCmd(m.client),
statusCmd,
) )
case connectErrMsg: case connectErrMsg:
@@ -157,6 +176,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.rebuildUserManageTable() m.rebuildUserManageTable()
return m, nil return m, nil
case pickerReadyMsg:
if m.mode != modeUserPicker {
return m, nil // the picker was closed before the lookup came back
}
m.users = msg.users
m.pickerMembers = msg.members
m.usersLoading = false
m.rebuildUserPickerTable()
m.rebuildUserManageTable()
return m, nil
case apiKeyCreatedMsg: case apiKeyCreatedMsg:
m.revealedAPIKey = msg.key m.revealedAPIKey = msg.key
m.mode = modeAPIKeyReveal m.mode = modeAPIKeyReveal
@@ -167,8 +197,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.mode = modeDashboard m.mode = modeDashboard
return m, clearStatusCmd() return m, clearStatusCmd()
case meFetchedMsg:
if m.mode != modePasswordSet {
return m, nil // the form was closed before the lookup came back
}
m.pwLoading = false
m.pwNeedCurrent = msg.me.User.ID == m.selectedUser.ID && msg.me.HasPassword
m.pwFocus = pwNew
if m.pwNeedCurrent {
m.pwFocus = pwCurrent
}
m.pwInputs[m.pwFocus].Focus()
return m, nil
case passwordSetMsg:
m.statusMsg = "password set for " + msg.username + " -- their other web sessions were signed out"
return m, clearStatusCmd()
case userActionErrMsg: case userActionErrMsg:
m.usersLoading = false m.usersLoading = false
m.pwLoading = false
m.blurPasswordForm()
m.statusMsg = "error: " + msg.err.Error() m.statusMsg = "error: " + msg.err.Error()
m.mode = modeDashboard m.mode = modeDashboard
return m, clearStatusCmd() return m, clearStatusCmd()
@@ -205,22 +254,32 @@ func (m Model) refreshActiveSection() tea.Cmd {
switch m.activeSection { switch m.activeSection {
case sectionIncidents: case sectionIncidents:
return tea.Batch(fetchIncidentsCmd(m.client, m.incidentFilter), fetchStatsCmd(m.client)) return tea.Batch(fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter), fetchStatsCmd(m.client))
case sectionAlerts: case sectionAlerts:
return tea.Batch(fetchAlertsCmd(m.client, m.alertFilter), fetchStatsCmd(m.client)) return tea.Batch(fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter), fetchStatsCmd(m.client))
case sectionStats: case sectionStats:
// Both: fetchStatsCmd feeds the Incident Response block, the other the charts. // Both: fetchStatsCmd feeds the Incident Response block, the other the charts.
return tea.Batch(fetchStatsCmd(m.client), fetchDetailStatsCmd(m.client)) return tea.Batch(fetchStatsCmd(m.client), fetchDetailStatsCmd(m.client))
case sectionArchived: case sectionArchived:
return fetchArchivedIncidentsCmd(m.client) return fetchArchivedIncidentsCmd(m.client, m.activeTeamID)
case sectionSchedule: case sectionSchedule:
return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) return m.fetchScheduleWindowCmd()
case sectionUsers: case sectionUsers:
return fetchUsersCmd(m.client) return fetchUsersCmd(m.client)
} }
return nil return nil
} }
// fetchScheduleWindowCmd reloads the schedule window for the schedule's team, or
// does nothing when the caller belongs to none.
func (m Model) fetchScheduleWindowCmd() tea.Cmd {
team, ok := m.scheduleTeam()
if !ok {
return nil
}
return fetchScheduleCmd(m.client, team.ID, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
}
// routeKey passes the key to the active component then to our handler. // routeKey passes the key to the active component then to our handler.
func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) { func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch m.mode { switch m.mode {
@@ -279,6 +338,14 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case modeAPIKeyMenu, modeAPIKeyReveal: case modeAPIKeyMenu, modeAPIKeyReveal:
return m.handleKey(msg) return m.handleKey(msg)
case modePasswordSet:
var inputCmd tea.Cmd
if !m.pwLoading {
m.pwInputs[m.pwFocus], inputCmd = m.pwInputs[m.pwFocus].Update(msg)
}
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
default: // modeDashboard default: // modeDashboard
if m.connected { if m.connected {
switch m.activeSection { switch m.activeSection {
@@ -338,6 +405,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m.handleUserNotifyEditKey(msg) return m.handleUserNotifyEditKey(msg)
case modeAPIKeyMenu: case modeAPIKeyMenu:
return m.handleAPIKeyMenuKey(msg) return m.handleAPIKeyMenuKey(msg)
case modePasswordSet:
return m.handlePasswordKey(msg)
case modeAPIKeyCreate: case modeAPIKeyCreate:
return m.handleAPIKeyCreateKey(msg) return m.handleAPIKeyCreateKey(msg)
case modeAPIKeyReveal: case modeAPIKeyReveal:
@@ -379,14 +448,20 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case sectionIncidents: case sectionIncidents:
m.incidentFilter = nextFilter(incidentFilters, m.incidentFilter) m.incidentFilter = nextFilter(incidentFilters, m.incidentFilter)
m.loading = true m.loading = true
return m, fetchIncidentsCmd(m.client, m.incidentFilter) return m, fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter)
case sectionAlerts: case sectionAlerts:
m.alertFilter = nextFilter(alertFilters, m.alertFilter) m.alertFilter = nextFilter(alertFilters, m.alertFilter)
m.loading = true m.loading = true
return m, fetchAlertsCmd(m.client, m.alertFilter) return m, fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter)
} }
return m, nil return m, nil
case "T":
if !m.connected || len(m.teams) == 0 {
return m, nil
}
return m.switchTeam()
case "enter": case "enter":
switch m.activeSection { switch m.activeSection {
case sectionIncidents: case sectionIncidents:
@@ -419,14 +494,14 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.statusMsg = "resolve the incident before archiving it" m.statusMsg = "resolve the incident before archiving it"
return m, clearStatusCmd() return m, clearStatusCmd()
} }
return m, archiveIncidentCmd(m.client, inc.ID, m.incidentFilter) return m, archiveIncidentCmd(m.client, inc.ID, m.activeTeamID, m.incidentFilter)
case sectionArchived: case sectionArchived:
i := m.archivedTable.Cursor() i := m.archivedTable.Cursor()
if i < 0 || i >= len(m.archivedIncidents) { if i < 0 || i >= len(m.archivedIncidents) {
return m, nil return m, nil
} }
m.archivedLoading = true m.archivedLoading = true
return m, unarchiveIncidentCmd(m.client, m.archivedIncidents[i].ID) return m, unarchiveIncidentCmd(m.client, m.archivedIncidents[i].ID, m.activeTeamID)
} }
return m, nil return m, nil
@@ -434,16 +509,18 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case "left", "h": case "left", "h":
if m.activeSection == sectionSchedule { if m.activeSection == sectionSchedule {
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, -7) m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, -7)
m.scheduleLoading = true cmd := m.fetchScheduleWindowCmd()
return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) m.scheduleLoading = cmd != nil
return m, cmd
} }
return m, nil return m, nil
case "right", "l": case "right", "l":
if m.activeSection == sectionSchedule { if m.activeSection == sectionSchedule {
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, 7) m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, 7)
m.scheduleLoading = true cmd := m.fetchScheduleWindowCmd()
return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) m.scheduleLoading = cmd != nil
return m, cmd
} }
return m, nil return m, nil
@@ -451,6 +528,9 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if m.activeSection != sectionSchedule || !m.connected { if m.activeSection != sectionSchedule || !m.connected {
return m, nil return m, nil
} }
if !m.checkScheduleEditable() {
return m, clearStatusCmd()
}
m.pickerAssignWeek = false m.pickerAssignWeek = false
return m.openUserPicker(pickerSchedule) return m.openUserPicker(pickerSchedule)
@@ -458,6 +538,9 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if m.activeSection != sectionSchedule || !m.connected { if m.activeSection != sectionSchedule || !m.connected {
return m, nil return m, nil
} }
if !m.checkScheduleEditable() {
return m, clearStatusCmd()
}
m.pickerAssignWeek = true m.pickerAssignWeek = true
return m.openUserPicker(pickerSchedule) return m.openUserPicker(pickerSchedule)
@@ -468,7 +551,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
cursor := m.scheduleTable.Cursor() cursor := m.scheduleTable.Cursor()
if cursor >= len(m.scheduleDays) { if cursor < 0 || cursor >= len(m.scheduleDays) {
return m, nil return m, nil
} }
day := m.scheduleDays[cursor] day := m.scheduleDays[cursor]
@@ -484,9 +567,13 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
cursor := m.userManageTable.Cursor() cursor := m.userManageTable.Cursor()
if cursor >= len(m.users) { if cursor < 0 || cursor >= len(m.users) {
return m, nil return m, nil
} }
if !m.isAdmin {
m.statusMsg = "only administrators can delete users"
return m, clearStatusCmd()
}
m.selectedUser = m.users[cursor] m.selectedUser = m.users[cursor]
m.confirmTarget = confirmDeleteUser m.confirmTarget = confirmDeleteUser
m.mode = modeConfirm m.mode = modeConfirm
@@ -497,6 +584,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if m.activeSection != sectionUsers || !m.connected { if m.activeSection != sectionUsers || !m.connected {
return m, nil return m, nil
} }
if !m.isAdmin {
m.statusMsg = "only administrators can create users"
return m, clearStatusCmd()
}
m.userFormInputs[0].Reset() m.userFormInputs[0].Reset()
m.userFormInputs[1].Reset() m.userFormInputs[1].Reset()
m.userFormFocus = 0 m.userFormFocus = 0
@@ -510,10 +601,14 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
cursor := m.userManageTable.Cursor() cursor := m.userManageTable.Cursor()
if cursor >= len(m.users) { if cursor < 0 || cursor >= len(m.users) {
return m, nil return m, nil
} }
m.selectedUser = m.users[cursor] m.selectedUser = m.users[cursor]
if !m.canManageUser(m.selectedUser) {
cmd := m.refuseUserAction("notification topic")
return m, cmd
}
// Prefilled with what they have, so editing a topic does not mean // Prefilled with what they have, so editing a topic does not mean
// retyping it, and clearing one is a deliberate wipe. // retyping it, and clearing one is a deliberate wipe.
m.ntfyTopicInput.SetValue(m.selectedUser.Topic()) m.ntfyTopicInput.SetValue(m.selectedUser.Topic())
@@ -523,6 +618,22 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
case "k": case "k":
if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 {
return m, nil
}
cursor := m.userManageTable.Cursor()
if cursor < 0 || cursor >= len(m.users) {
return m, nil
}
m.selectedUser = m.users[cursor]
if !m.canManageUser(m.selectedUser) {
cmd := m.refuseUserAction("API keys")
return m, cmd
}
m.mode = modeAPIKeyMenu
return m, nil
case "p":
if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 { if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 {
return m, nil return m, nil
} }
@@ -531,20 +642,39 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
m.selectedUser = m.users[cursor] m.selectedUser = m.users[cursor]
m.mode = modeAPIKeyMenu if !m.canManageUser(m.selectedUser) {
return m, nil cmd := m.refuseUserAction("password")
return m, cmd
}
for i := range m.pwInputs {
m.pwInputs[i].Reset()
m.pwInputs[i].Blur()
}
m.pwNeedCurrent = false
m.pwLoading = true
m.mode = modePasswordSet
// Whether the form needs the current password depends on who the key
// belongs to, which the client does not otherwise know.
return m, fetchMeCmd(m.client)
} }
return m, nil return m, nil
} }
// refuseUserAction explains a self-or-admin action declined up front, and is what
// the status bar clears afterwards. The server refuses these with a 403 anyway.
func (m *Model) refuseUserAction(what string) tea.Cmd {
m.statusMsg = "only administrators can change another user's " + what
return clearStatusCmd()
}
// loadSectionIfEmpty fetches a section's data the first time it is opened. // loadSectionIfEmpty fetches a section's data the first time it is opened.
func (m *Model) loadSectionIfEmpty() tea.Cmd { func (m *Model) loadSectionIfEmpty() tea.Cmd {
switch m.activeSection { switch m.activeSection {
case sectionAlerts: case sectionAlerts:
if len(m.alerts) == 0 { if len(m.alerts) == 0 {
m.loading = true m.loading = true
return fetchAlertsCmd(m.client, m.alertFilter) return fetchAlertsCmd(m.client, m.activeTeamID, m.alertFilter)
} }
case sectionStats: case sectionStats:
if !m.statsLoaded { if !m.statsLoaded {
@@ -554,12 +684,13 @@ func (m *Model) loadSectionIfEmpty() tea.Cmd {
case sectionArchived: case sectionArchived:
if len(m.archivedIncidents) == 0 { if len(m.archivedIncidents) == 0 {
m.archivedLoading = true m.archivedLoading = true
return fetchArchivedIncidentsCmd(m.client) return fetchArchivedIncidentsCmd(m.client, m.activeTeamID)
} }
case sectionSchedule: case sectionSchedule:
if len(m.scheduleDays) == 0 { if len(m.scheduleDays) == 0 {
m.scheduleLoading = true cmd := m.fetchScheduleWindowCmd()
return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) m.scheduleLoading = cmd != nil
return cmd
} }
case sectionUsers: case sectionUsers:
if len(m.users) == 0 { if len(m.users) == 0 {
@@ -590,6 +721,18 @@ func (m Model) openIncident(inc api.Incident) (Model, tea.Cmd) {
func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) { func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
m.pickerTarget = target m.pickerTarget = target
m.mode = modeUserPicker m.mode = modeUserPicker
if target == pickerSchedule {
// Only the team's own members can go on its rota, so who they are has to
// be known before anybody is offered.
team, ok := m.scheduleTeam()
if !ok {
m.mode = modeDashboard
return m, nil
}
m.pickerMembers = nil
m.usersLoading = true
return m, fetchPickerCmd(m.client, team.ID)
}
if len(m.users) == 0 { if len(m.users) == 0 {
m.usersLoading = true m.usersLoading = true
return m, fetchUsersCmd(m.client) return m, fetchUsersCmd(m.client)
@@ -598,6 +741,63 @@ func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
return m, nil return m, nil
} }
// switchTeam steps the active team through all teams, then each of the caller's
// teams in turn, and reloads what depends on it. Sections that are not on screen
// are emptied rather than fetched, so they load when next opened; the incident
// queue is the exception because it is what the caller returns to.
func (m Model) switchTeam() (Model, tea.Cmd) {
next := int64(0)
if m.activeTeamID == 0 {
next = m.teams[0].ID
} else {
for i, t := range m.teams {
if t.ID == m.activeTeamID && i+1 < len(m.teams) {
next = m.teams[i+1].ID
}
}
}
m.activeTeamID = next
m.incidents, m.alerts, m.archivedIncidents = nil, nil, nil
m.scheduleEntries, m.scheduleDays, m.currentOnCall = nil, nil, nil
m.loading = true
m.rebuildIncidentTable()
m.rebuildTable()
m.rebuildArchivedTable()
m.rebuildScheduleTable()
label := "all teams"
if t, ok := m.activeTeam(); ok {
label = t.Name
}
m.statusMsg = "Team: " + label
cmds := []tea.Cmd{clearStatusCmd()}
if m.activeSection != sectionIncidents {
cmds = append(cmds, fetchIncidentsCmd(m.client, m.activeTeamID, m.incidentFilter))
}
cmds = append(cmds, m.refreshActiveSection())
if m.activeSection == sectionSchedule {
m.scheduleLoading = true
}
return m, tea.Batch(cmds...)
}
// checkScheduleEditable says why a schedule change is refused, in the status
// bar, and reports whether it may go ahead. The server enforces the same rule.
func (m *Model) checkScheduleEditable() bool {
team, ok := m.scheduleTeam()
switch {
case !ok:
m.statusMsg = "you are not in any team"
case !m.canEditSchedule(team):
m.statusMsg = "only owners of " + team.Name + " can change its schedule"
default:
return true
}
return false
}
// nextFilter advances a filter cycle, wrapping at the end. // nextFilter advances a filter cycle, wrapping at the end.
func nextFilter(cycle []string, current string) string { func nextFilter(cycle []string, current string) string {
for i, f := range cycle { for i, f := range cycle {
@@ -681,10 +881,10 @@ func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if inc.ArchivedAt != nil { if inc.ArchivedAt != nil {
m.archivedLoading = true m.archivedLoading = true
m.mode = modeDashboard m.mode = modeDashboard
return m, unarchiveIncidentCmd(m.client, inc.ID) return m, unarchiveIncidentCmd(m.client, inc.ID, m.activeTeamID)
} }
m.mode = modeDashboard m.mode = modeDashboard
return m, archiveIncidentCmd(m.client, inc.ID, m.incidentFilter) return m, archiveIncidentCmd(m.client, inc.ID, m.activeTeamID, m.incidentFilter)
case "c": case "c":
m.mode = modeNote m.mode = modeNote
@@ -844,7 +1044,8 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeDashboard m.mode = modeDashboard
m.pendingDeleteEntry = nil m.pendingDeleteEntry = nil
m.scheduleLoading = true m.scheduleLoading = true
return m, deleteScheduleEntryCmd(m.client, entry.ID, team, _ := m.scheduleTeam()
return m, deleteScheduleEntryCmd(m.client, team.ID, entry.ID,
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
case confirmDeleteUser: case confirmDeleteUser:
@@ -861,7 +1062,8 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
m.scheduleLoading = true m.scheduleLoading = true
return m, assignScheduleCmd(m.client, p.userID, p.dates, true, team, _ := m.scheduleTeam()
return m, assignScheduleCmd(m.client, team.ID, p.userID, p.dates, true,
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
} }
@@ -882,10 +1084,11 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case "enter": case "enter":
cursor := m.userPickerTable.Cursor() cursor := m.userPickerTable.Cursor()
if cursor < 0 || cursor >= len(m.users) { pickable := m.pickerUsers()
if cursor < 0 || cursor >= len(pickable) {
return m, nil return m, nil
} }
user := m.users[cursor] user := pickable[cursor]
if m.pickerTarget == pickerIncidentAssignee { if m.pickerTarget == pickerIncidentAssignee {
m.mode = modeIncidentDetail m.mode = modeIncidentDetail
@@ -893,7 +1096,7 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
} }
scheduleCursor := m.scheduleTable.Cursor() scheduleCursor := m.scheduleTable.Cursor()
if scheduleCursor >= len(m.scheduleDays) { if scheduleCursor < 0 || scheduleCursor >= len(m.scheduleDays) {
m.mode = modeDashboard m.mode = modeDashboard
return m, nil return m, nil
} }
@@ -935,7 +1138,8 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
// Nobody else loses anything, but the server rejects any date that // Nobody else loses anything, but the server rejects any date that
// already exists — including days this same person already holds, which // already exists — including days this same person already holds, which
// is a no-op worth letting through silently. // is a no-op worth letting through silently.
return m, assignScheduleCmd(m.client, user.ID, dates, m.scheduleOccupied(dates), team, _ := m.scheduleTeam()
return m, assignScheduleCmd(m.client, team.ID, user.ID, dates, m.scheduleOccupied(dates),
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
} }
@@ -1127,3 +1331,69 @@ func (m Model) handleAPIKeyRevokeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
// ── Set password ──────────────────────────────────────────────────────────────
// pwFields is the set-password form's fields in tab order.
func (m Model) pwFields() []int {
if m.pwNeedCurrent {
return []int{pwCurrent, pwNew, pwRepeat}
}
return []int{pwNew, pwRepeat}
}
func (m *Model) blurPasswordForm() {
for i := range m.pwInputs {
m.pwInputs[i].Blur()
}
}
func (m Model) handlePasswordKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.blurPasswordForm()
m.pwLoading = false
m.mode = modeDashboard
return m, nil
}
if m.pwLoading {
return m, nil
}
switch msg.String() {
case "tab", "shift+tab":
fields := m.pwFields()
i := slices.Index(fields, m.pwFocus)
step := 1
if msg.String() == "shift+tab" {
step = len(fields) - 1
}
m.pwInputs[m.pwFocus].Blur()
m.pwFocus = fields[(i+step)%len(fields)]
m.pwInputs[m.pwFocus].Focus()
return m, nil
case "enter":
password := m.pwInputs[pwNew].Value()
switch {
case m.pwNeedCurrent && m.pwInputs[pwCurrent].Value() == "":
m.statusMsg = "enter your current password"
return m, clearStatusCmd()
case len(password) < minPasswordLen:
m.statusMsg = fmt.Sprintf("the password must be at least %d characters", minPasswordLen)
return m, clearStatusCmd()
case password != m.pwInputs[pwRepeat].Value():
m.statusMsg = "the two new passwords do not match"
return m, clearStatusCmd()
}
current := ""
if m.pwNeedCurrent {
current = m.pwInputs[pwCurrent].Value()
}
m.blurPasswordForm()
m.mode = modeDashboard
m.statusMsg = "Setting password…"
return m, setPasswordCmd(m.client, m.selectedUser, password, current)
}
return m, nil
}
+299 -2
View File
@@ -5,8 +5,9 @@ import (
"testing" "testing"
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
) )
// press sends one key and returns the resulting model and command. A nil command // press sends one key and returns the resulting model and command. A nil command
@@ -31,9 +32,10 @@ func press(t *testing.T, m Model, key string) (Model, tea.Cmd) {
// sized returns a connected model with a usable window, which most handlers need. // sized returns a connected model with a usable window, which most handlers need.
func sized() Model { func sized() Model {
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40 m.width, m.height = 120, 40
m.connected = true m.connected = true
m.isAdmin = true // most handlers are being tested for what they do, not who may
return m return m
} }
@@ -319,6 +321,8 @@ func pickingOnCall(entries []api.ScheduleEntry, dayIndex int, week bool) Model {
{ID: 1, Username: "niklas", Email: "n@example.com"}, {ID: 1, Username: "niklas", Email: "n@example.com"},
{ID: 2, Username: "alex", Email: "a@example.com"}, {ID: 2, Username: "alex", Email: "a@example.com"},
} }
m.pickerTarget = pickerSchedule
m.pickerMembers = map[int64]bool{1: true, 2: true}
m.rebuildUserPickerTable() m.rebuildUserPickerTable()
m.scheduleTable.SetCursor(dayIndex) m.scheduleTable.SetCursor(dayIndex)
m.pickerAssignWeek = week m.pickerAssignWeek = week
@@ -676,3 +680,296 @@ func containsAll(s string, subs ...string) bool {
} }
return true return true
} }
// The bug: assigning an on-call week panicked with "index out of range [-1]"
// on a perfectly normal schedule, as long as nobody had moved the cursor first.
//
// The cause is not in this package. bubbles' SetRows clamps the cursor down but
// never up, so the empty rebuild every table gets from the first WindowSizeMsg
// -- which arrives before any fetch returns -- pins the cursor at -1, and
// loading real rows afterwards leaves it there. Pressing up or down hid it,
// which is why every existing test missed it: they all call SetCursor, and
// SetCursor clamps.
//
// So this test must NOT touch the cursor. It reproduces the real order of
// events: size first, data second, keys third.
func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.connected = true
m.isAdmin = true
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleOwner}}
m.activeSection = sectionSchedule
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
// 1. Terminal size arrives while every table is still empty.
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = next.(Model)
// 2. The schedule and the user list land.
next, _ = m.Update(scheduleFetchedMsg{entries: []api.ScheduleEntry{}})
m = next.(Model)
next, _ = m.Update(usersFetchedMsg{users: []api.User{
{ID: 1, Username: "niklas", Email: "n@example.com"},
}})
m = next.(Model)
if got := m.scheduleTable.Cursor(); got < 0 {
t.Fatalf("schedule cursor is %d after loading %d days; a populated table must have a usable cursor",
got, len(m.scheduleDays))
}
// 3. Assign the week to the first user, without ever moving a cursor.
m, _ = press(t, m, "W")
if m.mode != modeUserPicker {
t.Fatalf("W did not open the user picker, got mode %v", m.mode)
}
// The picker waits for the team's members before offering anybody.
next, _ = m.Update(pickerReadyMsg{
users: []api.User{{ID: 1, Username: "niklas", Email: "n@example.com"}},
members: map[int64]bool{1: true},
})
m = next.(Model)
m, _ = press(t, m, "enter") // panicked here
if m.mode == modeUserPicker {
t.Fatal("enter left the picker open; the assignment never went anywhere")
}
}
// ── Teams ─────────────────────────────────────────────────────────────────
func twoTeams() []api.Team {
return []api.Team{
{ID: 1, Name: "Ops", Role: api.RoleOwner},
{ID: 2, Name: "Dev", Role: api.RoleMember},
}
}
func TestConnected_LoadsTeamsAndWhoIAm(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40
next, _ := m.Update(connectedMsg{
teams: twoTeams(),
me: api.Me{User: api.User{ID: 7, IsAdmin: true}},
})
m = next.(Model)
if len(m.teams) != 2 || m.meID != 7 || !m.isAdmin {
t.Errorf("expected teams, id and admin flag to be kept, got %+v %d %v", m.teams, m.meID, m.isAdmin)
}
if m.activeTeamID != 0 {
t.Errorf("with no default team every team shows, got active %d", m.activeTeamID)
}
}
func TestConnected_DefaultTeamFromConfig(t *testing.T) {
for _, want := range []string{"dev", "2"} { // by name, any case, or by id
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark).WithDefaultTeam(want)
next, _ := m.Update(connectedMsg{teams: twoTeams()})
if got := next.(Model).activeTeamID; got != 2 {
t.Errorf("default team %q: expected team 2, got %d", want, got)
}
}
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark).WithDefaultTeam("nope")
next, cmd := m.Update(connectedMsg{teams: twoTeams()})
m = next.(Model)
if m.activeTeamID != 0 || !strings.Contains(m.statusMsg, "nope") {
t.Errorf("an unknown default should fall back to all teams and say so, got %d %q",
m.activeTeamID, m.statusMsg)
}
if cmd == nil {
t.Error("expected the initial fetches to still be issued")
}
}
func TestSwitchTeam_CyclesAllThenEachTeam(t *testing.T) {
m := sized()
m.teams = twoTeams()
var seen []int64
for i := 0; i < 4; i++ {
var cmd tea.Cmd
m, cmd = press(t, m, "T")
if cmd == nil {
t.Fatal("switching team should reload")
}
seen = append(seen, m.activeTeamID)
}
want := []int64{1, 2, 0, 1}
for i := range want {
if seen[i] != want[i] {
t.Fatalf("expected the cycle %v, got %v", want, seen)
}
}
}
func TestSwitchTeam_ClearsRowsFromTheOtherTeam(t *testing.T) {
m := sized()
m.teams = twoTeams()
m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}}
m.rebuildIncidentTable()
m, _ = press(t, m, "T")
if len(m.incidents) != 0 {
t.Errorf("the previous team's incidents must not linger, got %d", len(m.incidents))
}
if !strings.Contains(m.statusMsg, "Ops") {
t.Errorf("expected the status bar to name the team, got %q", m.statusMsg)
}
}
func TestSwitchTeam_NoTeamsDoesNothing(t *testing.T) {
m, cmd := press(t, sized(), "T")
if cmd != nil || m.activeTeamID != 0 {
t.Errorf("without teams T has nothing to switch to")
}
}
func TestScheduleTeam(t *testing.T) {
m := sized()
if _, ok := m.scheduleTeam(); ok {
t.Error("no teams means no schedule")
}
m.teams = []api.Team{{ID: 2, Name: "Dev", Role: api.RoleMember}, {ID: 1, Name: "Ops", Role: api.RoleOwner}}
if tm, _ := m.scheduleTeam(); tm.ID != 1 {
t.Errorf("with all teams showing the one the caller owns is used, got %d", tm.ID)
}
m.activeTeamID = 2
if tm, _ := m.scheduleTeam(); tm.ID != 2 {
t.Errorf("the active team wins, got %d", tm.ID)
}
}
func TestSchedule_OnlyOwnersAndAdminsEdit(t *testing.T) {
m := scheduledWeek(nil)
m.isAdmin = false
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleMember}}
m, cmd := press(t, m, "+")
if m.mode == modeUserPicker {
t.Fatal("a plain member must not get as far as the picker")
}
if !strings.Contains(m.statusMsg, "owners of Ops") {
t.Errorf("expected the reason in the status bar, got %q", m.statusMsg)
}
if cmd == nil {
t.Error("the message should clear itself")
}
m.isAdmin = true // administrators may edit any team's rota
m.statusMsg = ""
m, _ = press(t, m, "+")
if m.mode != modeUserPicker {
t.Errorf("an administrator should reach the picker, got mode %v", m.mode)
}
}
// The picker fetches the team's members, and offers nobody until they arrive:
// the server answers 404 for anyone else.
func TestSchedulePicker_OffersOnlyTeamMembers(t *testing.T) {
m := scheduledWeek(nil)
m, cmd := press(t, m, "+")
if cmd == nil || !m.usersLoading {
t.Fatal("opening the picker should start loading the members")
}
if got := len(m.pickerUsers()); got != 0 {
t.Errorf("nobody should be offered before the members are known, got %d", got)
}
disabled := time.Now()
next, _ := m.Update(pickerReadyMsg{
users: []api.User{
{ID: 1, Username: "niklas"},
{ID: 2, Username: "outsider"},
{ID: 3, Username: "gone", DisabledAt: &disabled},
},
members: map[int64]bool{1: true, 3: true},
})
m = next.(Model)
got := m.pickerUsers()
if len(got) != 1 || got[0].Username != "niklas" {
t.Errorf("expected only the enabled member, got %+v", got)
}
if rows := m.userPickerTable.Rows(); len(rows) != 1 {
t.Errorf("the table should match, got %d rows", len(rows))
}
}
func TestUsers_NonAdminsCannotCreateOrDelete(t *testing.T) {
m := threeUsers()
m.isAdmin = false
m.meID = 1
m, _ = press(t, m, "n")
if m.mode != modeDashboard || !strings.Contains(m.statusMsg, "administrators") {
t.Errorf("n should be refused with a reason, got mode %v %q", m.mode, m.statusMsg)
}
m, _ = press(t, m, "d")
if m.mode != modeDashboard {
t.Errorf("d should not ask to delete for a non-admin, got mode %v", m.mode)
}
}
func TestUsers_NonAdminManagesOnlyThemselves(t *testing.T) {
m := threeUsers() // cursor on erik, id 3
m.isAdmin = false
m.meID = 1
for _, key := range []string{"t", "k", "p"} {
next, _ := press(t, m, key)
if next.mode != modeDashboard {
t.Errorf("%s on somebody else's row should be refused, got mode %v", key, next.mode)
}
if !strings.Contains(next.statusMsg, "another user's") {
t.Errorf("%s: expected the reason, got %q", key, next.statusMsg)
}
}
m.userManageTable.SetCursor(0) // niklas, id 1: themselves
if next, _ := press(t, m, "k"); next.mode != modeAPIKeyMenu {
t.Errorf("a user may manage their own keys, got mode %v", next.mode)
}
}
func TestUserFlags(t *testing.T) {
now := time.Now()
cases := []struct {
u api.User
want string
}{
{api.User{}, "—"},
{api.User{IsAdmin: true}, "admin"},
{api.User{DisabledAt: &now}, "disabled"},
{api.User{IsAdmin: true, DisabledAt: &now}, "admin,disabled"},
}
for _, c := range cases {
if got := userFlags(c.u); got != c.want {
t.Errorf("userFlags(%+v) = %q, want %q", c.u, got, c.want)
}
}
}
// Rebuilding a list on every refresh must not send the cursor back to the top.
func TestRefresh_KeepsTheCursor(t *testing.T) {
m := sized()
m.incidents = []api.Incident{{ID: 1}, {ID: 2}, {ID: 3}}
m.rebuildIncidentTable()
m.incidentTable.SetCursor(2)
next, _ := m.Update(incidentsFetchedMsg{incidents: m.incidents})
if got := next.(Model).incidentTable.Cursor(); got != 2 {
t.Errorf("expected the cursor to stay on row 2, got %d", got)
}
}
// The Team column comes and goes as the team switches, and the table must
// survive its column count changing under rows that are already loaded.
func TestTeamColumn_AppearsWithoutPanicking(t *testing.T) {
m := sized()
m.incidents = []api.Incident{{ID: 1, Title: "a", TeamName: "Ops"}}
m.rebuildIncidentTable()
m.teams = twoTeams()
m.rebuildIncidentTable() // five columns become six over a five-cell row
if got := len(m.incidentTable.Columns()); got != 6 {
t.Errorf("expected a Team column across two teams, got %d columns", got)
}
m.activeTeamID = 1
m.rebuildIncidentTable()
if got := len(m.incidentTable.Columns()); got != 5 {
t.Errorf("expected the Team column to go when one team is chosen, got %d", got)
}
}
+204
View File
@@ -0,0 +1,204 @@
package tui
import (
"strings"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
tea "github.com/charmbracelet/bubbletea"
)
// threeUsers is the Users section with the cursor on the last of three users.
func threeUsers() Model {
m := onUsers([]api.User{
{ID: 1, Username: "niklas"},
{ID: 2, Username: "anna"},
{ID: 3, Username: "erik"},
})
m.userManageTable.SetCursor(2)
return m
}
// The table used to see k before the section did, take it as "up", and the
// handler then opened API keys for the user above the one selected.
func TestUsers_APIKeysOpenForTheSelectedUser(t *testing.T) {
m, _ := press(t, threeUsers(), "k")
if m.mode != modeAPIKeyMenu {
t.Fatalf("expected the API key menu, got mode %v", m.mode)
}
if m.selectedUser.Username != "erik" {
t.Errorf("API keys opened for %s, want erik", m.selectedUser.Username)
}
}
// Same collision with d, which the table read as half a page down: the delete
// confirmation named a different user than the one under the cursor.
func TestUsers_DeleteTargetsTheSelectedUser(t *testing.T) {
m := threeUsers()
m.userManageTable.SetCursor(0)
m, _ = press(t, m, "d")
if m.mode != modeConfirm || m.selectedUser.Username != "niklas" {
t.Errorf("delete asked about %q in mode %v, want niklas", m.selectedUser.Username, m.mode)
}
}
func TestSchedule_DeleteTargetsTheSelectedDay(t *testing.T) {
m := sized()
m.activeSection = sectionSchedule
m.scheduleEntries = []api.ScheduleEntry{
{ID: 10, UserID: 1, Username: "niklas", Date: m.scheduleWindow.Format("2006-01-02")},
{ID: 11, UserID: 2, Username: "anna", Date: m.scheduleWindow.AddDate(0, 0, 1).Format("2006-01-02")},
}
m.scheduleDays = buildScheduleDays(m.scheduleWindow, m.scheduleEntries)
m.rebuildScheduleTable()
m.scheduleTable.SetCursor(0)
m, _ = press(t, m, "d")
if m.pendingDeleteEntry == nil || m.pendingDeleteEntry.ID != 10 {
t.Errorf("schedule delete targeted %+v, want entry 10", m.pendingDeleteEntry)
}
}
// f cycles the filter; it must not also page the cursor down.
func TestFilter_DoesNotMoveTheCursor(t *testing.T) {
m := sized()
m.incidents = make([]api.Incident, 40)
for i := range m.incidents {
m.incidents[i] = api.Incident{ID: int64(i + 1), Title: "x", Status: api.StatusTriggered, TriggeredAt: time.Now()}
}
m.rebuildIncidentTable()
m, _ = press(t, m, "f")
if c := m.incidentTable.Cursor(); c != 0 {
t.Errorf("f moved the cursor to %d", c)
}
}
// The arrow keys still move the users table, now that k is an action there.
func TestUsers_ArrowKeysStillNavigate(t *testing.T) {
m := threeUsers()
next, _ := m.Update(keyUp())
if c := next.(Model).userManageTable.Cursor(); c != 1 {
t.Errorf("up arrow left the cursor on %d, want 1", c)
}
}
func TestPassword_OpensForTheSelectedUserAndLooksUpWhoIAm(t *testing.T) {
m, cmd := press(t, threeUsers(), "p")
if m.mode != modePasswordSet || m.selectedUser.Username != "erik" {
t.Fatalf("expected the password form for erik, got mode %v for %q", m.mode, m.selectedUser.Username)
}
if !m.pwLoading || cmd == nil {
t.Error("the form should look up /api/me before it is usable")
}
if !strings.Contains(m.View(), "Checking who this key belongs to") {
t.Error("the form should say it is waiting")
}
}
func TestPassword_SomeoneElseNeedsNoCurrentPassword(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: true}})
m = next.(Model)
if m.pwNeedCurrent || m.pwFocus != pwNew {
t.Errorf("setting erik's password as niklas should not ask for a current one")
}
if strings.Contains(m.View(), "Current password") {
t.Error("the current-password field should be hidden")
}
}
func TestPassword_OwnExistingPasswordNeedsCurrent(t *testing.T) {
m := threeUsers()
m.userManageTable.SetCursor(0)
m, _ = press(t, m, "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: true}})
m = next.(Model)
if !m.pwNeedCurrent || m.pwFocus != pwCurrent {
t.Fatal("changing your own existing password should ask for the current one first")
}
m = typeInto(t, m, "correct horse")
m, _ = press(t, m, "tab")
m = typeInto(t, m, "a brand new secret")
m, _ = press(t, m, "tab")
m = typeInto(t, m, "a brand new secret")
m, cmd := press(t, m, "enter")
if cmd == nil || m.mode != modeDashboard {
t.Errorf("a complete form should submit (mode %v)", m.mode)
}
}
func TestPassword_OwnFirstPasswordNeedsNoCurrent(t *testing.T) {
m := threeUsers()
m.userManageTable.SetCursor(0)
m, _ = press(t, m, "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: false}})
if next.(Model).pwNeedCurrent {
t.Error("there is no current password to ask for yet")
}
}
func TestPassword_RejectsBeforeSending(t *testing.T) {
cases := []struct{ name, pw, repeat, want string }{
{"too short", "short", "short", "at least 10"},
{"mismatch", "a brand new secret", "a different secret", "do not match"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}}})
m = typeInto(t, next.(Model), tc.pw)
m, _ = press(t, m, "tab")
m = typeInto(t, m, tc.repeat)
m, cmd := press(t, m, "enter")
if m.mode != modePasswordSet {
t.Error("the form should stay open")
}
if !strings.Contains(m.statusMsg, tc.want) {
t.Errorf("status %q should mention %q", m.statusMsg, tc.want)
}
if cmd == nil {
return
}
// Only the clear-status timer may be scheduled, never a request.
if _, ok := cmd().(clearStatusMsg); !ok {
t.Error("nothing should be sent to the server")
}
})
}
}
func TestPassword_EscapeCancels(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
m, _ = press(t, m, "esc")
if m.mode != modeDashboard {
t.Errorf("esc should close the form, got mode %v", m.mode)
}
// A lookup arriving after the form closed must not reopen anything.
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 3}, HasPassword: true}})
if next.(Model).mode != modeDashboard {
t.Error("a late /api/me answer reopened the form")
}
}
func TestPassword_ErrorClosesTheFormWithAMessage(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
next, _ := m.Update(userActionErrMsg{errTest("server returned 403: current password is incorrect")})
m = next.(Model)
if m.mode != modeDashboard || !strings.Contains(m.statusMsg, "current password is incorrect") {
t.Errorf("expected the server's message on the dashboard, got %q in mode %v", m.statusMsg, m.mode)
}
}
func typeInto(t *testing.T, m Model, s string) Model {
t.Helper()
next, _ := m.Update(runes(s))
return next.(Model)
}
func runes(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} }
func keyUp() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyUp} }
type errTest string
func (e errTest) Error() string { return string(e) }
+200 -127
View File
@@ -6,8 +6,8 @@ import (
"strings" "strings"
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api"
) )
// Order must match the section constants — renderTabs indexes this by ordinal. // Order must match the section constants — renderTabs indexes this by ordinal.
@@ -26,40 +26,51 @@ func (m Model) View() string {
} }
func (m Model) renderHeader() string { func (m Model) renderHeader() string {
title := styleHeader.Render("terdut-tui") title := m.styles.Header.Render("terdut-tui")
right := styleMuted.Render(m.serverURL) if len(m.teams) > 0 {
title += m.styles.Muted.Render(" team: " + m.activeTeamLabel())
}
right := m.styles.Muted.Render(m.serverURL)
return spread(title, right, m.width) return spread(title, right, m.width)
} }
// activeTeamLabel names what the lists are narrowed to.
func (m Model) activeTeamLabel() string {
if t, ok := m.activeTeam(); ok {
return t.Name
}
return "all"
}
func (m Model) renderTabs() string { func (m Model) renderTabs() string {
var tabs []string var tabs []string
for i, name := range sectionNames { for i, name := range sectionNames {
if section(i) == m.activeSection { if section(i) == m.activeSection {
tabs = append(tabs, styleTabActive.Render(name)) tabs = append(tabs, m.styles.TabActive.Render(name))
} else { } else {
tabs = append(tabs, styleTabInactive.Render(name)) tabs = append(tabs, m.styles.TabInactive.Render(name))
} }
} }
sep := styleMuted.Render(strings.Repeat("─", m.width)) sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
return strings.Join(tabs, "") + "\n" + sep return strings.Join(tabs, "") + "\n" + sep
} }
func (m Model) renderBody() string { func (m Model) renderBody() string {
if m.err != nil { if m.err != nil {
return "\n" + styleError.Render(fmt.Sprintf(" Error: %v", m.err)) + return "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) +
"\n" + styleMuted.Render(" Press r to retry.") "\n" + m.styles.Muted.Render(" Press r to retry.")
} }
if !m.connected { if !m.connected {
return "\n" + styleMuted.Render(" Connecting…") return "\n" + m.styles.Muted.Render(" Connecting…")
} }
switch m.mode { switch m.mode {
case modeIncidentDetail, modeAlertDetail: case modeIncidentDetail, modeAlertDetail:
return m.renderDetail() return m.renderDetail()
case modeNote: case modeNote:
return m.renderPrompt(styleHeader.Render("Note: ") + m.noteInput.View()) return m.renderPrompt(m.styles.Header.Render("Note: ") + m.noteInput.View())
case modeSnooze: case modeSnooze:
return m.renderPrompt(styleHeader.Render("Snooze for: ") + m.snoozeInput.View()) return m.renderPrompt(m.styles.Header.Render("Snooze for: ") + m.snoozeInput.View())
case modeConfirm: case modeConfirm:
switch m.confirmTarget { switch m.confirmTarget {
case confirmDeleteNote, confirmResolveIncident: case confirmDeleteNote, confirmResolveIncident:
@@ -83,6 +94,8 @@ func (m Model) renderBody() string {
return m.renderAPIKeyReveal() return m.renderAPIKeyReveal()
case modeAPIKeyRevokeByID: case modeAPIKeyRevokeByID:
return m.renderAPIKeyRevokeByID() return m.renderAPIKeyRevokeByID()
case modePasswordSet:
return m.renderPasswordSet()
default: default:
return m.renderDashboard() return m.renderDashboard()
} }
@@ -90,9 +103,9 @@ func (m Model) renderBody() string {
func (m Model) renderFooter() string { func (m Model) renderFooter() string {
withStatus := func(actions string) string { withStatus := func(actions string) string {
rendered := styleFooter.Render(actions) rendered := m.styles.Footer.Render(actions)
if m.statusMsg != "" { if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + rendered return m.styles.Status.Render(" "+m.statusMsg) + "\n" + rendered
} }
return "\n" + rendered return "\n" + rendered
} }
@@ -108,13 +121,13 @@ func (m Model) renderFooter() string {
return withStatus(" i·open incident esc·back") return withStatus(" i·open incident esc·back")
case modeNote: case modeNote:
return "\n" + styleFooter.Render(" enter·submit esc·cancel") return "\n" + m.styles.Footer.Render(" enter·submit esc·cancel")
case modeSnooze: case modeSnooze:
return "\n" + styleFooter.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)") return "\n" + m.styles.Footer.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)")
case modeConfirm: case modeConfirm:
return "\n" + styleError.Render(" "+m.confirmPrompt()) return "\n" + m.styles.Error.Render(" "+m.confirmPrompt())
case modeUserPicker: case modeUserPicker:
if m.pickerTarget == pickerIncidentAssignee { if m.pickerTarget == pickerIncidentAssignee {
@@ -144,25 +157,36 @@ func (m Model) renderFooter() string {
case modeAPIKeyRevokeByID: case modeAPIKeyRevokeByID:
return withStatus(" enter·revoke esc·back") return withStatus(" enter·revoke esc·back")
case modePasswordSet:
return withStatus(" tab·next field enter·set password esc·cancel")
default: default:
switch m.activeSection { switch m.activeSection {
case sectionIncidents: case sectionIncidents:
return withStatus(" enter·detail x·archive f·filter r·refresh tab·section q·quit") return withStatus(" enter·detail x·archive f·filter " + m.teamHint() + "r·refresh tab·section q·quit")
case sectionAlerts: case sectionAlerts:
return withStatus(" enter·detail f·filter r·refresh tab·section q·quit") return withStatus(" enter·detail f·filter " + m.teamHint() + "r·refresh tab·section q·quit")
case sectionStats: case sectionStats:
return withStatus(" ↑/↓·scroll r·refresh tab·section q·quit") return withStatus(" ↑/↓·scroll r·refresh tab·section q·quit")
case sectionArchived: case sectionArchived:
return withStatus(" enter·detail x·unarchive r·refresh tab·section q·quit") return withStatus(" enter·detail x·unarchive " + m.teamHint() + "r·refresh tab·section q·quit")
case sectionSchedule: case sectionSchedule:
return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit") return withStatus(" +·assign day W·assign week d·del ←/→·shift week " + m.teamHint() + "tab·section r·refresh q·quit")
case sectionUsers: case sectionUsers:
return withStatus(" n·new user t·topic d·delete k·API keys r·refresh tab·section q·quit") return withStatus(" n·new user t·topic d·delete k·API keys p·password r·refresh tab·section q·quit")
} }
return "\n" + styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp())) return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
} }
} }
// teamHint is the footer's team-switch key, shown only when there is a choice.
func (m Model) teamHint() string {
if len(m.teams) > 1 {
return "T·team "
}
return ""
}
func (m Model) confirmPrompt() string { func (m Model) confirmPrompt() string {
switch m.confirmTarget { switch m.confirmTarget {
case confirmDeleteNote: case confirmDeleteNote:
@@ -241,9 +265,9 @@ func (m Model) renderIncidents() string {
var content string var content string
switch { switch {
case m.loading && len(m.incidents) == 0: case m.loading && len(m.incidents) == 0:
content = styleMuted.Render(" Loading incidents…") content = m.styles.Muted.Render(" Loading incidents…")
case len(m.incidents) == 0: case len(m.incidents) == 0:
content = styleMuted.Render( content = m.styles.Muted.Render(
fmt.Sprintf(" No %s incidents.", filterLabel(m.incidentFilter))) fmt.Sprintf(" No %s incidents.", filterLabel(m.incidentFilter)))
default: default:
content = m.incidentTable.View() content = m.incidentTable.View()
@@ -256,9 +280,9 @@ func (m Model) renderAlerts() string {
var content string var content string
switch { switch {
case m.loading && len(m.alerts) == 0: case m.loading && len(m.alerts) == 0:
content = styleMuted.Render(" Loading alerts…") content = m.styles.Muted.Render(" Loading alerts…")
case len(m.alerts) == 0: case len(m.alerts) == 0:
content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", filterLabel(m.alertFilter))) content = m.styles.Muted.Render(fmt.Sprintf(" No %s alerts.", filterLabel(m.alertFilter)))
default: default:
content = m.alertTable.View() content = m.alertTable.View()
} }
@@ -267,10 +291,10 @@ func (m Model) renderAlerts() string {
func (m Model) renderArchived() string { func (m Model) renderArchived() string {
if m.archivedLoading { if m.archivedLoading {
return "\n" + styleMuted.Render(" Loading archived incidents…") return "\n" + m.styles.Muted.Render(" Loading archived incidents…")
} }
if len(m.archivedIncidents) == 0 { if len(m.archivedIncidents) == 0 {
return "\n" + styleMuted.Render(" No archived incidents.") return "\n" + m.styles.Muted.Render(" No archived incidents.")
} }
return "\n" + m.archivedTable.View() return "\n" + m.archivedTable.View()
} }
@@ -286,12 +310,12 @@ func (m Model) renderIncidentStatsBar() string {
mttr = humanSeconds(m.incidentStats.MTTRSeconds) mttr = humanSeconds(m.incidentStats.MTTRSeconds)
} }
left := fmt.Sprintf(" %s %s %s %s", left := fmt.Sprintf(" %s %s %s %s",
styleTriggered.Render(fmt.Sprintf("Triggered: %d", triggered)), m.styles.Triggered.Render(fmt.Sprintf("Triggered: %d", triggered)),
styleAcknowledged.Render(fmt.Sprintf("Acked: %d", acked)), m.styles.Acknowledged.Render(fmt.Sprintf("Acked: %d", acked)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)), m.styles.Resolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
styleMuted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)), m.styles.Muted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)),
) )
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.incidentFilter))) right := m.styles.Muted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.incidentFilter)))
return spread(left, right, m.width) return spread(left, right, m.width)
} }
@@ -304,10 +328,10 @@ func (m Model) renderAlertStatsBar() string {
} }
left := fmt.Sprintf(" Total: %d %s %s", left := fmt.Sprintf(" Total: %d %s %s",
total, total,
styleFiring.Render(fmt.Sprintf("Firing: %d", firing)), m.styles.Firing.Render(fmt.Sprintf("Firing: %d", firing)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)), m.styles.Resolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
) )
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.alertFilter))) right := m.styles.Muted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.alertFilter)))
return spread(left, right, m.width) return spread(left, right, m.width)
} }
@@ -324,34 +348,52 @@ func spread(left, right string, width int) string {
func (m Model) renderSchedule() string { func (m Model) renderSchedule() string {
if m.scheduleLoading { if m.scheduleLoading {
return "\n" + styleMuted.Render(" Loading schedule…") return "\n" + m.styles.Muted.Render(" Loading schedule…")
}
team, ok := m.scheduleTeam()
if !ok {
return "\n" + m.styles.Muted.Render(" You are not in any team, so there is no schedule to show.")
} }
var onCallLine string var onCallLine string
if m.currentOnCall != nil { if len(m.currentOnCall) > 0 {
onCallLine = fmt.Sprintf(" On-call today: %s", onCallLine = " On-call today: " + m.styles.AlertName.Render(m.onCallNames())
styleAlertName.Render(m.currentOnCall.Username))
} else { } else {
onCallLine = styleMuted.Render(" On-call today: nobody scheduled") onCallLine = m.styles.Muted.Render(" On-call today: nobody scheduled")
} }
from := m.scheduleWindow from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 6) to := m.scheduleWindow.AddDate(0, 0, 6)
windowLabel := styleMuted.Render(fmt.Sprintf(" %s — %s", windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s: %s — %s",
from.Format("Jan 02"), to.Format("Jan 02, 2006"))) team.Name, from.Format("Jan 02"), to.Format("Jan 02, 2006")))
header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n" header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n"
return header + m.scheduleTable.View() return header + m.scheduleTable.View()
} }
// onCallNames lists who is on call today. With several teams each name carries
// its team, since one person per team is on call and "alice, bob" alone would
// not say whose.
func (m Model) onCallNames() string {
parts := make([]string, len(m.currentOnCall))
for i, e := range m.currentOnCall {
parts[i] = e.Username
if len(m.teams) > 1 && e.TeamName != "" {
parts[i] += " (" + e.TeamName + ")"
}
}
return strings.Join(parts, ", ")
}
func (m Model) renderUserPicker() string { func (m Model) renderUserPicker() string {
if m.usersLoading { if m.usersLoading {
return "\n" + styleMuted.Render(" Loading users…") return "\n" + m.styles.Muted.Render(" Loading users…")
} }
if m.pickerTarget == pickerIncidentAssignee { if m.pickerTarget == pickerIncidentAssignee {
header := fmt.Sprintf("\n Assign %s to:\n\n", header := fmt.Sprintf("\n Assign %s to:\n\n",
styleBold.Render(m.selectedIncident.Title)) m.styles.Bold.Render(m.selectedIncident.Title))
return header + m.userPickerTable.View() return header + m.userPickerTable.View()
} }
@@ -376,7 +418,7 @@ func (m Model) renderUserPicker() string {
} }
} }
header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", styleBold.Render(scope)) header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", m.styles.Bold.Render(scope))
return header + m.userPickerTable.View() return header + m.userPickerTable.View()
} }
@@ -384,14 +426,14 @@ func (m Model) renderUserPicker() string {
func (m Model) renderDetail() string { func (m Model) renderDetail() string {
if m.detailLoading { if m.detailLoading {
return "\n" + styleMuted.Render(" Loading…") return "\n" + m.styles.Muted.Render(" Loading…")
} }
return m.detailViewport.View() return m.detailViewport.View()
} }
// renderPrompt puts an input line under the detail pane. // renderPrompt puts an input line under the detail pane.
func (m Model) renderPrompt(prompt string) string { func (m Model) renderPrompt(prompt string) string {
sep := styleMuted.Render(strings.Repeat("─", m.width)) sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
return m.detailViewport.View() + "\n" + sep + "\n" + prompt return m.detailViewport.View() + "\n" + sep + "\n" + prompt
} }
@@ -401,7 +443,7 @@ func (m Model) renderStats() string {
// Only announce loading before the first result: a background refresh must not // Only announce loading before the first result: a background refresh must not
// blank the page out from under whoever is reading it. // blank the page out from under whoever is reading it.
if m.statsLoading && !m.statsLoaded { if m.statsLoading && !m.statsLoaded {
return "\n" + styleMuted.Render(" Loading statistics…") return "\n" + m.styles.Muted.Render(" Loading statistics…")
} }
return m.statsViewport.View() return m.statsViewport.View()
} }
@@ -418,16 +460,16 @@ func line(style lipgloss.Style, s string) string {
// ── Content builders ─────────────────────────────────────────────────────── // ── Content builders ───────────────────────────────────────────────────────
func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string { func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string {
now := time.Now() now := time.Now()
var b strings.Builder var b strings.Builder
contentW := width - 4 contentW := width - 4
// Title + status header // Title + status header
title := styleAlertName.Render(inc.Title) title := s.AlertName.Render(inc.Title)
status := incidentStatusStyle(inc.Status).Render(incidentStatusLabel(inc)) status := s.IncidentStatus(inc.Status).Render(incidentStatusLabel(inc))
if inc.Severity != "" { if inc.Severity != "" {
status += " " + severityStyle(inc.Severity).Render(strings.ToUpper(inc.Severity)) status += " " + s.Severity(inc.Severity).Render(strings.ToUpper(inc.Severity))
} }
gap := contentW - lipgloss.Width(title) - lipgloss.Width(status) gap := contentW - lipgloss.Width(title) - lipgloss.Width(status)
if gap < 1 { if gap < 1 {
@@ -436,13 +478,16 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
b.WriteString("\n " + title + strings.Repeat(" ", gap) + status + "\n\n") b.WriteString("\n " + title + strings.Repeat(" ", gap) + status + "\n\n")
// Timing and ownership // Timing and ownership
if inc.TeamName != "" {
b.WriteString(fmt.Sprintf(" Team: %s\n", inc.TeamName))
}
b.WriteString(fmt.Sprintf(" Triggered: %s (%s)\n", b.WriteString(fmt.Sprintf(" Triggered: %s (%s)\n",
inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt))) inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt)))
if inc.AssignedTo != "" { if inc.AssignedTo != "" {
b.WriteString(fmt.Sprintf(" Assigned: %s\n", styleBold.Render(inc.AssignedTo))) b.WriteString(fmt.Sprintf(" Assigned: %s\n", s.Bold.Render(inc.AssignedTo)))
} else { } else {
b.WriteString(line(styleMuted, " Assigned: nobody")) b.WriteString(line(s.Muted, " Assigned: nobody"))
} }
if inc.AcknowledgedByID != nil { if inc.AcknowledgedByID != nil {
@@ -450,14 +495,18 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
if inc.AcknowledgedAt != nil { if inc.AcknowledgedAt != nil {
ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC") ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC")
} }
b.WriteString(line(styleResolved, b.WriteString(line(s.Resolved,
fmt.Sprintf(" Acked: %s%s", inc.AcknowledgedBy, ackAt))) fmt.Sprintf(" Acked: %s%s", inc.AcknowledgedBy, ackAt)))
} else { } else {
b.WriteString(line(styleMuted, " Acked: not acknowledged")) b.WriteString(line(s.Muted, " Acked: not acknowledged"))
}
if inc.EscalationLevel > 0 {
b.WriteString(line(s.Snoozed, fmt.Sprintf(" Escalation: level %d", inc.EscalationLevel)))
} }
if inc.IsSnoozed() { if inc.IsSnoozed() {
b.WriteString(line(styleSnoozed, fmt.Sprintf(" Snoozed: until %s (%s)", b.WriteString(line(s.Snoozed, fmt.Sprintf(" Snoozed: until %s (%s)",
inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil)))) inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil))))
} }
@@ -470,14 +519,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source)) inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source))
} }
if inc.ArchivedAt != nil { if inc.ArchivedAt != nil {
b.WriteString(line(styleMuted, " Archived: "+ b.WriteString(line(s.Muted, " Archived: "+
inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC"))) inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC")))
} }
b.WriteString("\n") b.WriteString("\n")
// Group labels — the correlation Alertmanager applied. // Group labels — the correlation Alertmanager applied.
if len(inc.GroupLabels) > 0 { if len(inc.GroupLabels) > 0 {
b.WriteString(divider("Grouped By", width)) b.WriteString(divider(s, "Grouped By", width))
for _, k := range sortedKeys(inc.GroupLabels) { for _, k := range sortedKeys(inc.GroupLabels) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(inc.GroupLabels[k], contentW-24))) b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(inc.GroupLabels[k], contentW-24)))
} }
@@ -485,14 +534,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
} }
// Member alerts // Member alerts
b.WriteString(divider(fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width)) b.WriteString(divider(s, fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width))
if len(inc.Alerts) == 0 { if len(inc.Alerts) == 0 {
b.WriteString(line(styleMuted, " No alerts.")) b.WriteString(line(s.Muted, " No alerts."))
} else { } else {
for _, a := range inc.Alerts { for _, a := range inc.Alerts {
marker := styleFiring.Render("●") marker := s.Firing.Render("●")
if a.Status != "firing" { if a.Status != "firing" {
marker = styleResolved.Render("✓") marker = s.Resolved.Render("✓")
} }
instance := a.Labels["instance"] instance := a.Labels["instance"]
if instance == "" { if instance == "" {
@@ -500,29 +549,29 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
} }
b.WriteString(fmt.Sprintf(" %s %-28s %-26s %s\n", b.WriteString(fmt.Sprintf(" %s %-28s %-26s %s\n",
marker, truncate(a.Name, 28), truncate(instance, 26), marker, truncate(a.Name, 28), truncate(instance, 26),
styleMuted.Render("last seen "+humanAgo(now, a.ReceivedAt)))) s.Muted.Render("last seen "+humanAgo(now, a.ReceivedAt))))
} }
} }
b.WriteString("\n") 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(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))
if len(timeline) == 0 { if len(timeline) == 0 {
b.WriteString(line(styleMuted, " Nothing recorded yet.")) b.WriteString(line(s.Muted, " Nothing recorded yet."))
} else { } else {
noteIndex := 0 noteIndex := 0
for _, e := range timeline { for _, e := range timeline {
when := styleMuted.Render(humanAgo(now, e.CreatedAt)) when := s.Muted.Render(humanAgo(now, e.CreatedAt))
if e.Type != api.EventNote { if e.Type != api.EventNote {
b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when)) b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when))
continue continue
} }
marker := " " marker := " "
author := styleBold.Render(e.Username) author := s.Bold.Render(e.Username)
if noteIndex == cursor { if noteIndex == cursor {
marker = styleSelected.Render("> ") marker = s.Selected.Render("> ")
author = styleSelected.Render(e.Username) author = s.Selected.Render(e.Username)
} }
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when)) b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when))
b.WriteString(" " + e.Detail + "\n") b.WriteString(" " + e.Detail + "\n")
@@ -598,6 +647,8 @@ func eventLabel(e api.IncidentEvent) string {
// the only thing that says why nobody's phone rang. // the only thing that says why nobody's phone rang.
return truncate(fmt.Sprintf(" Notification to %s failed · %s", return truncate(fmt.Sprintf(" Notification to %s failed · %s",
notifiedTarget(who), e.Detail), 52) notifiedTarget(who), e.Detail), 52)
case api.EventDeadmanSilent:
return " Dead man's switch went silent"
default: default:
label := " " + e.Type label := " " + e.Type
if e.Detail != "" { if e.Detail != "" {
@@ -626,21 +677,21 @@ func notifyKind(detail string) string {
return " (" + detail + ")" return " (" + detail + ")"
} }
func buildAlertDetailContent(alert api.Alert, width int) string { func buildAlertDetailContent(s Styles, alert api.Alert, width int) string {
now := time.Now() now := time.Now()
var b strings.Builder var b strings.Builder
contentW := width - 4 contentW := width - 4
name := styleAlertName.Render(alert.Name) name := s.AlertName.Render(alert.Name)
var statusStr string var statusStr string
if alert.Status == "firing" { if alert.Status == "firing" {
statusStr = styleFiring.Render("● FIRING") statusStr = s.Firing.Render("● FIRING")
} else { } else {
label := "✓ RESOLVED" label := "✓ RESOLVED"
if alert.ResolutionSource != nil { if alert.ResolutionSource != nil {
label += " · " + *alert.ResolutionSource label += " · " + *alert.ResolutionSource
} }
statusStr = styleResolved.Render(label) statusStr = s.Resolved.Render(label)
} }
gap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr) gap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
if gap < 1 { if gap < 1 {
@@ -660,15 +711,15 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
} }
if alert.IncidentID != nil { if alert.IncidentID != nil {
b.WriteString(fmt.Sprintf(" Incident: %s %s\n", b.WriteString(fmt.Sprintf(" Incident: %s %s\n",
styleBold.Render(fmt.Sprintf("#%d", *alert.IncidentID)), s.Bold.Render(fmt.Sprintf("#%d", *alert.IncidentID)),
styleMuted.Render("press i to open it"))) s.Muted.Render("press i to open it")))
} else { } else {
b.WriteString(line(styleMuted, " Incident: none")) b.WriteString(line(s.Muted, " Incident: none"))
} }
b.WriteString("\n") b.WriteString("\n")
if len(alert.Labels) > 0 { if len(alert.Labels) > 0 {
b.WriteString(divider("Labels", width)) b.WriteString(divider(s, "Labels", width))
for _, k := range sortedKeys(alert.Labels) { for _, k := range sortedKeys(alert.Labels) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Labels[k], contentW-24))) b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Labels[k], contentW-24)))
} }
@@ -676,7 +727,7 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
} }
if len(alert.Annotations) > 0 { if len(alert.Annotations) > 0 {
b.WriteString(divider("Annotations", width)) b.WriteString(divider(s, "Annotations", width))
for _, k := range sortedKeys(alert.Annotations) { for _, k := range sortedKeys(alert.Annotations) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Annotations[k], contentW-24))) b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Annotations[k], contentW-24)))
} }
@@ -684,14 +735,14 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
} }
// Alerts carry no workflow state: it all lives on the incident. // Alerts carry no workflow state: it all lives on the incident.
b.WriteString(divider("", width)) b.WriteString(divider(s, "", width))
b.WriteString(line(styleMuted, b.WriteString(line(s.Muted,
" Alerts are read-only — acknowledge, assign, note and resolve on the incident.")) " Alerts are read-only — acknowledge, assign, note and resolve on the incident."))
return b.String() return b.String()
} }
func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string { func buildStatsContent(s Styles, incidents *api.IncidentStats, top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string {
barWidth := width/2 - 10 barWidth := width/2 - 10
if barWidth < 8 { if barWidth < 8 {
barWidth = 8 barWidth = 8
@@ -704,41 +755,41 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
b.WriteString("\n") b.WriteString("\n")
// Response times first: they are what a rota is actually judged on. // Response times first: they are what a rota is actually judged on.
b.WriteString(divider("Incident Response", width)) b.WriteString(divider(s, "Incident Response", width))
if incidents == nil { if incidents == nil {
b.WriteString(line(styleMuted, " No data.")) b.WriteString(line(s.Muted, " No data."))
} else { } else {
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total",
styleBold.Render(fmt.Sprintf("%d", incidents.Total)))) s.Bold.Render(fmt.Sprintf("%d", incidents.Total))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Triggered", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Triggered",
styleTriggered.Render(fmt.Sprintf("%d", incidents.Triggered)))) s.Triggered.Render(fmt.Sprintf("%d", incidents.Triggered))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Acknowledged", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Acknowledged",
styleAcknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged)))) s.Acknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Resolved", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Resolved",
styleResolved.Render(fmt.Sprintf("%d", incidents.Resolved)))) s.Resolved.Render(fmt.Sprintf("%d", incidents.Resolved))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to acknowledge", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to acknowledge",
styleBold.Render(humanSeconds(incidents.MTTASeconds)))) s.Bold.Render(humanSeconds(incidents.MTTASeconds))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve",
styleBold.Render(humanSeconds(incidents.MTTRSeconds)))) s.Bold.Render(humanSeconds(incidents.MTTRSeconds))))
if incidents.MTTASeconds == nil || incidents.MTTRSeconds == nil { if incidents.MTTASeconds == nil || incidents.MTTRSeconds == nil {
b.WriteString(line(styleMuted, " (— means nothing has been acknowledged or resolved yet)")) b.WriteString(line(s.Muted, " (— means nothing has been acknowledged or resolved yet)"))
} }
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(divider("Top Alerts", width)) b.WriteString(divider(s, "Top Alerts", width))
if len(top) == 0 { if len(top) == 0 {
b.WriteString(line(styleMuted, " No data.")) b.WriteString(line(s.Muted, " No data."))
} else { } else {
maxCount := top[0].Count maxCount := top[0].Count
for i, a := range top { for i, a := range top {
bar := styleResolved.Render(strings.Repeat("█", renderBarWidth(a.Count, maxCount, barWidth))) bar := s.Resolved.Render(strings.Repeat("█", renderBarWidth(a.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %2d. %-30s %s %d\n", i+1, truncate(a.Name, 30), bar, a.Count)) b.WriteString(fmt.Sprintf(" %2d. %-30s %s %d\n", i+1, truncate(a.Name, 30), bar, a.Count))
} }
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(divider("Alerts by Hour (UTC)", width)) b.WriteString(divider(s, "Alerts by Hour (UTC)", width))
if len(byHour) > 0 { if len(byHour) > 0 {
maxCount := 0 maxCount := 0
for _, h := range byHour { for _, h := range byHour {
@@ -747,15 +798,15 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
} }
} }
for _, h := range byHour { for _, h := range byHour {
bar := styleFiring.Render(strings.Repeat("█", renderBarWidth(h.Count, maxCount, barWidth))) bar := s.Firing.Render(strings.Repeat("█", renderBarWidth(h.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %2dh %-*s %d\n", h.Hour, barWidth, bar, h.Count)) b.WriteString(fmt.Sprintf(" %2dh %-*s %d\n", h.Hour, barWidth, bar, h.Count))
} }
} else { } else {
b.WriteString(line(styleMuted, " No data.")) b.WriteString(line(s.Muted, " No data."))
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(divider("Alerts by Day", width)) b.WriteString(divider(s, "Alerts by Day", width))
if len(byDay) > 0 { if len(byDay) > 0 {
maxCount := 0 maxCount := 0
for _, d := range byDay { for _, d := range byDay {
@@ -764,11 +815,11 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
} }
} }
for _, d := range byDay { for _, d := range byDay {
bar := styleAccent.Render(strings.Repeat("█", renderBarWidth(d.Count, maxCount, barWidth))) bar := s.Accent.Render(strings.Repeat("█", renderBarWidth(d.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %-4s %-*s %d\n", d.DayName[:3], barWidth, bar, d.Count)) b.WriteString(fmt.Sprintf(" %-4s %-*s %d\n", d.DayName[:3], barWidth, bar, d.Count))
} }
} else { } else {
b.WriteString(line(styleMuted, " No data.")) b.WriteString(line(s.Muted, " No data."))
} }
return b.String() return b.String()
@@ -778,22 +829,22 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
func (m Model) renderUsers() string { func (m Model) renderUsers() string {
if m.usersLoading { if m.usersLoading {
return "\n" + styleMuted.Render(" Loading users…") return "\n" + m.styles.Muted.Render(" Loading users…")
} }
if len(m.users) == 0 { if len(m.users) == 0 {
return "\n" + styleMuted.Render(" No users found. Press n to create one.") return "\n" + m.styles.Muted.Render(" No users found. Press n to create one.")
} }
return "\n" + m.userManageTable.View() return "\n" + m.userManageTable.View()
} }
func (m Model) renderUserCreate() string { func (m Model) renderUserCreate() string {
header := "\n " + styleBold.Render("Create new user") + "\n\n" header := "\n " + m.styles.Bold.Render("Create new user") + "\n\n"
usernameLabel := " Username: " usernameLabel := " Username: "
emailLabel := " Email: " emailLabel := " Email: "
if m.userFormFocus == 0 { if m.userFormFocus == 0 {
usernameLabel = styleSelected.Render(" Username: ") usernameLabel = m.styles.Selected.Render(" Username: ")
} else { } else {
emailLabel = styleSelected.Render(" Email: ") emailLabel = m.styles.Selected.Render(" Email: ")
} }
return header + return header +
usernameLabel + m.userFormInputs[0].View() + "\n" + usernameLabel + m.userFormInputs[0].View() + "\n" +
@@ -801,59 +852,81 @@ func (m Model) renderUserCreate() string {
} }
func (m Model) renderUserNotifyEdit() string { func (m Model) renderUserNotifyEdit() string {
header := fmt.Sprintf("\n Push notifications for %s\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n Push notifications for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
hint := line(styleMuted, hint := line(m.styles.Muted,
" The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+ " The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+
" their incidents then page the server's shared fallback topic, which\n"+ " their incidents then page the server's shared fallback topic, which\n"+
" carries no Acknowledge button.") " carries no Acknowledge button.")
label := styleSelected.Render(" Topic: ") label := m.styles.Selected.Render(" Topic: ")
return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n" return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n"
} }
func (m Model) renderPasswordSet() string {
header := fmt.Sprintf("\n Web UI password for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
if m.pwLoading {
return header + line(m.styles.Muted, " Checking who this key belongs to…")
}
labels := [pwFieldCount]string{
pwCurrent: " Current password: ",
pwNew: " New password: ",
pwRepeat: " Repeat: ",
}
var form string
for _, f := range m.pwFields() {
label := labels[f]
if f == m.pwFocus {
label = m.styles.Selected.Render(label)
}
form += label + m.pwInputs[f].View() + "\n"
}
hint := fmt.Sprintf(" At least %d characters. Setting it signs %s out of every other\n web UI session. API keys are not affected.", minPasswordLen, m.selectedUser.Username)
return header + form + "\n" + line(m.styles.Muted, hint)
}
func (m Model) renderAPIKeyMenu() string { func (m Model) renderAPIKeyMenu() string {
header := fmt.Sprintf("\n API keys for %s\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n API keys for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
warning := line(styleMuted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.") warning := line(m.styles.Muted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.")
options := "\n" + options := "\n" +
styleAccent.Render(" n") + " · create a new API key\n" + m.styles.Accent.Render(" n") + " · create a new API key\n" +
styleAccent.Render(" r") + " · revoke a key by ID\n" m.styles.Accent.Render(" r") + " · revoke a key by ID\n"
return header + "\n" + warning + options return header + "\n" + warning + options
} }
func (m Model) renderAPIKeyCreate() string { func (m Model) renderAPIKeyCreate() string {
header := fmt.Sprintf("\n New API key for %s\n\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n New API key for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
label := styleSelected.Render(" Key name: ") label := m.styles.Selected.Render(" Key name: ")
return header + label + m.apiKeyNameInput.View() + "\n" return header + label + m.apiKeyNameInput.View() + "\n"
} }
func (m Model) renderAPIKeyReveal() string { func (m Model) renderAPIKeyReveal() string {
sep := styleMuted.Render(strings.Repeat("─", m.width)) sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
warn := styleError.Render(" !! COPY NOW — this key will NEVER be shown again !!") warn := m.styles.Error.Render(" !! COPY NOW — this key will NEVER be shown again !!")
nameLine := fmt.Sprintf(" Key name: %s", styleBold.Render(m.revealedAPIKey.Name)) nameLine := fmt.Sprintf(" Key name: %s", m.styles.Bold.Render(m.revealedAPIKey.Name))
idLine := fmt.Sprintf(" Key ID: %s %s", idLine := fmt.Sprintf(" Key ID: %s %s",
styleBold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)), m.styles.Bold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)),
styleMuted.Render("(save this — needed for future revocation)")) m.styles.Muted.Render("(save this — needed for future revocation)"))
keyLine := styleResolved.Render(" " + m.revealedAPIKey.Key) keyLine := m.styles.Resolved.Render(" " + m.revealedAPIKey.Key)
return "\n" + sep + "\n\n" + return "\n" + sep + "\n\n" +
warn + "\n\n" + warn + "\n\n" +
nameLine + "\n" + nameLine + "\n" +
idLine + "\n\n" + idLine + "\n\n" +
styleMuted.Render(" Key value:") + "\n" + m.styles.Muted.Render(" Key value:") + "\n" +
keyLine + "\n\n" + keyLine + "\n\n" +
sep + "\n" sep + "\n"
} }
func (m Model) renderAPIKeyRevokeByID() string { func (m Model) renderAPIKeyRevokeByID() string {
header := fmt.Sprintf("\n Revoke API key for %s\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n Revoke API key for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
hint := line(styleMuted, " Enter the integer key ID (shown when the key was created).") hint := line(m.styles.Muted, " Enter the integer key ID (shown when the key was created).")
label := styleSelected.Render(" Key ID: ") label := m.styles.Selected.Render(" Key ID: ")
return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n" return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n"
} }
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
func divider(title string, width int) string { func divider(s Styles, title string, width int) string {
prefix := "── " prefix := "── "
if title != "" { if title != "" {
prefix += title + " " prefix += title + " "
@@ -862,7 +935,7 @@ func divider(title string, width int) string {
if remaining > 0 { if remaining > 0 {
prefix += strings.Repeat("─", remaining) prefix += strings.Repeat("─", remaining)
} }
return styleMuted.Render(prefix) + "\n" return s.Muted.Render(prefix) + "\n"
} }
func sortedKeys(m map[string]string) []string { func sortedKeys(m map[string]string) []string {
+22 -17
View File
@@ -6,8 +6,9 @@ import (
"testing" "testing"
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
) )
// ansi matches the escape sequences lipgloss emits when it decides the output // ansi matches the escape sequences lipgloss emits when it decides the output
@@ -16,6 +17,10 @@ var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func plain(s string) string { return ansi.ReplaceAllString(s, "") } func plain(s string) string { return ansi.ReplaceAllString(s, "") }
// testStyles is the default theme, so assertions here run against what a user
// with no 'theme:' key actually sees.
func testStyles() Styles { return newStyles(theme.GruvboxDark) }
func mustContain(t *testing.T, got string, wants ...string) { func mustContain(t *testing.T, got string, wants ...string) {
t.Helper() t.Helper()
got = plain(got) got = plain(got)
@@ -52,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(inc, timeline, -1, 110) out := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 110)
mustContain(t, out, mustContain(t, out,
"DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL", "DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL",
"Assigned:", "admin", "Assigned:", "admin",
@@ -72,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(inc, nil, -1, 110), mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110),
"TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h") "TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h")
} }
@@ -83,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(inc, nil, -1, 110)), "Snoozed:") { if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, -1, 110)), "Snoozed:") {
t.Error("an expired snooze should not be rendered") t.Error("an expired snooze should not be rendered")
} }
} }
@@ -95,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(inc, nil, -1, 110), mustContain(t, buildIncidentDetailContent(testStyles(), inc, 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(inc, nil, -1, 110), "nobody", "not acknowledged") mustContain(t, buildIncidentDetailContent(testStyles(), inc, 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(inc, nil, -1, 110), "Nothing recorded yet") mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "Nothing recorded yet")
} }
func TestIncidentDetail_MarksSelectedNote(t *testing.T) { func TestIncidentDetail_MarksSelectedNote(t *testing.T) {
@@ -117,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(inc, timeline, 1, 110)) out := plain(buildIncidentDetailContent(testStyles(), inc, timeline, 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)
@@ -207,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(inc, timeline, -1, 120) got := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 120)
mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed") mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed")
} }
@@ -230,7 +235,7 @@ func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {
Labels: map[string]string{"instance": "node-1", "severity": "critical"}, Labels: map[string]string{"instance": "node-1", "severity": "critical"},
Annotations: map[string]string{"summary": "disk 90%"}, Annotations: map[string]string{"summary": "disk 90%"},
} }
mustContain(t, buildAlertDetailContent(alert, 110), mustContain(t, buildAlertDetailContent(testStyles(), alert, 110),
"DiskFull", "FIRING", "Incident:", "#7", "press i to open it", "DiskFull", "FIRING", "Incident:", "#7", "press i to open it",
"instance", "node-1", "summary", "disk 90%", "instance", "node-1", "summary", "disk 90%",
"Alerts are read-only") "Alerts are read-only")
@@ -238,21 +243,21 @@ func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {
func TestAlertDetail_NoIncident(t *testing.T) { func TestAlertDetail_NoIncident(t *testing.T) {
alert := api.Alert{ID: 3, Name: "Orphan", Status: "resolved", ReceivedAt: time.Now()} alert := api.Alert{ID: 3, Name: "Orphan", Status: "resolved", ReceivedAt: time.Now()}
mustContain(t, buildAlertDetailContent(alert, 110), "Incident:", "none") mustContain(t, buildAlertDetailContent(testStyles(), alert, 110), "Incident:", "none")
} }
func TestAlertDetail_ShowsResolutionSource(t *testing.T) { func TestAlertDetail_ShowsResolutionSource(t *testing.T) {
source := "expiry" source := "expiry"
alert := api.Alert{Name: "Gone", Status: "resolved", ReceivedAt: time.Now(), alert := api.Alert{Name: "Gone", Status: "resolved", ReceivedAt: time.Now(),
ResolutionSource: &source} ResolutionSource: &source}
mustContain(t, buildAlertDetailContent(alert, 110), "RESOLVED", "expiry") mustContain(t, buildAlertDetailContent(testStyles(), alert, 110), "RESOLVED", "expiry")
} }
// Null MTTA means nothing has been acknowledged, which is a different claim // Null MTTA means nothing has been acknowledged, which is a different claim
// from an instant response. // from an instant response.
func TestStats_RendersDashForMissingAverages(t *testing.T) { func TestStats_RendersDashForMissingAverages(t *testing.T) {
stats := &api.IncidentStats{Total: 2, Triggered: 2} stats := &api.IncidentStats{Total: 2, Triggered: 2}
out := buildStatsContent(stats, nil, nil, nil, 110) out := buildStatsContent(testStyles(), stats, nil, nil, nil, 110)
mustContain(t, out, "Incident Response", "Mean time to acknowledge", "—", mustContain(t, out, "Incident Response", "Mean time to acknowledge", "—",
"nothing has been acknowledged or resolved yet") "nothing has been acknowledged or resolved yet")
} }
@@ -260,12 +265,12 @@ func TestStats_RendersDashForMissingAverages(t *testing.T) {
func TestStats_RendersAverages(t *testing.T) { func TestStats_RendersAverages(t *testing.T) {
mtta, mttr := 150.0, 3600.0 mtta, mttr := 150.0, 3600.0
stats := &api.IncidentStats{Total: 3, Resolved: 1, MTTASeconds: &mtta, MTTRSeconds: &mttr} stats := &api.IncidentStats{Total: 3, Resolved: 1, MTTASeconds: &mtta, MTTRSeconds: &mttr}
out := buildStatsContent(stats, []api.TopAlert{{Name: "DiskFull", Count: 4}}, nil, nil, 110) out := buildStatsContent(testStyles(), stats, []api.TopAlert{{Name: "DiskFull", Count: 4}}, nil, nil, 110)
mustContain(t, out, "2m", "1h", "Top Alerts", "DiskFull") mustContain(t, out, "2m", "1h", "Top Alerts", "DiskFull")
} }
func TestStats_HandlesNoIncidentData(t *testing.T) { func TestStats_HandlesNoIncidentData(t *testing.T) {
mustContain(t, buildStatsContent(nil, nil, nil, nil, 110), "Incident Response", "No data") mustContain(t, buildStatsContent(testStyles(), nil, nil, nil, nil, 110), "Incident Response", "No data")
} }
func TestView_TabsAndDashboardRender(t *testing.T) { func TestView_TabsAndDashboardRender(t *testing.T) {
@@ -296,7 +301,7 @@ func TestView_EmptyStates(t *testing.T) {
// The stats page renders inside the normal section chrome now, so it has to // The stats page renders inside the normal section chrome now, so it has to
// survive the real path: a window size message sizes the viewport and fills it. // survive the real path: a window size message sizes the viewport and fills it.
func TestView_StatsSectionRendersInPlace(t *testing.T) { func TestView_StatsSectionRendersInPlace(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.connected = true m.connected = true
m.incidentStats = &api.IncidentStats{Total: 3, Triggered: 1} m.incidentStats = &api.IncidentStats{Total: 3, Triggered: 1}
m.topAlerts = []api.TopAlert{{Name: "DiskFull", Count: 4}} m.topAlerts = []api.TopAlert{{Name: "DiskFull", Count: 4}}
@@ -335,7 +340,7 @@ func TestFooter_IncidentDetailOffersResolveOnlyWhileOpen(t *testing.T) {
} }
func TestView_ZeroWidthRendersNothing(t *testing.T) { func TestView_ZeroWidthRendersNothing(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
if m.View() != "" { if m.View() != "" {
t.Error("expected no output before the first window size message") t.Error("expected no output before the first window size message")
} }
+10 -3
View File
@@ -12,7 +12,14 @@ import (
"strings" "strings"
) )
const releaseAPI = "https://api.github.com/repos/yeniklas/terdut-tui/releases/latest" // Gitea's release payload carries the same tag_name, and its attachments the same name
// and browser_download_url, so the types below are unchanged from the GitHub original.
//
// A binary installed before the move still polls api.github.com and will never see a
// release published here. That GitHub repository is still in place, so such a build
// reports itself up to date rather than erroring -- its last GitHub release is the
// bridge, and crossing it is a one-time manual download.
const releaseAPI = "https://git.ryuvia.com/api/v1/repos/niklas/terdut-tui/releases/latest"
type release struct { type release struct {
TagName string `json:"tag_name"` TagName string `json:"tag_name"`
@@ -125,7 +132,7 @@ func fetchLatest() (*release, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
@@ -134,7 +141,7 @@ func fetchLatest() (*release, error) {
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned %s", resp.Status) return nil, fmt.Errorf("Gitea API returned %s", resp.Status)
} }
var rel release var rel release
+12 -5
View File
@@ -5,11 +5,12 @@ import (
"fmt" "fmt"
"os" "os"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/config"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"git.ryuvia.com/niklas/terdut-tui/internal/tui"
"git.ryuvia.com/niklas/terdut-tui/internal/updater"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
"github.com/yeniklas/terdut-tui/internal/config"
"github.com/yeniklas/terdut-tui/internal/tui"
"github.com/yeniklas/terdut-tui/internal/updater"
) )
var version = "dev" var version = "dev"
@@ -38,8 +39,14 @@ func main() {
os.Exit(1) os.Exit(1)
} }
th, err := theme.Load(cfg.Theme)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
client := api.NewClient(cfg.ServerURL, cfg.APIKey) client := api.NewClient(cfg.ServerURL, cfg.APIKey)
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval) model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th).WithDefaultTeam(cfg.Team)
p := tea.NewProgram(model, tea.WithAltScreen()) p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {