9 Commits

Author SHA1 Message Date
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
Niklas Ye e336aeea97 feat: reassign on-call days and weeks to another person
Release / test (push) Failing after 4s
Release / build (amd64, darwin) (push) Has been skipped
Release / build (amd64, linux) (push) Has been skipped
Release / build (arm64, darwin) (push) Has been skipped
Release / build (arm64, linux) (push) Has been skipped
Release / release (push) Has been skipped
Assigning over a day somebody else held did nothing but flash a 409 for
three seconds. The server holds one person per date and refused any that
was taken, all-or-nothing, so pressing W on a week where a single day was
already assigned placed none of the other six either. The only way
through was d on each day first — seven delete-and-confirm cycles to move
one week.

The clash is already on screen, so it is found before the request rather
than read back out of an error: the picker hands off to a confirmation
naming who loses the days and how many there are, and accepting sends the
whole selection with replace, which terdut-server v0.8.0 added. One
question to move a week, and nobody's shift moves without somebody being
asked. A day nobody holds still assigns with no prompt at all.

Reassigning somebody to a day they already hold raises no prompt, since
it takes nothing from anyone, but it does send replace: the server
rejects any date that exists, so without it a harmless no-op would fail.
2026-08-07 14:04:37 +02:00
Niklas Ye 85ad2d65ee feat: ntfy topics per user, and notifications on the timeline
Release / test (push) Failing after 6s
Release / release (push) Has been skipped
Release / build (amd64, darwin) (push) Has been skipped
Release / build (amd64, linux) (push) Has been skipped
Release / build (arm64, darwin) (push) Has been skipped
Release / build (arm64, linux) (push) Has been skipped
terdut-server pages the on-call person through ntfy, but none of it was
reachable from here. A user's topic could only be set with curl, so a
new user silently got no pages and quietly fell back to the shared
fallback topic — which carries no Acknowledge button. And nothing said
whether anybody had been paged at all.

The Users section grows an Ntfy Topic column and t to edit it,
prefilled with the current value. Submitting an empty field clears the
topic rather than being rejected as a mistake: clearing is how somebody
is taken off their own topic, and it is what the server means by an
empty string. Nil and empty arrive as the same thing, because the
server stores a blank topic as NULL, so User.Topic flattens the two
instead of leaving every caller to.

The incident timeline renders the server's notified and notify_failed
events. No new fetch — the timeline endpoint already carried them, and
unknown types already fell through to a generic label; this is about
saying something useful. An event with no user means the fallback
topic, not "the server acted", which is the difference between somebody
having been paged and the rota having been empty.

Both need terdut-server v0.6.0 or later, and the timeline entries a
server newer than that. Against an older one the column stays empty and
editing a topic reports the server's 404, which is the honest answer.
2026-08-07 13:41:07 +02:00
Niklas Ye f75ae60e74 fix: truncate in runes rather than bytes
truncate measures and slices by byte, so a string cut inside a
multi-byte rune both mis-measures the fixed-width column it is being
laid out against and emits a broken character. Everything it is handed
is server-supplied — alert names, label values, annotations — and none
of that is guaranteed to be ASCII.

Identical behaviour for the ASCII case.
2026-08-07 13:31:51 +02:00
Niklas Ye 4740687b96 feat!: stats as a section instead of an overlay
Release / test (push) Failing after 6s
Release / build (amd64, darwin) (push) Has been skipped
Release / build (amd64, linux) (push) Has been skipped
Release / build (arm64, darwin) (push) Has been skipped
Release / build (arm64, linux) (push) Has been skipped
Release / release (push) Has been skipped
Stats was the one full-screen view reached by a key of its own rather
than by tab, and the interface was less coherent for it. It is now a
section sitting third, after Alerts, and behaves like every other one:
tab in, tab out, r to refresh.

Three things fall out of the move. It auto-refreshes for the first time
— the tick handler skips every non-dashboard mode, which is why the
overlay never updated while it was open. Its error path no longer forces
the queue back into view on a failed fetch, an assumption that only made
sense while stats floated above the dashboard. And first-visit loading
keys off a statsLoaded flag rather than slice emptiness, because the
three empty slices a quiet server returns are a real answer, not a
missing one; the loading placeholder is likewise suppressed once
something has been drawn, so a background refresh cannot blank the page
out from under whoever is reading it.

The S key is gone, and with it the ability to peek at statistics from an
open incident and land back on it. That round-trip was the only thing
statsReturnMode bought, and it was the whole reason stats needed a mode.
2026-08-06 12:46:38 +02:00
Niklas Ye 1cb3fc3d14 ci: check every push and pull request
The release workflow gates a tag, which is the last possible moment: a
commit that breaks the suite stays green on main until somebody decides
to publish.

Runs go vet and go test on pushes to main and on pull requests. push is
scoped to main so a branch pushed as part of a pull request is not
checked twice, and runs for the same ref cancel each other.
2026-07-31 07:29:00 +02:00
24 changed files with 2208 additions and 423 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
-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-*'
+12 -2
View File
@@ -1,6 +1,6 @@
# 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.4.0+**.
## Domain model
@@ -28,12 +28,13 @@ non-destructive "not now" alternative.
main.go CLI entry point: flags, config load, health check, start TUI
internal/api/client.go REST API client — one method per endpoint
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
model.go Model struct, mode/section constants, Init(), tea.Cmd constructors
update.go Update() — dispatch only, no API calls inline
view.go View() — pure rendering
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
```
@@ -43,6 +44,9 @@ internal/updater/updater.go Self-update via GitHub Releases
2. **`View()` is pure** — no side effects, no state mutations.
3. **All state in `Model`** — no globals.
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
@@ -52,8 +56,13 @@ Location: `~/.config/terdut-tui/config.yaml`
server_url: https://terdut.example.com
api_key: <64-char hex key>
refresh_interval: 30 # seconds, optional, default 30
theme: gruvbox-dark # optional, default gruvbox-dark
```
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`).
## Running
@@ -73,6 +82,7 @@ go build -ldflags="-X main.version=v0.1.0" -o terdut-tui .
## Sections
`Incidents` (the queue, and the default) · `Alerts` (raw read-only feed) ·
`Stats` (MTTA/MTTR and alert frequency charts) ·
`Archived` (archived incidents) · `Schedule` · `Users`
## Development stages
+76 -6
View File
@@ -1,6 +1,6 @@
# 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).
@@ -8,11 +8,11 @@ Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea).
- **Incident queue** — open incidents with severity, status, assignee and age, auto-refreshing
- **Incident actions** — acknowledge, assign, snooze, note, resolve and archive
- **Timeline** — the full history of an incident, system events 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
- **On-call schedule** — visual calendar of who is on duty, assign and remove entries
- **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day
- **User management** — add and remove users, manage API keys
- **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;
> use terdut-tui v0.3.x with those.
@@ -37,12 +37,28 @@ Two behaviours worth knowing before you press a key:
- **Snooze is the "not now" button.** It hides an incident from the default queue
without closing it, and expires on its own.
## Push notifications
When the server is configured for ntfy, an incident that opens pages whoever is
on call. Each user has their own topic, shown as a column in the Users section
and edited with `t`. A user with no topic falls back to the server's shared
fallback topic, which carries **no Acknowledge button** — the topic is shared, so
a button on it would let any subscriber acknowledge as somebody else.
Every delivery lands on the incident's timeline: `Notified <user> (triggered)`
when ntfy accepted the page, and `Notification to <user> failed` when it ran out
of retries. That second one is the one to look for when nobody's phone rang.
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
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
go install github.com/yeniklas/terdut-tui@latest
go install git.ryuvia.com/niklas/terdut-tui@latest
```
## Configuration
@@ -53,10 +69,49 @@ Create `~/.config/terdut-tui/config.yaml`:
server_url: https://terdut.example.com
api_key: <your-api-key>
refresh_interval: 30 # seconds, optional
theme: gruvbox-dark # optional, this is the default
```
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
```
@@ -78,9 +133,10 @@ Global:
| `esc` | Go back |
| `r` | Refresh |
| `f` | Cycle filter |
| `S` | Statistics |
| `q` | Quit |
The sections, in `tab` order: Incidents · Alerts · Stats · Archived · Schedule · Users.
Incidents section:
| Key | Action |
@@ -108,6 +164,12 @@ Alerts section (read-only):
| `f` | Cycle: firing → resolved → all → archived |
| `i` | In detail: jump to the alert's incident |
Stats section:
| Key | Action |
|-----|--------|
| `j` / `k`, `pgup` / `pgdn` | Scroll |
Schedule section:
| Key | Action |
@@ -116,10 +178,18 @@ Schedule section:
| `d` | Remove the assignment |
| `←` / `→` | Shift the week window |
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
selection at once when you accept, so reassigning a week is one confirmation
rather than seven deletions. Taking somebody's shift needs terdut-server
**v0.8.0 or later**; against an older server the assignment is refused with
`date already assigned`.
Users section:
| Key | Action |
|-----|--------|
| `n` | Create a user |
| `t` | Edit the user's ntfy topic — submit empty to clear it |
| `d` | Delete a user |
| `k` | API keys for the selected user |
+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
+27 -4
View File
@@ -358,11 +358,17 @@ func (c *Client) GetCurrentOnCall() (*ScheduleEntry, error) {
return &entry, nil
}
func (c *Client) AssignSchedule(userID int64, dates []string) ([]ScheduleEntry, error) {
// AssignSchedule puts one user on call for the given dates.
//
// 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
// 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) {
body := struct {
UserID int64 `json:"user_id"`
Dates []string `json:"dates"`
}{UserID: userID, Dates: dates}
UserID int64 `json:"user_id"`
Dates []string `json:"dates"`
Replace bool `json:"replace,omitempty"`
}{UserID: userID, Dates: dates, Replace: replace}
req, err := c.newRequestWithBody(http.MethodPost, "/api/schedule", body)
if err != nil {
return nil, err
@@ -401,6 +407,23 @@ func (c *Client) CreateUser(username, email string) (*User, error) {
return &user, c.do(req, &user)
}
// SetUserNotifyTarget points a user's push notifications at an ntfy topic.
//
// An empty topic clears it: the server stores NULL, and that user's incidents
// page the shared fallback topic instead — which carries no Acknowledge button,
// because anyone subscribed to it could otherwise acknowledge as somebody else.
func (c *Client) SetUserNotifyTarget(userID int64, topic string) (*User, error) {
body := struct {
NtfyTopic string `json:"ntfy_topic"`
}{NtfyTopic: topic}
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/notify", userID), body)
if err != nil {
return nil, err
}
var user User
return &user, c.do(req, &user)
}
func (c *Client) DeleteUser(id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d", id))
if err != nil {
+61
View File
@@ -83,6 +83,8 @@ func TestClient_IncidentEndpoints(t *testing.T) {
http.MethodDelete, "/api/incidents/7/notes/12", ""},
{"stats", func(c *Client) error { _, err := c.GetIncidentStats(); return err },
http.MethodGet, "/api/stats/incidents", ""},
{"set notify target", func(c *Client) error { _, err := c.SetUserNotifyTarget(7, "t"); return err },
http.MethodPut, "/api/users/7/notify", ""},
}
for _, tt := range tests {
@@ -163,6 +165,65 @@ func TestClient_RequestBodies(t *testing.T) {
t.Errorf("expected duration 90m, got %q", body.Duration)
}
})
// replace is what takes a day off its current holder, so it has to reach the
// wire when asked for — and stay off it when not.
t.Run("assign schedule", func(t *testing.T) {
c, got := stub(t, http.StatusCreated, `[]`)
if _, err := c.AssignSchedule(3, []string{"2026-07-27"}, false); err != nil {
t.Fatalf("assign: %v", err)
}
if got.body != `{"user_id":3,"dates":["2026-07-27"]}` {
t.Errorf("unexpected body %q", got.body)
}
})
t.Run("assign schedule with replace", func(t *testing.T) {
c, got := stub(t, http.StatusCreated, `[]`)
if _, err := c.AssignSchedule(3, []string{"2026-07-27"}, true); err != nil {
t.Fatalf("assign: %v", err)
}
if got.body != `{"user_id":3,"dates":["2026-07-27"],"replace":true}` {
t.Errorf("unexpected body %q", got.body)
}
})
t.Run("set notify target", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.SetUserNotifyTarget(3, "terdut-niklas"); err != nil {
t.Fatalf("set notify target: %v", err)
}
if got.body != `{"ntfy_topic":"terdut-niklas"}` {
t.Errorf("unexpected body %q", got.body)
}
})
// Clearing has to put an explicit empty string on the wire: omitting the
// field would leave the topic untouched instead of removing it.
t.Run("clear notify target", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.SetUserNotifyTarget(3, ""); err != nil {
t.Fatalf("clear notify target: %v", err)
}
if got.body != `{"ntfy_topic":""}` {
t.Errorf("expected an explicit empty topic, got %q", got.body)
}
})
}
func TestUser_TopicFlattensNilAndEmpty(t *testing.T) {
var users []User
if err := json.Unmarshal([]byte(
`[{"id":1,"username":"a"},{"id":2,"username":"b","ntfy_topic":""},
{"id":3,"username":"c","ntfy_topic":"terdut-c"}]`), &users); err != nil {
t.Fatalf("decode: %v", err)
}
want := []string{"", "", "terdut-c"}
for i, u := range users {
if got := u.Topic(); got != want[i] {
t.Errorf("user %d: expected topic %q, got %q", u.ID, want[i], got)
}
}
}
// The 409 on re-resolving is the server telling the user why nothing happened,
+22
View File
@@ -95,6 +95,13 @@ const (
EventUnsnoozed = "unsnoozed"
EventResolved = "resolved"
EventNote = "note"
// Written by the server's notifier from the delivery result, not at enqueue.
// Detail carries the notification kind ("triggered", "reminder", "resolved"),
// and on a failure the reason after it. An absent user means the page went to
// the shared fallback topic rather than to a person.
EventNotified = "notified"
EventNotifyFailed = "notify_failed"
)
// IncidentEvent is one entry in an incident's timeline. An empty Username means
@@ -158,6 +165,21 @@ type User struct {
Username string `json:"username"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
// NtfyTopic is where this user's push notifications go. Nil and empty mean
// the same thing — no topic of their own — because the server stores a blank
// string as NULL. Their incidents fall back to the server's shared fallback
// topic, which carries no Acknowledge button.
NtfyTopic *string `json:"ntfy_topic,omitempty"`
}
// Topic reads the user's ntfy topic, flattening the nil and empty cases the
// server treats alike.
func (u User) Topic() string {
if u.NtfyTopic == nil {
return ""
}
return *u.NtfyTopic
}
type APIKey struct {
+4 -1
View File
@@ -15,12 +15,14 @@ type Config struct {
ServerURL string
APIKey string
RefreshInterval time.Duration
Theme string
}
type rawConfig struct {
ServerURL string `yaml:"server_url"`
APIKey string `yaml:"api_key"`
RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds
Theme string `yaml:"theme,omitempty"`
}
func Load() (*Config, error) {
@@ -33,7 +35,7 @@ func Load() (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
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", path)
}
return nil, fmt.Errorf("cannot read config file: %w", err)
}
@@ -59,5 +61,6 @@ func Load() (*Config, error) {
ServerURL: raw.ServerURL,
APIKey: raw.APIKey,
RefreshInterval: interval,
Theme: raw.Theme,
}, nil
}
+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
}
+113 -36
View File
@@ -4,13 +4,13 @@ import (
"fmt"
"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/table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api"
)
// ── Enums ──────────────────────────────────────────────────────────────────
@@ -21,11 +21,12 @@ const (
// Incidents lead: they are the work. Alerts is the raw feed underneath.
sectionIncidents section = iota
sectionAlerts
sectionStats
sectionArchived
sectionSchedule
sectionUsers
sectionCount = 5
sectionCount = 6
)
type mode int
@@ -37,9 +38,9 @@ const (
modeNote
modeSnooze
modeConfirm
modeStats
modeUserPicker
modeUserCreate
modeUserNotifyEdit
modeAPIKeyMenu
modeAPIKeyCreate
modeAPIKeyReveal
@@ -53,6 +54,7 @@ const (
confirmResolveIncident
confirmDeleteScheduleEntry
confirmDeleteUser
confirmReassignSchedule
)
// pickerTarget says what the user picker is choosing a person for.
@@ -142,6 +144,18 @@ type scheduleDay struct {
entry *api.ScheduleEntry
}
// pendingAssign is an on-call assignment held back by the reassignment
// confirmation, because some of its dates belong to somebody else.
type pendingAssign struct {
userID int64
username string
dates []string
// taken are the dates currently held by other people, and holders the
// distinct names holding them — both only for wording the prompt.
taken []string
holders []string
}
type Model struct {
client *api.Client
serverURL string
@@ -193,16 +207,17 @@ type Model struct {
confirmTarget confirmTarget
pendingDeleteID int64 // note event ID
pendingDeleteEntry *api.ScheduleEntry
pendingAssign *pendingAssign
// Stats
topAlerts []api.TopAlert
hourStats []api.HourStat
dayStats []api.DayStat
topAlerts []api.TopAlert
hourStats []api.HourStat
dayStats []api.DayStat
// statsLoaded tracks the first fetch separately from emptiness: a server with
// no alerts yet legitimately returns three empty slices.
statsLoaded bool
statsLoading bool
statsViewport viewport.Model
// statsReturnMode is where esc goes back to, since stats opens from both
// the dashboard and an incident.
statsReturnMode mode
// Schedule
scheduleWindow time.Time
@@ -224,16 +239,19 @@ type Model struct {
selectedUser api.User
userFormInputs [2]textinput.Model
userFormFocus int
ntfyTopicInput textinput.Model
apiKeyNameInput textinput.Model
apiKeyRevokeInput textinput.Model
revealedAPIKey api.APIKey
help help.Model
keys keyMap
help help.Model
keys keyMap
styles Styles
}
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model {
ts := defaultTableStyles()
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration, th theme.Theme) Model {
st := newStyles(th)
ts := st.Table()
incidentT := table.New(table.WithFocused(true))
incidentT.SetStyles(ts)
@@ -253,6 +271,10 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
manageT := table.New(table.WithFocused(true))
manageT.SetStyles(ts)
// Sized by the first tea.WindowSizeMsg; built here so it carries the default
// scroll keymap, which the zero value lacks.
statsVP := viewport.New(0, 0)
noteIn := textinput.New()
noteIn.Placeholder = "type your note…"
noteIn.CharLimit = 1000
@@ -269,6 +291,10 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
emailIn.Placeholder = "email"
emailIn.CharLimit = 128
topicIn := textinput.New()
topicIn.Placeholder = "ntfy topic — empty clears it"
topicIn.CharLimit = 128
keyNameIn := textinput.New()
keyNameIn.Placeholder = "key name (e.g. laptop)"
keyNameIn.CharLimit = 64
@@ -277,6 +303,15 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
revokeIn.Placeholder = "integer key ID"
revokeIn.CharLimit = 20
for _, in := range []*textinput.Model{
&noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn,
} {
*in = st.Input(*in)
}
helpModel := help.New()
helpModel.Styles = st.Help()
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
weekday := int(today.Weekday())
@@ -298,6 +333,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
incidentTable: incidentT,
alertTable: alertT,
archivedTable: archivedT,
statsViewport: statsVP,
noteInput: noteIn,
snoozeInput: snoozeIn,
scheduleWindow: window,
@@ -305,10 +341,12 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
userPickerTable: pickerT,
userManageTable: manageT,
userFormInputs: [2]textinput.Model{usernameIn, emailIn},
ntfyTopicInput: topicIn,
apiKeyNameInput: keyNameIn,
apiKeyRevokeInput: revokeIn,
help: help.New(),
help: helpModel,
keys: keys,
styles: st,
}
}
@@ -318,37 +356,45 @@ func (m Model) Init() tea.Cmd {
// ── Table rebuilders ───────────────────────────────────────────────────────
func defaultTableStyles() table.Styles {
s := table.DefaultStyles()
s.Header = s.Header.Bold(true)
s.Selected = s.Selected.
Foreground(lipgloss.Color("0")).
Background(colorPrimary).
Bold(true)
return s
// 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)
}
}
func (m *Model) rebuildIncidentTable() {
m.incidentTable.SetColumns(incidentColumns(m.width))
m.incidentTable.SetRows(incidentRows(m.incidents))
setRows(&m.incidentTable, incidentRows(m.incidents))
m.incidentTable.SetHeight(tableHeight(m.height, 8))
}
func (m *Model) rebuildTable() {
m.alertTable.SetColumns(alertColumns(m.width))
m.alertTable.SetRows(alertRows(m.alerts))
setRows(&m.alertTable, alertRows(m.alerts))
m.alertTable.SetHeight(tableHeight(m.height, 8))
}
func (m *Model) rebuildArchivedTable() {
m.archivedTable.SetColumns(incidentColumns(m.width))
m.archivedTable.SetRows(incidentRows(m.archivedIncidents))
setRows(&m.archivedTable, incidentRows(m.archivedIncidents))
m.archivedTable.SetHeight(tableHeight(m.height, 8))
}
func (m *Model) rebuildScheduleTable() {
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))
}
@@ -358,7 +404,7 @@ func (m *Model) rebuildUserPickerTable() {
for i, u := range m.users {
rows[i] = table.Row{u.Username, u.Email}
}
m.userPickerTable.SetRows(rows)
setRows(&m.userPickerTable, rows)
m.userPickerTable.SetHeight(tableHeight(m.height, 10))
}
@@ -366,9 +412,13 @@ func (m *Model) rebuildUserManageTable() {
m.userManageTable.SetColumns(userManageColumns(m.width))
rows := make([]table.Row, len(m.users))
for i, u := range m.users {
rows[i] = table.Row{u.Username, u.Email, u.CreatedAt.UTC().Format("2006-01-02")}
topic := u.Topic()
if topic == "" {
topic = "—"
}
rows[i] = table.Row{u.Username, u.Email, topic, u.CreatedAt.UTC().Format("2006-01-02")}
}
m.userManageTable.SetRows(rows)
setRows(&m.userManageTable, rows)
m.userManageTable.SetHeight(tableHeight(m.height, 10))
}
@@ -385,16 +435,24 @@ func (m *Model) refreshDetailContent() {
return
}
if m.mode == modeAlertDetail {
m.detailViewport.SetContent(buildAlertDetailContent(m.selectedAlert, m.width))
m.detailViewport.SetContent(buildAlertDetailContent(m.styles, m.selectedAlert, m.width))
return
}
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() {
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 {
h := m.height - 5
if h < 1 {
h = 1
}
return h
}
func (m Model) detailViewportHeight() int {
@@ -489,13 +547,16 @@ func userPickerColumns(width int) []table.Column {
func userManageColumns(width int) []table.Column {
createdW := 12
usernameW := 25
emailW := width - usernameW - createdW - 8
topicW := 22
// 8 = bubbles' Padding(0, 1) on each of the four cells.
emailW := width - usernameW - topicW - createdW - 8
if emailW < 15 {
emailW = 15
}
return []table.Column{
{Title: "Username", Width: usernameW},
{Title: "Email", Width: emailW},
{Title: "Ntfy Topic", Width: topicW},
{Title: "Created", Width: createdW},
}
}
@@ -839,9 +900,9 @@ func fetchScheduleCmd(client *api.Client, from, to time.Time) tea.Cmd {
}
}
func assignScheduleCmd(client *api.Client, userID int64, dates []string, from, to time.Time) tea.Cmd {
func assignScheduleCmd(client *api.Client, userID int64, dates []string, replace bool, from, to time.Time) tea.Cmd {
return func() tea.Msg {
if _, err := client.AssignSchedule(userID, dates); err != nil {
if _, err := client.AssignSchedule(userID, dates, replace); err != nil {
return scheduleActionErrMsg{err}
}
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
@@ -896,6 +957,22 @@ func createUserCmd(client *api.Client, username, email string) tea.Cmd {
}
}
// setUserNotifyTargetCmd points a user's pages at a topic, or clears it when
// topic is empty. It re-lists afterwards so the table shows what the server
// stored rather than what was typed.
func setUserNotifyTargetCmd(client *api.Client, userID int64, topic string) tea.Cmd {
return func() tea.Msg {
if _, err := client.SetUserNotifyTarget(userID, topic); err != nil {
return userActionErrMsg{err}
}
users, err := client.ListUsers()
if err != nil {
return userActionErrMsg{err}
}
return usersFetchedMsg{users: users}
}
}
func deleteUserCmd(client *api.Client, userID int64) tea.Cmd {
return func() tea.Msg {
if err := client.DeleteUser(userID); err != nil {
+134 -2
View File
@@ -4,8 +4,9 @@ import (
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/table"
"github.com/yeniklas/terdut-tui/internal/api"
)
func TestNextFilter(t *testing.T) {
@@ -172,6 +173,32 @@ func TestAlertRows_ShowIncidentLink(t *testing.T) {
}
}
// A user with no ntfy topic gets no pages of their own — the row has to say so
// rather than leaving a blank that reads as "not loaded yet".
func TestUserManageRows_ShowMissingTopic(t *testing.T) {
topic := "terdut-niklas"
empty := ""
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40
m.users = []api.User{
{ID: 1, Username: "niklas", NtfyTopic: &topic},
{ID: 2, Username: "alex"},
// The server stores a blank topic as NULL, but a stale client or an older
// server can still hand one back; it means the same thing.
{ID: 3, Username: "sam", NtfyTopic: &empty},
}
m.rebuildUserManageTable()
rows := m.userManageTable.Rows()
if rows[0][2] != "terdut-niklas" {
t.Errorf("expected the topic in the row, got %q", rows[0][2])
}
if rows[1][2] != "—" || rows[2][2] != "—" {
t.Errorf("expected an em dash for nil and empty topics, got %q and %q",
rows[1][2], rows[2][2])
}
}
// A previous release overflowed the terminal by two columns because the padding
// budget was wrong. Columns plus bubbles' per-cell padding must land exactly on
// the window width.
@@ -191,6 +218,17 @@ func TestColumnWidthsFitTheTerminal(t *testing.T) {
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)
}
}
}
@@ -198,7 +236,9 @@ func TestColumnWidthsFitTheTerminal(t *testing.T) {
// what must not happen is a negative or zero column.
func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) {
for _, width := range []int{20, 40, 60} {
for _, w := range append(widths(incidentColumns(width)), widths(alertColumns(width))...) {
cols := append(widths(incidentColumns(width)), widths(alertColumns(width))...)
cols = append(cols, widths(userManageColumns(width))...)
for _, w := range cols {
if w < 1 {
t.Errorf("width %d produced a non-positive column %d", width, w)
}
@@ -238,3 +278,95 @@ func TestBuildScheduleDays(t *testing.T) {
t.Error("expected unassigned days to have no entry")
}
}
// ── Schedule reassignment ─────────────────────────────────────────────────
// scheduledWeek builds a model showing the week of 2026-07-27 with the given
// entries already on the rota.
func scheduledWeek(entries []api.ScheduleEntry) Model {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40
m.connected = true
m.activeSection = sectionSchedule
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
m.scheduleEntries = entries
m.scheduleDays = buildScheduleDays(m.scheduleWindow, entries)
m.rebuildScheduleTable()
return m
}
func TestScheduleConflicts(t *testing.T) {
m := scheduledWeek([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
{ID: 2, Date: "2026-07-28", UserID: 3, Username: "sam"},
{ID: 3, Date: "2026-07-29", UserID: 2, Username: "alex"},
})
week := []string{"2026-07-27", "2026-07-28", "2026-07-29", "2026-07-30"}
// Assigning alex: the days niklas and sam hold are conflicts, the day alex
// already holds is not, and the free day is not.
taken, holders := m.scheduleConflicts(week, 2)
if len(taken) != 2 || taken[0] != "2026-07-27" || taken[1] != "2026-07-28" {
t.Errorf("expected the two other people's days, got %v", taken)
}
if len(holders) != 2 || holders[0] != "niklas" || holders[1] != "sam" {
t.Errorf("expected both holders named once, got %v", holders)
}
}
// Reassigning somebody to a day they already hold takes nothing from anyone, so
// it must not raise a prompt — but it still needs replace, because the server
// rejects any date that already exists.
func TestScheduleConflicts_OwnDayIsNotAConflict(t *testing.T) {
m := scheduledWeek([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 2, Username: "alex"},
})
dates := []string{"2026-07-27"}
if taken, _ := m.scheduleConflicts(dates, 2); len(taken) != 0 {
t.Errorf("expected no conflict on the user's own day, got %v", taken)
}
if !m.scheduleOccupied(dates) {
t.Error("expected the day to still count as occupied, so replace is sent")
}
}
func TestScheduleOccupied_FreeDays(t *testing.T) {
m := scheduledWeek(nil)
if m.scheduleOccupied([]string{"2026-07-27", "2026-07-28"}) {
t.Error("expected an empty rota to need no replace")
}
}
func TestDayCount(t *testing.T) {
tests := []struct {
taken, total int
want string
}{
{1, 1, "This day is"},
{7, 7, "All 7 days are"},
{3, 7, "3 of 7 days are"},
}
for _, tt := range tests {
if got := dayCount(tt.taken, tt.total); got != tt.want {
t.Errorf("dayCount(%d, %d) = %q, want %q", tt.taken, tt.total, got, tt.want)
}
}
}
func TestJoinNames(t *testing.T) {
tests := []struct {
names []string
want string
}{
{nil, "somebody else"},
{[]string{"niklas"}, "niklas"},
{[]string{"niklas", "alex"}, "niklas and alex"},
{[]string{"niklas", "alex", "sam"}, "niklas, alex and sam"},
}
for _, tt := range tests {
if got := joinNames(tt.names); got != tt.want {
t.Errorf("joinNames(%v) = %q, want %q", tt.names, got, tt.want)
}
}
}
+143 -67
View File
@@ -3,97 +3,173 @@ package tui
import (
"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/yeniklas/terdut-tui/internal/api"
)
var (
colorPrimary = lipgloss.Color("69") // blue
colorMuted = lipgloss.Color("240") // gray
colorFiring = lipgloss.Color("196") // red
colorResolved = lipgloss.Color("70") // green
colorAccent = lipgloss.Color("214") // orange
// Styles is every style the views draw with, built once from a theme and held
// on the Model. Nothing here reads a colour literal: the theme is the only
// place a colour is named.
type Styles struct {
Header lipgloss.Style
TabActive lipgloss.Style
TabInactive lipgloss.Style
Footer lipgloss.Style
Status lipgloss.Style
colorSevCritical = lipgloss.Color("196") // red
colorSevError = lipgloss.Color("202") // dark orange
colorSevWarning = lipgloss.Color("214") // orange
colorSevInfo = lipgloss.Color("39") // cyan
styleHeader = lipgloss.NewStyle().
Bold(true).
Foreground(colorPrimary).
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)
Error lipgloss.Style
Firing lipgloss.Style
Resolved lipgloss.Style
Muted lipgloss.Style
Accent lipgloss.Style
AlertName lipgloss.Style
Bold lipgloss.Style
Selected lipgloss.Style
// Incident status. Triggered is unclaimed work and reads as loudly as a
// firing alert; acknowledged means somebody has it.
styleTriggered = lipgloss.NewStyle().Foreground(colorFiring).Bold(true)
styleAcknowledged = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
styleSnoozed = lipgloss.NewStyle().Foreground(colorMuted).Italic(true)
Triggered lipgloss.Style
Acknowledged lipgloss.Style
Snoozed lipgloss.Style
// Severity, over the conventional Alertmanager label values.
styleSevCritical = lipgloss.NewStyle().Foreground(colorSevCritical).Bold(true)
styleSevError = lipgloss.NewStyle().Foreground(colorSevError).Bold(true)
styleSevWarning = lipgloss.NewStyle().Foreground(colorSevWarning)
styleSevInfo = lipgloss.NewStyle().Foreground(colorSevInfo)
)
SevCritical lipgloss.Style
SevError lipgloss.Style
SevWarning lipgloss.Style
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.
func severityStyle(severity string) lipgloss.Style {
func (s Styles) Severity(severity string) lipgloss.Style {
switch strings.ToLower(severity) {
case "critical":
return styleSevCritical
return s.SevCritical
case "error":
return styleSevError
return s.SevError
case "warning":
return styleSevWarning
return s.SevWarning
case "info":
return styleSevInfo
return s.SevInfo
default:
return styleMuted
return s.Muted
}
}
// incidentStatusStyle picks the style for an incident status, falling back to
// muted for statuses added after this client was built.
func incidentStatusStyle(status string) lipgloss.Style {
// IncidentStatus picks the style for an incident status, falling back to muted
// for statuses added after this client was built.
func (s Styles) IncidentStatus(status string) lipgloss.Style {
switch status {
case api.StatusTriggered:
return styleTriggered
return s.Triggered
case api.StatusAcknowledged:
return styleAcknowledged
return s.Acknowledged
case api.StatusResolved:
return styleResolved
return s.Resolved
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")
}
+152 -47
View File
@@ -4,10 +4,10 @@ import (
"strconv"
"strings"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -24,7 +24,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.detailViewport.Width = m.width
m.detailViewport.Height = m.detailViewportHeight()
m.statsViewport.Width = m.width
m.statsViewport.Height = m.height - 5
m.statsViewport.Height = m.statsViewportHeight()
m.refreshDetailContent()
m.refreshStatsContent()
return m, nil
@@ -118,13 +118,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.hourStats = msg.byHour
m.dayStats = msg.byDay
m.statsLoading = false
m.statsLoaded = true
m.refreshStatsContent()
return m, nil
case detailStatsErrMsg:
m.statsLoading = false
// Mark it loaded even on failure, so tabbing back in does not re-fire the
// request every time. The tick and r still retry.
m.statsLoaded = true
m.statusMsg = "stats error: " + msg.err.Error()
m.mode = modeDashboard
return m, clearStatusCmd()
// ── Schedule messages ─────────────────────────────────────────────────
@@ -205,6 +208,9 @@ func (m Model) refreshActiveSection() tea.Cmd {
return tea.Batch(fetchIncidentsCmd(m.client, m.incidentFilter), fetchStatsCmd(m.client))
case sectionAlerts:
return tea.Batch(fetchAlertsCmd(m.client, m.alertFilter), fetchStatsCmd(m.client))
case sectionStats:
// Both: fetchStatsCmd feeds the Incident Response block, the other the charts.
return tea.Batch(fetchStatsCmd(m.client), fetchDetailStatsCmd(m.client))
case sectionArchived:
return fetchArchivedIncidentsCmd(m.client)
case sectionSchedule:
@@ -240,12 +246,6 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeStats:
var vpCmd tea.Cmd
m.statsViewport, vpCmd = m.statsViewport.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(vpCmd, ourCmd)
case modeUserPicker:
var tableCmd tea.Cmd
m.userPickerTable, tableCmd = m.userPickerTable.Update(msg)
@@ -258,6 +258,12 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeUserNotifyEdit:
var inputCmd tea.Cmd
m.ntfyTopicInput, inputCmd = m.ntfyTopicInput.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(inputCmd, ourCmd)
case modeAPIKeyCreate:
var inputCmd tea.Cmd
m.apiKeyNameInput, inputCmd = m.apiKeyNameInput.Update(msg)
@@ -286,6 +292,11 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.alertTable, tableCmd = m.alertTable.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
case sectionStats:
var vpCmd tea.Cmd
m.statsViewport, vpCmd = m.statsViewport.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(vpCmd, ourCmd)
case sectionArchived:
var tableCmd tea.Cmd
m.archivedTable, tableCmd = m.archivedTable.Update(msg)
@@ -319,12 +330,12 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m.handleSnoozeKey(msg)
case modeConfirm:
return m.handleConfirmKey(msg)
case modeStats:
return m.handleStatsKey(msg)
case modeUserPicker:
return m.handleUserPickerKey(msg)
case modeUserCreate:
return m.handleUserCreateKey(msg)
case modeUserNotifyEdit:
return m.handleUserNotifyEditKey(msg)
case modeAPIKeyMenu:
return m.handleAPIKeyMenuKey(msg)
case modeAPIKeyCreate:
@@ -457,7 +468,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil
}
cursor := m.scheduleTable.Cursor()
if cursor >= len(m.scheduleDays) {
if cursor < 0 || cursor >= len(m.scheduleDays) {
return m, nil
}
day := m.scheduleDays[cursor]
@@ -473,7 +484,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil
}
cursor := m.userManageTable.Cursor()
if cursor >= len(m.users) {
if cursor < 0 || cursor >= len(m.users) {
return m, nil
}
m.selectedUser = m.users[cursor]
@@ -482,12 +493,6 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
}
return m, nil
case "S":
if !m.connected {
return m, nil
}
return m.openStats()
case "n":
if m.activeSection != sectionUsers || !m.connected {
return m, nil
@@ -500,12 +505,29 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeUserCreate
return m, nil
case "t":
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]
// Prefilled with what they have, so editing a topic does not mean
// retyping it, and clearing one is a deliberate wipe.
m.ntfyTopicInput.SetValue(m.selectedUser.Topic())
m.ntfyTopicInput.CursorEnd()
m.ntfyTopicInput.Focus()
m.mode = modeUserNotifyEdit
return m, nil
case "k":
if m.activeSection != sectionUsers || !m.connected || len(m.users) == 0 {
return m, nil
}
cursor := m.userManageTable.Cursor()
if cursor >= len(m.users) {
if cursor < 0 || cursor >= len(m.users) {
return m, nil
}
m.selectedUser = m.users[cursor]
@@ -524,6 +546,11 @@ func (m *Model) loadSectionIfEmpty() tea.Cmd {
m.loading = true
return fetchAlertsCmd(m.client, m.alertFilter)
}
case sectionStats:
if !m.statsLoaded {
m.statsLoading = true
return tea.Batch(fetchStatsCmd(m.client), fetchDetailStatsCmd(m.client))
}
case sectionArchived:
if len(m.archivedIncidents) == 0 {
m.archivedLoading = true
@@ -677,9 +704,6 @@ func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeConfirm
return m, nil
case "S":
return m.openStats()
case "[":
return m.moveNoteCursor(-1), nil
@@ -733,9 +757,6 @@ func (m Model) handleAlertDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, clearStatusCmd()
}
return m.openIncident(api.Incident{ID: *m.selectedAlert.IncidentID})
case "S":
return m.openStats()
}
return m, nil
@@ -801,6 +822,7 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
}
m.pendingDeleteID = 0
m.pendingDeleteEntry = nil
m.pendingAssign = nil
return m, nil
}
@@ -830,30 +852,22 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.mode = modeDashboard
m.usersLoading = true
return m, deleteUserCmd(m.client, userID)
case confirmReassignSchedule:
p := m.pendingAssign
m.mode = modeDashboard
m.pendingAssign = nil
if p == nil {
return m, nil
}
m.scheduleLoading = true
return m, assignScheduleCmd(m.client, p.userID, p.dates, true,
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
}
return m, nil
}
// ── Stats ─────────────────────────────────────────────────────────────────
func (m Model) handleStatsKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if msg.String() == "esc" {
m.mode = m.statsReturnMode
return m, nil
}
return m, nil
}
// openStats enters the statistics view, remembering where to go back to.
func (m Model) openStats() (Model, tea.Cmd) {
m.statsReturnMode = m.mode
m.mode = modeStats
m.statsLoading = true
m.statsViewport = viewport.New(m.width, m.height-5)
return m, fetchDetailStatsCmd(m.client)
}
// ── User picker ───────────────────────────────────────────────────────────
func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
@@ -879,7 +893,7 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
}
scheduleCursor := m.scheduleTable.Cursor()
if scheduleCursor >= len(m.scheduleDays) {
if scheduleCursor < 0 || scheduleCursor >= len(m.scheduleDays) {
m.mode = modeDashboard
return m, nil
}
@@ -897,15 +911,84 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
} else {
dates = []string{d.Format("2006-01-02")}
}
// The server refuses a date somebody else holds, so ask before taking
// it rather than letting the request come back 409. The answer is
// already on screen — no round trip is needed to work out who loses
// their shift.
taken, holders := m.scheduleConflicts(dates, user.ID)
if len(taken) > 0 {
m.pendingAssign = &pendingAssign{
userID: user.ID,
username: user.Username,
dates: dates,
taken: taken,
holders: holders,
}
m.confirmTarget = confirmReassignSchedule
m.mode = modeConfirm
return m, nil
}
m.mode = modeDashboard
m.scheduleLoading = true
return m, assignScheduleCmd(m.client, user.ID, dates,
// Nobody else loses anything, but the server rejects any date that
// already exists — including days this same person already holds, which
// is a no-op worth letting through silently.
return m, assignScheduleCmd(m.client, user.ID, dates, m.scheduleOccupied(dates),
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
}
return m, nil
}
// scheduleConflicts reports which of dates are already held by somebody other
// than newUserID, and the distinct names holding them.
//
// Days the target already owns are not conflicts — reassigning somebody to
// their own shift takes nothing from anyone, and prompting for it would be
// noise. The server still needs replace for those, since it rejects any date
// that exists.
func (m Model) scheduleConflicts(dates []string, newUserID int64) (taken, holders []string) {
held := make(map[string]api.ScheduleEntry, len(m.scheduleDays))
for _, d := range m.scheduleDays {
if d.entry != nil {
held[d.entry.Date] = *d.entry
}
}
seen := make(map[string]bool)
for _, date := range dates {
e, ok := held[date]
if !ok || e.UserID == newUserID {
continue
}
taken = append(taken, date)
if !seen[e.Username] {
seen[e.Username] = true
holders = append(holders, e.Username)
}
}
return taken, holders
}
// scheduleOccupied reports whether any of dates already has an entry at all,
// including one belonging to the incoming user. That is what decides whether
// the request needs replace, as opposed to whether it needs confirming.
func (m Model) scheduleOccupied(dates []string) bool {
held := make(map[string]bool, len(m.scheduleDays))
for _, d := range m.scheduleDays {
if d.entry != nil {
held[d.entry.Date] = true
}
}
for _, date := range dates {
if held[date] {
return true
}
}
return false
}
// ── User management ───────────────────────────────────────────────────────────
func (m Model) handleUserCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) {
@@ -939,6 +1022,28 @@ func (m Model) handleUserCreateKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil
}
// handleUserNotifyEditKey edits one user's ntfy topic.
//
// Unlike the other forms here, an empty value is not a mistake to reject: it is
// how a topic is cleared, which the server accepts and treats as NULL.
func (m Model) handleUserNotifyEditKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.ntfyTopicInput.Blur()
m.mode = modeDashboard
return m, nil
case "enter":
topic := strings.TrimSpace(m.ntfyTopicInput.Value())
m.ntfyTopicInput.Blur()
m.mode = modeDashboard
m.usersLoading = true
return m, setUserNotifyTargetCmd(m.client, m.selectedUser.ID, topic)
}
return m, nil
}
func (m Model) handleAPIKeyMenuKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
+285 -26
View File
@@ -5,8 +5,9 @@ import (
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
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
@@ -31,7 +32,7 @@ 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.
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.connected = true
return m
@@ -309,13 +310,200 @@ func TestDeleteNote_ConfirmsThenActs(t *testing.T) {
}
}
// ── Schedule reassignment ─────────────────────────────────────────────────
// pickingOnCall opens the user picker for the schedule day at dayIndex, which
// is where a reassignment actually starts.
func pickingOnCall(entries []api.ScheduleEntry, dayIndex int, week bool) Model {
m := scheduledWeek(entries)
m.users = []api.User{
{ID: 1, Username: "niklas", Email: "n@example.com"},
{ID: 2, Username: "alex", Email: "a@example.com"},
}
m.rebuildUserPickerTable()
m.scheduleTable.SetCursor(dayIndex)
m.pickerAssignWeek = week
m.pickerTarget = pickerSchedule
m.mode = modeUserPicker
m.userPickerTable.SetCursor(1) // alex
return m
}
// The bug: a day somebody already holds could not be handed to anybody else.
// The server refuses it, so the TUI has to ask first and then say so.
func TestSchedule_ReassigningATakenDayAsksFirst(t *testing.T) {
m := pickingOnCall([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
}, 0, false)
m, cmd := press(t, m, "enter")
if m.mode != modeConfirm || m.confirmTarget != confirmReassignSchedule {
t.Fatalf("expected a reassignment confirmation, got mode %v target %v",
m.mode, m.confirmTarget)
}
if cmd != nil {
t.Error("expected nothing sent to the server before confirming")
}
mustContain(t, m.confirmPrompt(), "This day is assigned to niklas", "Reassign to alex?")
}
func TestSchedule_ReassignConfirmedSends(t *testing.T) {
m := pickingOnCall([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
}, 0, false)
m, _ = press(t, m, "enter")
m, cmd := press(t, m, "y")
if cmd == nil {
t.Fatal("expected the confirmed reassignment to be sent")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
if m.pendingAssign != nil {
t.Error("expected the pending assignment cleared")
}
}
// Declining must leave the rota alone — that is the whole point of the guard.
func TestSchedule_ReassignDeclinedSendsNothing(t *testing.T) {
m := pickingOnCall([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-27", UserID: 1, Username: "niklas"},
}, 0, false)
m, _ = press(t, m, "enter")
m, cmd := press(t, m, "n")
if cmd != nil {
t.Error("expected nothing sent when the reassignment is declined")
}
if m.pendingAssign != nil {
t.Error("expected the pending assignment discarded")
}
}
// A free day is the path that always worked, and must not grow a prompt.
func TestSchedule_AssigningAFreeDayDoesNotAsk(t *testing.T) {
m := pickingOnCall(nil, 0, false)
m, cmd := press(t, m, "enter")
if m.mode != modeDashboard {
t.Errorf("expected no prompt for a free day, got mode %v", m.mode)
}
if cmd == nil {
t.Error("expected the assignment to be sent straight away")
}
}
// The week case is the one that was worst: a single taken day rejected all
// seven. One prompt now covers the lot, and it says how much is being taken.
func TestSchedule_ReassigningAPartlyTakenWeekAsksOnce(t *testing.T) {
m := pickingOnCall([]api.ScheduleEntry{
{ID: 1, Date: "2026-07-28", UserID: 1, Username: "niklas"},
{ID: 2, Date: "2026-07-30", UserID: 3, Username: "sam"},
}, 0, true)
m, _ = press(t, m, "enter")
if m.confirmTarget != confirmReassignSchedule {
t.Fatalf("expected one confirmation for the week, got target %v", m.confirmTarget)
}
if got := len(m.pendingAssign.dates); got != 7 {
t.Errorf("expected all 7 days in the assignment, got %d", got)
}
mustContain(t, m.confirmPrompt(), "2 of 7 days are assigned to niklas and sam")
}
// ── Ntfy topic ────────────────────────────────────────────────────────────
// onUsers puts the model in the Users section with a loaded table.
func onUsers(users []api.User) Model {
m := sized()
m.activeSection = sectionUsers
m.users = users
m.rebuildUserManageTable()
return m
}
func userFixtures() []api.User {
topic := "terdut-niklas"
return []api.User{
{ID: 1, Username: "niklas", Email: "niklas@example.com", NtfyTopic: &topic},
{ID: 2, Username: "alex", Email: "alex@example.com"},
}
}
func TestNotifyTopic_EditPrefillsTheCurrentTopic(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
if m.mode != modeUserNotifyEdit {
t.Fatalf("expected the topic editor, got mode %v", m.mode)
}
if m.selectedUser.ID != 1 {
t.Errorf("expected the user under the cursor, got %d", m.selectedUser.ID)
}
// Prefilled, so editing a topic does not mean retyping it from scratch.
if got := m.ntfyTopicInput.Value(); got != "terdut-niklas" {
t.Errorf("expected the current topic prefilled, got %q", got)
}
}
// A user with no topic opens an empty field rather than the previous user's.
func TestNotifyTopic_EditStartsEmptyWhenUnset(t *testing.T) {
m := onUsers(userFixtures())
m, _ = press(t, m, "t")
m, _ = press(t, m, "esc")
m.userManageTable.SetCursor(1)
m, _ = press(t, m, "t")
if got := m.ntfyTopicInput.Value(); got != "" {
t.Errorf("expected an empty field for a user with no topic, got %q", got)
}
}
func TestNotifyTopic_EscapeAbandonsWithoutSaving(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m, cmd := press(t, m, "esc")
if cmd != nil {
t.Error("expected escape to save nothing")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
}
// Clearing a topic is a real action, not a no-op: it is how a user is taken off
// their own topic and back onto the shared fallback. Contrast the snooze prompt,
// where an empty value means "I changed my mind".
func TestNotifyTopic_EmptyInputStillSubmits(t *testing.T) {
m, _ := press(t, onUsers(userFixtures()), "t")
m.ntfyTopicInput.SetValue("")
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("expected clearing the topic to call the server")
}
if m.mode != modeDashboard {
t.Errorf("expected a return to the dashboard, got mode %v", m.mode)
}
}
func TestNotifyTopic_IsUsersSectionOnly(t *testing.T) {
m := sized()
m.activeSection = sectionIncidents
if next, cmd := press(t, m, "t"); cmd != nil || next.mode != modeDashboard {
t.Error("expected t to do nothing outside the Users section")
}
}
func TestTab_CyclesEverySection(t *testing.T) {
m := sized()
if m.activeSection != sectionIncidents {
t.Fatal("incidents is the section the client opens on")
}
want := []section{sectionAlerts, sectionArchived, sectionSchedule, sectionUsers, sectionIncidents}
want := []section{sectionAlerts, sectionStats, sectionArchived, sectionSchedule,
sectionUsers, sectionIncidents}
for i, expected := range want {
m, _ = press(t, m, "tab")
if m.activeSection != expected {
@@ -341,30 +529,54 @@ func TestFilter_CyclesPerSection(t *testing.T) {
}
}
// Stats opens from both the queue and an incident, and esc has to go back to
// wherever it was opened from.
func TestStats_ReturnsWhereItWasOpenedFrom(t *testing.T) {
t.Run("from the queue", func(t *testing.T) {
m, _ := press(t, sized(), "S")
if m.mode != modeStats {
t.Fatalf("expected stats, got mode %v", m.mode)
}
m, _ = press(t, m, "esc")
if m.mode != modeDashboard {
t.Errorf("expected the dashboard, got mode %v", m.mode)
}
})
// Stats is a section like any other: no key of its own, no mode of its own, and
// it loads once on first visit rather than on every tab-in — the three empty
// slices a quiet server returns are a real answer, not a missing one.
func TestStats_IsAnOrdinarySection(t *testing.T) {
m := sized()
m.activeSection = sectionAlerts
t.Run("from an incident", func(t *testing.T) {
m, _ := press(t, onIncident(openIncidentFixture(), nil), "S")
if m.mode != modeStats {
t.Fatalf("expected stats, got mode %v", m.mode)
}
m, _ = press(t, m, "esc")
if m.mode != modeIncidentDetail {
t.Errorf("expected the incident, got mode %v", m.mode)
}
})
m, cmd := press(t, m, "tab")
if m.activeSection != sectionStats {
t.Fatalf("expected the stats section, got %v", m.activeSection)
}
if m.mode != modeDashboard {
t.Errorf("stats is a section, not a mode: got mode %v", m.mode)
}
if cmd == nil {
t.Error("the first visit should fetch")
}
m.statsLoaded = true
m.statsLoading = false
if cmd := m.loadSectionIfEmpty(); cmd != nil {
t.Error("a second visit should reuse what was already fetched")
}
}
// S used to open the stats overlay from anywhere. It is gone, and must not
// disturb the view it is pressed in.
func TestStats_KeyIsGone(t *testing.T) {
m, _ := press(t, sized(), "S")
if m.activeSection != sectionIncidents || m.mode != modeDashboard {
t.Errorf("S should do nothing on the queue, got section %v mode %v",
m.activeSection, m.mode)
}
m, _ = press(t, onIncident(openIncidentFixture(), nil), "S")
if m.mode != modeIncidentDetail {
t.Errorf("S should leave the incident open, got mode %v", m.mode)
}
}
// The overlay never auto-refreshed, because the tick skipped every non-dashboard
// mode. As a section it rides the tick like the rest.
func TestStats_RefreshesOnTick(t *testing.T) {
m := sized()
m.activeSection = sectionStats
if m.refreshActiveSection() == nil {
t.Error("the stats section should refresh on the tick")
}
}
// Alerts carry no workflow state, so the detail view offers nothing but a way
@@ -465,3 +677,50 @@ func containsAll(s string, subs ...string) bool {
}
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.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)
}
m, _ = press(t, m, "enter") // panicked here
if m.mode == modeUserPicker {
t.Fatal("enter left the picker open; the assignment never went anywhere")
}
}
+217 -131
View File
@@ -6,11 +6,12 @@ import (
"strings"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api"
)
var sectionNames = []string{"Incidents", "Alerts", "Archived", "Schedule", "Users"}
// Order must match the section constants — renderTabs indexes this by ordinal.
var sectionNames = []string{"Incidents", "Alerts", "Stats", "Archived", "Schedule", "Users"}
func (m Model) View() string {
if m.width == 0 {
@@ -25,8 +26,8 @@ func (m Model) View() string {
}
func (m Model) renderHeader() string {
title := styleHeader.Render("terdut-tui")
right := styleMuted.Render(m.serverURL)
title := m.styles.Header.Render("terdut-tui")
right := m.styles.Muted.Render(m.serverURL)
return spread(title, right, m.width)
}
@@ -34,31 +35,31 @@ func (m Model) renderTabs() string {
var tabs []string
for i, name := range sectionNames {
if section(i) == m.activeSection {
tabs = append(tabs, styleTabActive.Render(name))
tabs = append(tabs, m.styles.TabActive.Render(name))
} 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
}
func (m Model) renderBody() string {
if m.err != nil {
return "\n" + styleError.Render(fmt.Sprintf(" Error: %v", m.err)) +
"\n" + styleMuted.Render(" Press r to retry.")
return "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) +
"\n" + m.styles.Muted.Render(" Press r to retry.")
}
if !m.connected {
return "\n" + styleMuted.Render(" Connecting…")
return "\n" + m.styles.Muted.Render(" Connecting…")
}
switch m.mode {
case modeIncidentDetail, modeAlertDetail:
return m.renderDetail()
case modeNote:
return m.renderPrompt(styleHeader.Render("Note: ") + m.noteInput.View())
return m.renderPrompt(m.styles.Header.Render("Note: ") + m.noteInput.View())
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:
switch m.confirmTarget {
case confirmDeleteNote, confirmResolveIncident:
@@ -68,12 +69,12 @@ func (m Model) renderBody() string {
default:
return m.renderSchedule()
}
case modeStats:
return m.renderStats()
case modeUserPicker:
return m.renderUserPicker()
case modeUserCreate:
return m.renderUserCreate()
case modeUserNotifyEdit:
return m.renderUserNotifyEdit()
case modeAPIKeyMenu:
return m.renderAPIKeyMenu()
case modeAPIKeyCreate:
@@ -89,9 +90,9 @@ func (m Model) renderBody() string {
func (m Model) renderFooter() string {
withStatus := func(actions string) string {
rendered := styleFooter.Render(actions)
rendered := m.styles.Footer.Render(actions)
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + rendered
return m.styles.Status.Render(" "+m.statusMsg) + "\n" + rendered
}
return "\n" + rendered
}
@@ -99,24 +100,21 @@ func (m Model) renderFooter() string {
switch m.mode {
case modeIncidentDetail:
if !m.selectedIncident.IsOpen() {
return withStatus(" x·archive c·note [/]·select d·del S·stats esc·back")
return withStatus(" x·archive c·note [/]·select d·del esc·back")
}
return withStatus(" a·ack A·unack R·resolve s·assign z·snooze Z·unsnooze c·note [/]·select d·del S·stats esc·back")
return withStatus(" a·ack A·unack R·resolve s·assign z·snooze Z·unsnooze c·note [/]·select d·del esc·back")
case modeAlertDetail:
return withStatus(" i·open incident S·stats esc·back")
return withStatus(" i·open incident esc·back")
case modeNote:
return "\n" + styleFooter.Render(" enter·submit esc·cancel")
return "\n" + m.styles.Footer.Render(" enter·submit esc·cancel")
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:
return "\n" + styleError.Render(" "+m.confirmPrompt())
case modeStats:
return withStatus(" esc·back")
return "\n" + m.styles.Error.Render(" "+m.confirmPrompt())
case modeUserPicker:
if m.pickerTarget == pickerIncidentAssignee {
@@ -131,6 +129,9 @@ func (m Model) renderFooter() string {
case modeUserCreate:
return withStatus(" tab·next field enter·create esc·cancel")
case modeUserNotifyEdit:
return withStatus(" enter·save esc·cancel (empty clears the topic)")
case modeAPIKeyMenu:
return withStatus(" n·new key r·revoke by ID esc·back")
@@ -146,17 +147,19 @@ func (m Model) renderFooter() string {
default:
switch m.activeSection {
case sectionIncidents:
return withStatus(" enter·detail x·archive f·filter S·stats r·refresh tab·section q·quit")
return withStatus(" enter·detail x·archive f·filter r·refresh tab·section q·quit")
case sectionAlerts:
return withStatus(" enter·detail f·filter S·stats r·refresh tab·section q·quit")
return withStatus(" enter·detail f·filter r·refresh tab·section q·quit")
case sectionStats:
return withStatus(" ↑/↓·scroll r·refresh tab·section q·quit")
case sectionArchived:
return withStatus(" enter·detail x·unarchive r·refresh tab·section q·quit")
case sectionSchedule:
return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit")
case sectionUsers:
return withStatus(" n·new user d·delete k·API keys r·refresh tab·section q·quit")
return withStatus(" n·new user t·topic d·delete k·API keys 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()))
}
}
@@ -175,10 +178,44 @@ func (m Model) confirmPrompt() string {
return "Delete schedule entry? [y/N]"
case confirmDeleteUser:
return fmt.Sprintf("Delete user %s (cascades all API keys)? [y/N]", m.selectedUser.Username)
case confirmReassignSchedule:
if p := m.pendingAssign; p != nil {
return fmt.Sprintf("%s assigned to %s. Reassign to %s? [y/N]",
dayCount(len(p.taken), len(p.dates)), joinNames(p.holders), p.username)
}
return "Reassign these days? [y/N]"
}
return "Are you sure? [y/N]"
}
// dayCount phrases how much of an assignment is being taken from somebody. A
// single day says so plainly; a partial week says which part, because "3 of 7"
// is the difference between taking a shift and taking somebody's whole week.
func dayCount(taken, total int) string {
switch {
case total == 1:
return "This day is"
case taken == total:
return fmt.Sprintf("All %d days are", total)
default:
return fmt.Sprintf("%d of %d days are", taken, total)
}
}
// joinNames renders a list of people as prose.
func joinNames(names []string) string {
switch len(names) {
case 0:
return "somebody else"
case 1:
return names[0]
case 2:
return names[0] + " and " + names[1]
default:
return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1]
}
}
// ── Dashboard ──────────────────────────────────────────────────────────────
func (m Model) renderDashboard() string {
@@ -187,6 +224,8 @@ func (m Model) renderDashboard() string {
return m.renderIncidents()
case sectionAlerts:
return m.renderAlerts()
case sectionStats:
return m.renderStats()
case sectionArchived:
return m.renderArchived()
case sectionSchedule:
@@ -202,9 +241,9 @@ func (m Model) renderIncidents() string {
var content string
switch {
case m.loading && len(m.incidents) == 0:
content = styleMuted.Render(" Loading incidents…")
content = m.styles.Muted.Render(" Loading incidents…")
case len(m.incidents) == 0:
content = styleMuted.Render(
content = m.styles.Muted.Render(
fmt.Sprintf(" No %s incidents.", filterLabel(m.incidentFilter)))
default:
content = m.incidentTable.View()
@@ -217,9 +256,9 @@ func (m Model) renderAlerts() string {
var content string
switch {
case m.loading && len(m.alerts) == 0:
content = styleMuted.Render(" Loading alerts…")
content = m.styles.Muted.Render(" Loading alerts…")
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:
content = m.alertTable.View()
}
@@ -228,10 +267,10 @@ func (m Model) renderAlerts() string {
func (m Model) renderArchived() string {
if m.archivedLoading {
return "\n" + styleMuted.Render(" Loading archived incidents…")
return "\n" + m.styles.Muted.Render(" Loading archived incidents…")
}
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()
}
@@ -247,12 +286,12 @@ func (m Model) renderIncidentStatsBar() string {
mttr = humanSeconds(m.incidentStats.MTTRSeconds)
}
left := fmt.Sprintf(" %s %s %s %s",
styleTriggered.Render(fmt.Sprintf("Triggered: %d", triggered)),
styleAcknowledged.Render(fmt.Sprintf("Acked: %d", acked)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
styleMuted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)),
m.styles.Triggered.Render(fmt.Sprintf("Triggered: %d", triggered)),
m.styles.Acknowledged.Render(fmt.Sprintf("Acked: %d", acked)),
m.styles.Resolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
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)
}
@@ -265,10 +304,10 @@ func (m Model) renderAlertStatsBar() string {
}
left := fmt.Sprintf(" Total: %d %s %s",
total,
styleFiring.Render(fmt.Sprintf("Firing: %d", firing)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
m.styles.Firing.Render(fmt.Sprintf("Firing: %d", firing)),
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)
}
@@ -285,20 +324,20 @@ func spread(left, right string, width int) string {
func (m Model) renderSchedule() string {
if m.scheduleLoading {
return "\n" + styleMuted.Render(" Loading schedule…")
return "\n" + m.styles.Muted.Render(" Loading schedule…")
}
var onCallLine string
if m.currentOnCall != nil {
onCallLine = fmt.Sprintf(" On-call today: %s",
styleAlertName.Render(m.currentOnCall.Username))
m.styles.AlertName.Render(m.currentOnCall.Username))
} else {
onCallLine = styleMuted.Render(" On-call today: nobody scheduled")
onCallLine = m.styles.Muted.Render(" On-call today: nobody scheduled")
}
from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 6)
windowLabel := styleMuted.Render(fmt.Sprintf(" %s — %s",
windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s — %s",
from.Format("Jan 02"), to.Format("Jan 02, 2006")))
header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n"
@@ -307,12 +346,12 @@ func (m Model) renderSchedule() string {
func (m Model) renderUserPicker() string {
if m.usersLoading {
return "\n" + styleMuted.Render(" Loading users…")
return "\n" + m.styles.Muted.Render(" Loading users…")
}
if m.pickerTarget == pickerIncidentAssignee {
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()
}
@@ -337,7 +376,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()
}
@@ -345,22 +384,24 @@ func (m Model) renderUserPicker() string {
func (m Model) renderDetail() string {
if m.detailLoading {
return "\n" + styleMuted.Render(" Loading…")
return "\n" + m.styles.Muted.Render(" Loading…")
}
return m.detailViewport.View()
}
// renderPrompt puts an input line under the detail pane.
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
}
// ── Stats ──────────────────────────────────────────────────────────────────
func (m Model) renderStats() string {
if m.statsLoading {
return "\n" + styleMuted.Render(" Loading statistics…")
// Only announce loading before the first result: a background refresh must not
// blank the page out from under whoever is reading it.
if m.statsLoading && !m.statsLoaded {
return "\n" + m.styles.Muted.Render(" Loading statistics…")
}
return m.statsViewport.View()
}
@@ -377,16 +418,16 @@ func line(style lipgloss.Style, s string) string {
// ── 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()
var b strings.Builder
contentW := width - 4
// Title + status header
title := styleAlertName.Render(inc.Title)
status := incidentStatusStyle(inc.Status).Render(incidentStatusLabel(inc))
title := s.AlertName.Render(inc.Title)
status := s.IncidentStatus(inc.Status).Render(incidentStatusLabel(inc))
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)
if gap < 1 {
@@ -399,9 +440,9 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt)))
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 {
b.WriteString(line(styleMuted, " Assigned: nobody"))
b.WriteString(line(s.Muted, " Assigned: nobody"))
}
if inc.AcknowledgedByID != nil {
@@ -409,14 +450,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
if inc.AcknowledgedAt != nil {
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)))
} else {
b.WriteString(line(styleMuted, " Acked: not acknowledged"))
b.WriteString(line(s.Muted, " Acked: not acknowledged"))
}
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))))
}
@@ -429,14 +470,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source))
}
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")))
}
b.WriteString("\n")
// Group labels — the correlation Alertmanager applied.
if len(inc.GroupLabels) > 0 {
b.WriteString(divider("Grouped By", width))
b.WriteString(divider(s, "Grouped By", width))
for _, k := range sortedKeys(inc.GroupLabels) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(inc.GroupLabels[k], contentW-24)))
}
@@ -444,14 +485,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
}
// 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 {
b.WriteString(line(styleMuted, " No alerts."))
b.WriteString(line(s.Muted, " No alerts."))
} else {
for _, a := range inc.Alerts {
marker := styleFiring.Render("●")
marker := s.Firing.Render("●")
if a.Status != "firing" {
marker = styleResolved.Render("✓")
marker = s.Resolved.Render("✓")
}
instance := a.Labels["instance"]
if instance == "" {
@@ -459,29 +500,29 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
}
b.WriteString(fmt.Sprintf(" %s %-28s %-26s %s\n",
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")
// Timeline — the only history the server keeps.
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 {
b.WriteString(line(styleMuted, " Nothing recorded yet."))
b.WriteString(line(s.Muted, " Nothing recorded yet."))
} else {
noteIndex := 0
for _, e := range timeline {
when := styleMuted.Render(humanAgo(now, e.CreatedAt))
when := s.Muted.Render(humanAgo(now, e.CreatedAt))
if e.Type != api.EventNote {
b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when))
continue
}
marker := " "
author := styleBold.Render(e.Username)
author := s.Bold.Render(e.Username)
if noteIndex == cursor {
marker = styleSelected.Render("> ")
author = styleSelected.Render(e.Username)
marker = s.Selected.Render("> ")
author = s.Selected.Render(e.Username)
}
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when))
b.WriteString(" " + e.Detail + "\n")
@@ -548,6 +589,15 @@ func eventLabel(e api.IncidentEvent) string {
return " Resolved by " + who
}
return " Resolved (all alerts stopped firing)"
case api.EventNotified:
// An empty username here is not "the server acted": it means the page
// went to the shared fallback topic, so it belongs to nobody.
return fmt.Sprintf(" Notified %s%s", notifiedTarget(who), notifyKind(e.Detail))
case api.EventNotifyFailed:
// The detail is "<kind>: <reason>", and the reason is the point — it is
// the only thing that says why nobody's phone rang.
return truncate(fmt.Sprintf(" Notification to %s failed · %s",
notifiedTarget(who), e.Detail), 52)
default:
label := " " + e.Type
if e.Detail != "" {
@@ -557,21 +607,40 @@ func eventLabel(e api.IncidentEvent) string {
}
}
func buildAlertDetailContent(alert api.Alert, width int) string {
// notifiedTarget names who a page reached. The server attaches no user when it
// published to the shared fallback topic, and saying so is the difference
// between "somebody was paged" and "the on-call rota was empty".
func notifiedTarget(username string) string {
if username == "" {
return "the fallback topic"
}
return username
}
// notifyKind renders the notification kind the server puts in Detail. It is an
// open set, so anything unrecognised is shown rather than dropped.
func notifyKind(detail string) string {
if detail == "" {
return ""
}
return " (" + detail + ")"
}
func buildAlertDetailContent(s Styles, alert api.Alert, width int) string {
now := time.Now()
var b strings.Builder
contentW := width - 4
name := styleAlertName.Render(alert.Name)
name := s.AlertName.Render(alert.Name)
var statusStr string
if alert.Status == "firing" {
statusStr = styleFiring.Render("● FIRING")
statusStr = s.Firing.Render("● FIRING")
} else {
label := "✓ RESOLVED"
if alert.ResolutionSource != nil {
label += " · " + *alert.ResolutionSource
}
statusStr = styleResolved.Render(label)
statusStr = s.Resolved.Render(label)
}
gap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
if gap < 1 {
@@ -591,15 +660,15 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
}
if alert.IncidentID != nil {
b.WriteString(fmt.Sprintf(" Incident: %s %s\n",
styleBold.Render(fmt.Sprintf("#%d", *alert.IncidentID)),
styleMuted.Render("press i to open it")))
s.Bold.Render(fmt.Sprintf("#%d", *alert.IncidentID)),
s.Muted.Render("press i to open it")))
} else {
b.WriteString(line(styleMuted, " Incident: none"))
b.WriteString(line(s.Muted, " Incident: none"))
}
b.WriteString("\n")
if len(alert.Labels) > 0 {
b.WriteString(divider("Labels", width))
b.WriteString(divider(s, "Labels", width))
for _, k := range sortedKeys(alert.Labels) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Labels[k], contentW-24)))
}
@@ -607,7 +676,7 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
}
if len(alert.Annotations) > 0 {
b.WriteString(divider("Annotations", width))
b.WriteString(divider(s, "Annotations", width))
for _, k := range sortedKeys(alert.Annotations) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Annotations[k], contentW-24)))
}
@@ -615,14 +684,14 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
}
// Alerts carry no workflow state: it all lives on the incident.
b.WriteString(divider("", width))
b.WriteString(line(styleMuted,
b.WriteString(divider(s, "", width))
b.WriteString(line(s.Muted,
" Alerts are read-only — acknowledge, assign, note and resolve on the incident."))
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
if barWidth < 8 {
barWidth = 8
@@ -635,41 +704,41 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
b.WriteString("\n")
// 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 {
b.WriteString(line(styleMuted, " No data."))
b.WriteString(line(s.Muted, " No data."))
} else {
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",
styleTriggered.Render(fmt.Sprintf("%d", incidents.Triggered))))
s.Triggered.Render(fmt.Sprintf("%d", incidents.Triggered))))
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",
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",
styleBold.Render(humanSeconds(incidents.MTTASeconds))))
s.Bold.Render(humanSeconds(incidents.MTTASeconds))))
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 {
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(divider("Top Alerts", width))
b.WriteString(divider(s, "Top Alerts", width))
if len(top) == 0 {
b.WriteString(line(styleMuted, " No data."))
b.WriteString(line(s.Muted, " No data."))
} else {
maxCount := top[0].Count
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("\n")
b.WriteString(divider("Alerts by Hour (UTC)", width))
b.WriteString(divider(s, "Alerts by Hour (UTC)", width))
if len(byHour) > 0 {
maxCount := 0
for _, h := range byHour {
@@ -678,15 +747,15 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, 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))
}
} else {
b.WriteString(line(styleMuted, " No data."))
b.WriteString(line(s.Muted, " No data."))
}
b.WriteString("\n")
b.WriteString(divider("Alerts by Day", width))
b.WriteString(divider(s, "Alerts by Day", width))
if len(byDay) > 0 {
maxCount := 0
for _, d := range byDay {
@@ -695,11 +764,11 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
}
}
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))
}
} else {
b.WriteString(line(styleMuted, " No data."))
b.WriteString(line(s.Muted, " No data."))
}
return b.String()
@@ -709,72 +778,82 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
func (m Model) renderUsers() string {
if m.usersLoading {
return "\n" + styleMuted.Render(" Loading users…")
return "\n" + m.styles.Muted.Render(" Loading users…")
}
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()
}
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: "
emailLabel := " Email: "
if m.userFormFocus == 0 {
usernameLabel = styleSelected.Render(" Username: ")
usernameLabel = m.styles.Selected.Render(" Username: ")
} else {
emailLabel = styleSelected.Render(" Email: ")
emailLabel = m.styles.Selected.Render(" Email: ")
}
return header +
usernameLabel + m.userFormInputs[0].View() + "\n" +
emailLabel + m.userFormInputs[1].View() + "\n"
}
func (m Model) renderUserNotifyEdit() string {
header := fmt.Sprintf("\n Push notifications for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
hint := line(m.styles.Muted,
" The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+
" their incidents then page the server's shared fallback topic, which\n"+
" carries no Acknowledge button.")
label := m.styles.Selected.Render(" Topic: ")
return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n"
}
func (m Model) renderAPIKeyMenu() string {
header := fmt.Sprintf("\n API keys for %s\n", styleBold.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.")
header := fmt.Sprintf("\n API keys for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
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" +
styleAccent.Render(" n") + " · create a new API key\n" +
styleAccent.Render(" r") + " · revoke a key by ID\n"
m.styles.Accent.Render(" n") + " · create a new API key\n" +
m.styles.Accent.Render(" r") + " · revoke a key by ID\n"
return header + "\n" + warning + options
}
func (m Model) renderAPIKeyCreate() string {
header := fmt.Sprintf("\n New API key for %s\n\n", styleBold.Render(m.selectedUser.Username))
label := styleSelected.Render(" Key name: ")
header := fmt.Sprintf("\n New API key for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
label := m.styles.Selected.Render(" Key name: ")
return header + label + m.apiKeyNameInput.View() + "\n"
}
func (m Model) renderAPIKeyReveal() string {
sep := styleMuted.Render(strings.Repeat("─", m.width))
warn := styleError.Render(" !! COPY NOW — this key will NEVER be shown again !!")
nameLine := fmt.Sprintf(" Key name: %s", styleBold.Render(m.revealedAPIKey.Name))
sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
warn := m.styles.Error.Render(" !! COPY NOW — this key will NEVER be shown again !!")
nameLine := fmt.Sprintf(" Key name: %s", m.styles.Bold.Render(m.revealedAPIKey.Name))
idLine := fmt.Sprintf(" Key ID: %s %s",
styleBold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)),
styleMuted.Render("(save this — needed for future revocation)"))
m.styles.Bold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)),
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" +
warn + "\n\n" +
nameLine + "\n" +
idLine + "\n\n" +
styleMuted.Render(" Key value:") + "\n" +
m.styles.Muted.Render(" Key value:") + "\n" +
keyLine + "\n\n" +
sep + "\n"
}
func (m Model) renderAPIKeyRevokeByID() string {
header := fmt.Sprintf("\n Revoke API key for %s\n", styleBold.Render(m.selectedUser.Username))
hint := line(styleMuted, " Enter the integer key ID (shown when the key was created).")
label := styleSelected.Render(" Key ID: ")
header := fmt.Sprintf("\n Revoke API key for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
hint := line(m.styles.Muted, " Enter the integer key ID (shown when the key was created).")
label := m.styles.Selected.Render(" Key ID: ")
return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n"
}
// ── Helpers ────────────────────────────────────────────────────────────────
func divider(title string, width int) string {
func divider(s Styles, title string, width int) string {
prefix := "── "
if title != "" {
prefix += title + " "
@@ -783,7 +862,7 @@ func divider(title string, width int) string {
if remaining > 0 {
prefix += strings.Repeat("─", remaining)
}
return styleMuted.Render(prefix) + "\n"
return s.Muted.Render(prefix) + "\n"
}
func sortedKeys(m map[string]string) []string {
@@ -806,12 +885,19 @@ func renderBarWidth(count, maxCount, maxWidth int) int {
return w
}
// truncate shortens s to max terminal cells, marking the cut with an ellipsis.
//
// Counted in runes rather than bytes: these strings are laid out against
// fixed-width columns, and a byte cut through a multi-byte rune would both
// mis-measure the column and emit a broken character. Server-supplied text —
// labels, annotations, delivery errors — is not guaranteed to be ASCII.
func truncate(s string, max int) string {
if max < 1 {
return ""
}
if len(s) <= max {
r := []rune(s)
if len(r) <= max {
return s
}
return s[:max-1] + "…"
return string(r[:max-1]) + "…"
}
+81 -16
View File
@@ -6,7 +6,9 @@ import (
"testing"
"time"
"github.com/yeniklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
tea "github.com/charmbracelet/bubbletea"
)
// ansi matches the escape sequences lipgloss emits when it decides the output
@@ -15,6 +17,10 @@ var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`)
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) {
t.Helper()
got = plain(got)
@@ -51,7 +57,7 @@ func TestIncidentDetail_RendersTheWholeStory(t *testing.T) {
{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,
"DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL",
"Assigned:", "admin",
@@ -71,7 +77,7 @@ func TestIncidentDetail_ShowsSnooze(t *testing.T) {
}
// 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".
mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110),
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110),
"TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h")
}
@@ -82,7 +88,7 @@ func TestIncidentDetail_HidesExpiredSnooze(t *testing.T) {
Title: "Noisy", Status: api.StatusTriggered,
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")
}
}
@@ -94,18 +100,18 @@ func TestIncidentDetail_ShowsResolutionSource(t *testing.T) {
Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour),
ResolvedAt: &now, ResolutionSource: &source,
}
mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110),
mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110),
"RESOLVED", "Resolved:", "manual")
}
func TestIncidentDetail_UnassignedAndUnacknowledged(t *testing.T) {
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) {
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) {
@@ -116,7 +122,7 @@ func TestIncidentDetail_MarksSelectedNote(t *testing.T) {
}
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") {
if strings.Contains(line, "alice") && !strings.HasPrefix(line, "> ") {
t.Errorf("expected the selected note marked, got %q", line)
@@ -174,6 +180,18 @@ func TestEventLabel_KnownTypes(t *testing.T) {
{api.IncidentEvent{Type: api.EventResolved, Username: "bo"}, "Resolved by bo"},
// No user means the server closed it via the alert cascade.
{api.IncidentEvent{Type: api.EventResolved}, "all alerts stopped firing"},
{api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "triggered"},
"Notified bo (triggered)"},
{api.IncidentEvent{Type: api.EventNotified, Username: "bo", Detail: "reminder"},
"Notified bo (reminder)"},
// On a notification, no user means the shared fallback topic — not that
// the server acted on its own.
{api.IncidentEvent{Type: api.EventNotified, Detail: "triggered"},
"Notified the fallback topic (triggered)"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Username: "bo", Detail: "triggered: ntfy returned 502"},
"Notification to bo failed"},
{api.IncidentEvent{Type: api.EventNotifyFailed, Detail: "triggered: no route to host"},
"Notification to the fallback topic failed"},
}
for _, tt := range tests {
t.Run(tt.event.Type, func(t *testing.T) {
@@ -182,6 +200,32 @@ func TestEventLabel_KnownTypes(t *testing.T) {
}
}
// The timeline is where a page that never landed becomes visible, so both
// outcomes have to survive into the rendered pane.
func TestIncidentDetail_RendersNotifications(t *testing.T) {
now := time.Now()
inc := api.Incident{ID: 1, Title: "DiskFull", Status: api.StatusTriggered, TriggeredAt: now}
timeline := []api.IncidentEvent{
{Type: api.EventTriggered, CreatedAt: now},
{Type: api.EventNotified, Username: "niklas", Detail: "triggered", CreatedAt: now},
{Type: api.EventNotifyFailed, Username: "niklas",
Detail: "reminder: ntfy returned 502", CreatedAt: now},
}
got := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 120)
mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed")
}
func TestUserNotifyEdit_SaysWhatAnEmptyValueDoes(t *testing.T) {
m := sized()
m.mode = modeUserNotifyEdit
m.selectedUser = api.User{ID: 1, Username: "niklas"}
mustContain(t, m.View(), "niklas", "empty to clear it", "fallback topic")
// The footer has to repeat it: that is where the reader looks for what a key does.
mustContain(t, m.renderFooter(), "empty clears the topic")
}
func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {
now := time.Now()
id := int64(7)
@@ -191,7 +235,7 @@ func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {
Labels: map[string]string{"instance": "node-1", "severity": "critical"},
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",
"instance", "node-1", "summary", "disk 90%",
"Alerts are read-only")
@@ -199,21 +243,21 @@ func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {
func TestAlertDetail_NoIncident(t *testing.T) {
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) {
source := "expiry"
alert := api.Alert{Name: "Gone", Status: "resolved", ReceivedAt: time.Now(),
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
// from an instant response.
func TestStats_RendersDashForMissingAverages(t *testing.T) {
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", "—",
"nothing has been acknowledged or resolved yet")
}
@@ -221,12 +265,12 @@ func TestStats_RendersDashForMissingAverages(t *testing.T) {
func TestStats_RendersAverages(t *testing.T) {
mtta, mttr := 150.0, 3600.0
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")
}
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) {
@@ -239,7 +283,7 @@ func TestView_TabsAndDashboardRender(t *testing.T) {
m.rebuildIncidentTable()
mustContain(t, m.View(),
"Incidents", "Alerts", "Archived", "Schedule", "Users",
"Incidents", "Alerts", "Stats", "Archived", "Schedule", "Users",
"Triggered: 1", "filter: open",
"DiskFull", "critical", "admin",
"enter·detail")
@@ -254,6 +298,27 @@ func TestView_EmptyStates(t *testing.T) {
mustContain(t, m.View(), "No archived incidents.")
}
// 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.
func TestView_StatsSectionRendersInPlace(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.connected = true
m.incidentStats = &api.IncidentStats{Total: 3, Triggered: 1}
m.topAlerts = []api.TopAlert{{Name: "DiskFull", Count: 4}}
m.statsLoaded = true
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = next.(Model)
m.activeSection = sectionStats
out := m.View()
mustContain(t, out, "Stats", "Incident Response", "Top Alerts", "DiskFull",
"tab·section")
if strings.Contains(plain(out), "Loading statistics") {
t.Error("loaded stats should not show the loading placeholder")
}
}
func TestView_ConnectionError(t *testing.T) {
m := sized()
m.connected = false
@@ -275,7 +340,7 @@ func TestFooter_IncidentDetailOffersResolveOnlyWhileOpen(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() != "" {
t.Error("expected no output before the first window size message")
}
+10 -3
View File
@@ -12,7 +12,14 @@ import (
"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 {
TagName string `json:"tag_name"`
@@ -125,7 +132,7 @@ func fetchLatest() (*release, error) {
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
@@ -134,7 +141,7 @@ func fetchLatest() (*release, error) {
defer resp.Body.Close()
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
+12 -5
View File
@@ -5,11 +5,12 @@ import (
"fmt"
"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"
"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"
@@ -38,8 +39,14 @@ func main() {
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)
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval)
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th)
p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil {