15 Commits

Author SHA1 Message Date
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
Niklas Ye 814ef2c5e8 fix: stop styled lines from indenting the text that follows
Release / test (push) Failing after 7s
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
An incident with nobody assigned and nobody holding it rendered its
detail view like this:

    Assigned:   nobody
                        Acked:      not acknowledged

lipgloss pads every line of a styled block out to the width of its widest
line. A trailing newline inside Render therefore produces a second line
made entirely of padding, and the next write to the builder starts after
that padding instead of at the left margin. Twelve call sites put the
newline inside.

Adding a line() helper that keeps the newline outside, and using it
throughout the content builders.

Shipped in v0.4.0 and only visible on the unassigned or unacknowledged
path, which is why it survived the pre-release check: that run had
somebody on call, so the incident was assigned and acknowledged and both
lines took the styled-with-value branch instead.

view_test.go covers the content builders, including the two states that
were broken and an expired snooze not being reported as a snooze.
2026-07-31 07:24:09 +02:00
Niklas Ye 8482315651 test: cover the API client and the update loop
The repo had no tests at all, which the v0.4.0 rewrite made
uncomfortable: this client speaks terdut-server's REST API directly, and
a wrong path or method is invisible until somebody runs the binary
against a live server. That is exactly how it broke when the server split
alerts from incidents.

The Elm architecture makes most of this cheap to check without a
terminal. Update is (Model, Msg) -> (Model, Cmd), so keypresses can be
synthesised and the resulting model inspected; a nil command is a
readable assertion that the model decided to do nothing.

Three suites:

  - client_test.go drives every incident endpoint against an httptest
    stub that records method, path, query and body. Also covers the
    filter query params, that a server error message survives into the
    error the UI shows, that a 404 from the on-call endpoint is not an
    error, and that omitted optional fields decode to zero rather than
    failing.
  - model_test.go covers the pure helpers: filter cycling, the snoozed
    pseudo-status, duration formatting, row builders, and the column
    width arithmetic that overflowed the terminal once already.
  - update_test.go covers the rules worth protecting rather than
    coverage for its own sake. Resolve prompts first and cancelling does
    not act, since resolution is terminal server-side. A resolved
    incident rejects all six workflow keys. Archiving refuses while an
    incident is open. The note cursor walks notes only and wraps. Stats
    returns to whichever view opened it. Modal states do not auto-refresh
    underneath the user.

129 tests, running in about 40ms.
2026-07-31 07:23:43 +02:00
Niklas Ye 9582543c1d ci: gate the release on vet and tests
The workflow only built and published, so a tag went straight to
binaries on the releases page with nothing having run against the code
first.

A test job now runs go vet and go test, and build depends on it; release
depends on build, so a tag that fails publishes nothing.

Only runs at release time — a push or pull request is still unchecked.
2026-07-31 07:23:28 +02:00
Niklas Ye 1140d773f8 feat!: incidents as the primary object
Release / build (amd64, darwin) (push) Failing after 9s
Release / build (arm64, darwin) (push) Failing after 10s
Release / build (amd64, linux) (push) Failing after 10s
Release / release (push) Has been skipped
Release / build (arm64, linux) (push) Failing after 11s
terdut-server v0.4.0 splits the alerts row into two objects, and the
endpoints this client drove for acknowledgement, comments and archiving
are gone. Pointing the same screens at the new paths would have missed
the point of the split: alerts are now Alertmanager's record, read-only
and carrying no human state, while the incident is the thing anyone
actually works on.

Incidents lead the section list and are what the client opens on. The
queue shows severity, status, assignee and age, and the detail view adds
what only exists server-side now: the group labels Alertmanager
correlated on, the member alerts, and an append-only timeline where
system events and notes are interleaved. That timeline is the whole
history the server keeps — alert rows are still mutated in place — so
rendering it in order matters more than styling it.

Actions all move onto the incident: a/A acknowledge, s assign, z/Z
snooze, c note, d delete note, x archive, R resolve.

Two of those need care rather than a keybinding:

  - R, not r, resolves, and it asks first. The server treats a manual
    resolve as terminal: a later occurrence opens a new incident instead
    of reopening this one, and an alert that never stops firing leaves
    the incident closed for good. A stray keypress is not recoverable,
    so the prompt says what it means.
  - x refuses on an open incident rather than archiving it, since
    archiving unresolved work only hides it. Snooze is offered as the
    "not now" answer, and the client treats a snoozed_until in the past
    as not snoozed, matching the server, which sweeps nothing.

Statistics lead with MTTA and MTTR, neither of which was computable
before. The server sends null until something has actually been
acknowledged or resolved, and that renders as — rather than 0: no data
is a different claim from instant.

Alerts keep a tab of their own as the raw feed — useful for asking what
Alertmanager is really sending — with an Incident column replacing Ack
By, and i in the detail view jumping to the incident where something can
be done about it. Archived now holds archived incidents; archiving an
alert is server-side housekeeping and no longer a user action.

BREAKING CHANGE: requires terdut-server v0.4.0 or later. Against an
older server every incident request 404s. Use terdut-tui v0.3.x with
servers before v0.4.0.
2026-07-30 21:54:54 +02:00
Niklas Ye e04cfcf433 feat: Last Seen column tracking Alertmanager re-send heartbeat
Release / build (amd64, darwin) (push) Failing after 10s
Release / build (amd64, linux) (push) Failing after 9s
Release / build (arm64, darwin) (push) Failing after 11s
Release / build (arm64, linux) (push) Failing after 9s
Release / release (push) Has been skipped
The alert list showed only Started, which comes from Prometheus and
never changes for the lifetime of an alert instance. A firing alert
that started 12 days ago looked identical whether Alertmanager
refreshed it 30 seconds ago or went silent a week ago.

terdut-server already tracks this: the webhook upsert sets
received_at on every accepted payload, including the periodic
re-sends issued at repeat_interval, and its archiver treats the
field as a liveness heartbeat. The field was already decoded into
api.Alert.ReceivedAt and simply never rendered.

Add a Last Seen column to the alert tables, rendered with the
existing humanAgo helper. The Alerts and Archived tabs share
alertColumns/alertRows, so both pick it up. The width budget is
re-derived for five columns; the slack constant now accounts for
all of bubbles' per-cell padding, so the table lands exactly on
the terminal width instead of overflowing by two columns as it
did with four.

The detail view gains a matching Last Seen line, with the timeline
labels widened to keep values aligned. Since received_at stops
advancing once an alert resolves, also pull through the server's
resolution_source and show it in the status header
(RESOLVED · alertmanager vs RESOLVED · expiry) so a frozen
timestamp is explained.
2026-07-30 08:48:53 +02:00
Niklas Ye 6834302622 feat: Archived alerts tab with archive/unarchive actions
Release / build (amd64, linux) (push) Failing after 6s
Release / release (push) Has been skipped
Release / build (amd64, darwin) (push) Failing after 5s
Release / build (arm64, darwin) (push) Failing after 6s
Release / build (arm64, linux) (push) Failing after 11s
Add a fourth tab (Alerts | Archived | Schedule | Users).
Archived alerts are fetched lazily on first visit using the
archived=true query param on GET /api/alerts.

Press x from the Alerts list or detail to archive an alert;
the non-archived list refreshes immediately. Press x from the
Archived list or detail to unarchive; the archived list
refreshes. Ack/unack are disabled in the Archived detail view.

New API methods: ArchiveAlert (POST), UnarchiveAlert (DELETE).
ArchivedAt field added to the Alert type.
2026-05-22 13:45:30 +02:00
Niklas Ye 24c2e6003a ci: GitHub Actions release workflow and Makefile 2026-05-22 11:59:21 +02:00
Niklas Ye f8e085e610 feat: one-week schedule view always starting on Monday 2026-05-22 11:56:47 +02:00
Niklas Ye d4839c9f6b fix: guard against negative schedule table cursor in renderUserPicker 2026-05-22 11:51:24 +02:00
18 changed files with 4068 additions and 653 deletions
+65
View File
@@ -0,0 +1,65 @@
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
- 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 ./...
+118
View File
@@ -0,0 +1,118 @@
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" .
- 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
+34 -1
View File
@@ -1,6 +1,19 @@
# terdut-tui # terdut-tui
TUI client for [terdut-server](https://github.com/terdut-server), a Prometheus Alertmanager receiver and on-call scheduler. 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
The server splits alerts from incidents, and this client mirrors it:
- **Alert** — Alertmanager's record. Firing or resolved, read-only, no workflow state.
- **Incident** — the work item: triggered → acknowledged → resolved, with an
assignee, snooze, notes and an append-only timeline. Many alerts to one incident,
correlated by Alertmanager's `groupKey`.
All user actions target incidents. Two server behaviours the UI has to respect:
manual resolve is **terminal** (hence the confirmation prompt), and snooze is the
non-destructive "not now" alternative.
## Tech stack ## Tech stack
@@ -57,6 +70,12 @@ go run . --self-update
go build -ldflags="-X main.version=v0.1.0" -o terdut-tui . 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 ## Development stages
| Stage | Feature | | Stage | Feature |
@@ -66,3 +85,17 @@ go build -ldflags="-X main.version=v0.1.0" -o terdut-tui .
| 3 | Alert detail: acknowledge, comment, statistics charts | | 3 | Alert detail: acknowledge, comment, statistics charts |
| 4 | On-call schedule calendar view | | 4 | On-call schedule calendar view |
| 5 | User management and API key lifecycle | | 5 | User management and API key lifecycle |
| 6 | Incidents: queue, timeline, ack/assign/snooze/resolve, MTTA/MTTR |
<!-- graymatter:instructions:begin — managed by `graymatter init`; edits inside this block are overwritten -->
## Memory (GrayMatter)
This project has persistent agent memory via the `graymatter` MCP tools:
- `memory_search` (`agent_id`, `query`) — call at the **start of a task** when prior context might matter.
- `memory_add` (`agent_id`, `text`) — call whenever you learn something **durable**: user preferences, decisions, conventions, gotchas.
- `memory_reflect` (`action`, `agent`, `text`/`target`) — update or forget stale facts. ⚠ takes `agent`, not `agent_id`.
- `checkpoint_save` / `checkpoint_resume` (`agent_id`) — snapshot/restore session state before major refactors or across restarts.
Use a stable `agent_id` of the form `<project>-<role>` (e.g. `myapp-backend`). Store conclusions, not conversation logs. Err on the side of remembering.
<!-- graymatter:instructions:end -->
+12
View File
@@ -0,0 +1,12 @@
VERSION := $(shell git describe --tags --always --dirty)
.PHONY: build install test
build:
go build -ldflags "-X main.version=$(VERSION)" -o terdut-tui .
install:
go install -ldflags "-X main.version=$(VERSION)" .
test:
go test ./...
+112 -9
View File
@@ -1,22 +1,64 @@
# terdut-tui # terdut-tui
A terminal user interface for [terdut-server](https://github.com/terdut-server). Communicates with the server over its REST API. A terminal user interface for [terdut-server](https://git.ryuvia.com/niklas/terdut-server). Communicates with the server over its REST API.
Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea). Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea).
## Features ## Features
- **Alert dashboard** — live view of firing and resolved alerts with auto-refresh - **Incident queue** — open incidents with severity, status, assignee and age, auto-refreshing
- **Alert actions** — acknowledge, comment, and view per-alert statistics - **Incident actions** — acknowledge, assign, snooze, note, resolve and archive
- **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 - **On-call schedule** — visual calendar of who is on duty, assign and remove entries
- **User management** — add and remove users, manage API keys - **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day
- **User management** — add and remove users, manage API keys, set each user's ntfy topic
> Requires terdut-server **v0.4.0 or later**. Earlier servers have no incidents API;
> use terdut-tui v0.3.x with those.
## Alerts and incidents
The server keeps two objects and this client follows that split:
- An **alert** is Alertmanager's record — firing or resolved, and read-only here.
- An **incident** is the work item. It is what you acknowledge, assign, snooze,
discuss and resolve, and it is where all the actions live.
Incidents are correlated by the `groupKey` Alertmanager already computed from your
`group_by` configuration, so several alerts commonly share one incident.
Two behaviours worth knowing before you press a key:
- **Resolving is final.** The server treats a manual resolve as terminal: a later
occurrence opens a *new* incident rather than reopening this one, and if the alert
underneath never stops firing the incident stays closed. The TUI asks for
confirmation before doing it.
- **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 ## Installation
Download the latest release binary for your platform from the [releases page](https://github.com/yeniklas/terdut-tui/releases), or build from source: Download the latest release binary for your platform from the [releases page](https://git.ryuvia.com/niklas/terdut-tui/releases), or build from source:
```bash ```bash
go install github.com/yeniklas/terdut-tui@latest go install git.ryuvia.com/niklas/terdut-tui@latest
``` ```
## Configuration ## Configuration
@@ -41,13 +83,74 @@ terdut-tui --self-update update to the latest release
### Keybindings ### Keybindings
Global:
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| `j` / `↓` | Move down | | `j` / `↓` | Move down |
| `k` / `↑` | Move up | | `k` / `↑` | Move up |
| `tab` | Switch section (Alerts / Schedule / Users) | | `tab` / `shift+tab` | Next / previous section |
| `enter` | Select / open detail | | `enter` | Open detail |
| `esc` | Go back | | `esc` | Go back |
| `r` | Refresh | | `r` | Refresh |
| `f` | Filter / cycle filter | | `f` | Cycle filter |
| `q` | Quit | | `q` | Quit |
The sections, in `tab` order: Incidents · Alerts · Stats · Archived · Schedule · Users.
Incidents section:
| Key | Action |
|-----|--------|
| `f` | Cycle: open → triggered → acknowledged → resolved → snoozed |
| `x` | Archive (resolved incidents only) |
Incident detail:
| Key | Action |
|-----|--------|
| `a` / `A` | Acknowledge / clear acknowledgement |
| `R` | Resolve — asks to confirm, and is final |
| `s` | Assign to a user |
| `z` / `Z` | Snooze for a duration / un-snooze |
| `c` | Add a note |
| `[` / `]` | Select a note |
| `d` | Delete the selected note (your own only) |
| `x` | Archive / un-archive |
Alerts section (read-only):
| Key | Action |
|-----|--------|
| `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 |
|-----|--------|
| `+` / `W` | Assign a day / a whole week |
| `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 go 1.25.9
+179 -23
View File
@@ -61,12 +61,20 @@ func (c *Client) do(req *http.Request, out any) error {
return nil return nil
} }
// ListAlerts fetches alerts from the server. status may be "firing", "resolved", or "" for all. // ListAlerts fetches alerts. status may be "firing", "resolved", or "" for all.
func (c *Client) ListAlerts(status string, limit int) ([]Alert, error) { // Set archived=true to fetch only archived alerts; false returns only non-archived.
//
// Alerts are read-only on the server — there is nothing to acknowledge or
// archive here. This is the raw feed, useful for checking what Alertmanager is
// actually sending; the work queue is ListIncidents.
func (c *Client) ListAlerts(status string, archived bool, limit int) ([]Alert, error) {
q := url.Values{} q := url.Values{}
if status != "" { if status != "" {
q.Set("status", status) q.Set("status", status)
} }
if archived {
q.Set("archived", "true")
}
if limit > 0 { if limit > 0 {
q.Set("limit", strconv.Itoa(limit)) q.Set("limit", strconv.Itoa(limit))
} }
@@ -117,49 +125,172 @@ func (c *Client) GetAlert(id int64) (*Alert, error) {
return &alert, c.do(req, &alert) return &alert, c.do(req, &alert)
} }
func (c *Client) AcknowledgeAlert(id int64) (*Alert, error) { // ── Incidents ──────────────────────────────────────────────────────────────
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/alerts/%d/acknowledge", id))
// ListIncidents fetches the work queue. status may be "triggered",
// "acknowledged", "resolved", or "" for the server default of open incidents
// only. archived and snoozed each switch the list to that set rather than
// adding to it, matching the server's filters.
func (c *Client) ListIncidents(status string, archived, snoozed bool, limit int) ([]Incident, error) {
q := url.Values{}
if status != "" {
q.Set("status", status)
}
if archived {
q.Set("archived", "true")
}
if snoozed {
q.Set("snoozed", "true")
}
if limit > 0 {
q.Set("limit", strconv.Itoa(limit))
}
path := "/api/incidents"
if len(q) > 0 {
path += "?" + q.Encode()
}
req, err := c.newRequest(http.MethodGet, path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var alert Alert var incidents []Incident
return &alert, c.do(req, &alert) return incidents, c.do(req, &incidents)
} }
func (c *Client) UnacknowledgeAlert(id int64) error { // GetIncident returns one incident with its member alerts inline.
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/alerts/%d/acknowledge", id)) func (c *Client) GetIncident(id int64) (*Incident, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d", id))
if err != nil {
return nil, err
}
var incident Incident
return &incident, c.do(req, &incident)
}
func (c *Client) GetIncidentTimeline(id int64) ([]IncidentEvent, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d/timeline", id))
if err != nil {
return nil, err
}
var events []IncidentEvent
return events, c.do(req, &events)
}
func (c *Client) AcknowledgeIncident(id int64) (*Incident, error) {
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/acknowledge", id))
if err != nil {
return nil, err
}
var incident Incident
return &incident, c.do(req, &incident)
}
func (c *Client) UnacknowledgeIncident(id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/acknowledge", id))
if err != nil { if err != nil {
return err return err
} }
return c.do(req, nil) return c.do(req, nil)
} }
func (c *Client) GetComments(alertID int64) ([]Comment, error) { // ResolveIncident closes an incident by hand. This is terminal on the server: a
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/alerts/%d/comments", alertID)) // later occurrence in the same group opens a new incident rather than reopening
// this one, and if the alert underneath never stops firing the incident stays
// closed. Use SnoozeIncident for "not now".
func (c *Client) ResolveIncident(id int64) (*Incident, error) {
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/resolve", id))
if err != nil { if err != nil {
return nil, err return nil, err
} }
var comments []Comment var incident Incident
return comments, c.do(req, &comments) return &incident, c.do(req, &incident)
} }
func (c *Client) AddComment(alertID int64, content string) (*Comment, error) { func (c *Client) AssignIncident(id, userID int64) (*Incident, error) {
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/alerts/%d/comments", alertID), map[string]string{"content": content}) body := struct {
UserID int64 `json:"user_id"`
}{UserID: userID}
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/incidents/%d/assign", id), body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var comment Comment var incident Incident
return &comment, c.do(req, &comment) return &incident, c.do(req, &incident)
} }
func (c *Client) DeleteComment(alertID, commentID int64) error { // SnoozeIncident hides an incident from the default queue for a duration,
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/alerts/%d/comments/%d", alertID, commentID)) // without closing it.
func (c *Client) SnoozeIncident(id int64, duration string) (*Incident, error) {
body := struct {
Duration string `json:"duration"`
}{Duration: duration}
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/incidents/%d/snooze", id), body)
if err != nil {
return nil, err
}
var incident Incident
return &incident, c.do(req, &incident)
}
func (c *Client) UnsnoozeIncident(id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/snooze", id))
if err != nil { if err != nil {
return err return err
} }
return c.do(req, nil) return c.do(req, nil)
} }
func (c *Client) ArchiveIncident(id int64) (*Incident, error) {
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/archive", id))
if err != nil {
return nil, err
}
var incident Incident
return &incident, c.do(req, &incident)
}
func (c *Client) UnarchiveIncident(id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/archive", id))
if err != nil {
return err
}
return c.do(req, nil)
}
// AddNote appends a note to the incident's timeline.
func (c *Client) AddNote(incidentID int64, content string) (*IncidentEvent, error) {
req, err := c.newRequestWithBody(http.MethodPost,
fmt.Sprintf("/api/incidents/%d/notes", incidentID), map[string]string{"content": content})
if err != nil {
return nil, err
}
var event IncidentEvent
return &event, c.do(req, &event)
}
// DeleteNote removes one of your own notes. Only notes are deletable — the rest
// of the timeline is a record of what happened.
func (c *Client) DeleteNote(incidentID, eventID int64) error {
req, err := c.newRequest(http.MethodDelete,
fmt.Sprintf("/api/incidents/%d/notes/%d", incidentID, eventID))
if err != nil {
return err
}
return c.do(req, nil)
}
func (c *Client) GetIncidentStats() (*IncidentStats, error) {
req, err := c.newRequest(http.MethodGet, "/api/stats/incidents")
if err != nil {
return nil, err
}
var stats IncidentStats
return &stats, c.do(req, &stats)
}
// ── Statistics ─────────────────────────────────────────────────────────────
func (c *Client) GetTopAlerts(limit int) ([]TopAlert, error) { func (c *Client) GetTopAlerts(limit int) ([]TopAlert, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/stats/alerts/top?limit=%d", limit)) req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/stats/alerts/top?limit=%d", limit))
if err != nil { if err != nil {
@@ -211,7 +342,9 @@ func (c *Client) GetCurrentOnCall() (*ScheduleEntry, error) {
return nil, nil return nil, nil
} }
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
var e struct{ Error string `json:"error"` } var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e) _ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error != "" { if e.Error != "" {
return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, e.Error) return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, e.Error)
@@ -225,11 +358,17 @@ func (c *Client) GetCurrentOnCall() (*ScheduleEntry, error) {
return &entry, nil 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 { body := struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Dates []string `json:"dates"` Dates []string `json:"dates"`
}{UserID: userID, Dates: dates} Replace bool `json:"replace,omitempty"`
}{UserID: userID, Dates: dates, Replace: replace}
req, err := c.newRequestWithBody(http.MethodPost, "/api/schedule", body) req, err := c.newRequestWithBody(http.MethodPost, "/api/schedule", body)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -268,6 +407,23 @@ func (c *Client) CreateUser(username, email string) (*User, error) {
return &user, c.do(req, &user) 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 { func (c *Client) DeleteUser(id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d", id)) req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d", id))
if err != nil { if err != nil {
+365
View File
@@ -0,0 +1,365 @@
package api
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// call records what the client actually put on the wire. The paths and methods
// are the contract with terdut-server, and getting one wrong is exactly how this
// client broke when the server split alerts from incidents.
type call struct {
method string
path string
query string
body string
auth string
}
// stub serves one canned response and records the request that fetched it.
func stub(t *testing.T, status int, response string) (*Client, *call) {
t.Helper()
got := &call{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
got.method, got.path, got.query = r.Method, r.URL.Path, r.URL.RawQuery
got.body, got.auth = string(body), r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
io.WriteString(w, response)
}))
t.Cleanup(srv.Close)
return NewClient(srv.URL, "test-key"), got
}
func TestClient_SendsBearerToken(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListIncidents("", false, false, 0); err != nil {
t.Fatalf("list: %v", err)
}
if got.auth != "Bearer test-key" {
t.Errorf("expected bearer token, got %q", got.auth)
}
}
// Every incident action, with the method and path terdut-server exposes.
func TestClient_IncidentEndpoints(t *testing.T) {
tests := []struct {
name string
invoke func(*Client) error
method string
path string
// resp defaults to a JSON object; endpoints returning a list need an array.
resp string
}{
{"get", func(c *Client) error { _, err := c.GetIncident(7); return err },
http.MethodGet, "/api/incidents/7", ""},
{"timeline", func(c *Client) error { _, err := c.GetIncidentTimeline(7); return err },
http.MethodGet, "/api/incidents/7/timeline", `[]`},
{"acknowledge", func(c *Client) error { _, err := c.AcknowledgeIncident(7); return err },
http.MethodPost, "/api/incidents/7/acknowledge", ""},
{"unacknowledge", func(c *Client) error { return c.UnacknowledgeIncident(7) },
http.MethodDelete, "/api/incidents/7/acknowledge", ""},
{"resolve", func(c *Client) error { _, err := c.ResolveIncident(7); return err },
http.MethodPost, "/api/incidents/7/resolve", ""},
{"assign", func(c *Client) error { _, err := c.AssignIncident(7, 3); return err },
http.MethodPost, "/api/incidents/7/assign", ""},
{"snooze", func(c *Client) error { _, err := c.SnoozeIncident(7, "2h"); return err },
http.MethodPost, "/api/incidents/7/snooze", ""},
{"unsnooze", func(c *Client) error { return c.UnsnoozeIncident(7) },
http.MethodDelete, "/api/incidents/7/snooze", ""},
{"archive", func(c *Client) error { _, err := c.ArchiveIncident(7); return err },
http.MethodPost, "/api/incidents/7/archive", ""},
{"unarchive", func(c *Client) error { return c.UnarchiveIncident(7) },
http.MethodDelete, "/api/incidents/7/archive", ""},
{"add note", func(c *Client) error { _, err := c.AddNote(7, "hi"); return err },
http.MethodPost, "/api/incidents/7/notes", ""},
{"delete note", func(c *Client) error { return c.DeleteNote(7, 12) },
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 {
t.Run(tt.name, func(t *testing.T) {
resp := tt.resp
if resp == "" {
resp = `{}`
}
c, got := stub(t, http.StatusOK, resp)
if err := tt.invoke(c); err != nil {
t.Fatalf("%s: %v", tt.name, err)
}
if got.method != tt.method || got.path != tt.path {
t.Errorf("expected %s %s, got %s %s", tt.method, tt.path, got.method, got.path)
}
})
}
}
func TestListIncidents_Filters(t *testing.T) {
tests := []struct {
name string
status string
archived bool
snoozed bool
limit int
want string
}{
{"default is the open queue", "", false, false, 0, ""},
{"status", "triggered", false, false, 0, "status=triggered"},
{"archived", "resolved", true, false, 0, "archived=true&status=resolved"},
{"snoozed", "", false, true, 0, "snoozed=true"},
{"limit", "", false, false, 500, "limit=500"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListIncidents(tt.status, tt.archived, tt.snoozed, tt.limit); err != nil {
t.Fatalf("list: %v", err)
}
if got.query != tt.want {
t.Errorf("expected query %q, got %q", tt.want, got.query)
}
})
}
}
func TestClient_RequestBodies(t *testing.T) {
t.Run("assign", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.AssignIncident(1, 42); err != nil {
t.Fatalf("assign: %v", err)
}
var body struct {
UserID int64 `json:"user_id"`
}
if err := json.Unmarshal([]byte(got.body), &body); err != nil {
t.Fatalf("decode body %q: %v", got.body, err)
}
if body.UserID != 42 {
t.Errorf("expected user_id 42, got %d", body.UserID)
}
})
t.Run("snooze", func(t *testing.T) {
c, got := stub(t, http.StatusOK, `{}`)
if _, err := c.SnoozeIncident(1, "90m"); err != nil {
t.Fatalf("snooze: %v", err)
}
var body struct {
Duration string `json:"duration"`
}
if err := json.Unmarshal([]byte(got.body), &body); err != nil {
t.Fatalf("decode body %q: %v", got.body, err)
}
if body.Duration != "90m" {
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,
// so the message has to survive into the error the TUI displays.
func TestClient_SurfacesServerErrorMessage(t *testing.T) {
c, _ := stub(t, http.StatusConflict, `{"error":"incident is resolved"}`)
_, err := c.ResolveIncident(1)
if err == nil {
t.Fatal("expected an error on 409")
}
if !strings.Contains(err.Error(), "incident is resolved") || !strings.Contains(err.Error(), "409") {
t.Errorf("expected status and server message in %q", err.Error())
}
}
func TestClient_ErrorWithoutBody(t *testing.T) {
c, _ := stub(t, http.StatusInternalServerError, ``)
if _, err := c.GetIncident(1); err == nil || !strings.Contains(err.Error(), "500") {
t.Errorf("expected a 500 error, got %v", err)
}
}
// Nobody on call is a normal state, not a failure.
func TestGetCurrentOnCall_404IsNotAnError(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `{"error":"no one is on call today"}`)
entry, err := c.GetCurrentOnCall()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if entry != nil {
t.Errorf("expected nil entry, got %+v", entry)
}
}
// Optional fields are omitted by the server rather than sent null, so decoding
// has to leave them zero instead of failing.
func TestIncident_DecodesSparseServerShape(t *testing.T) {
c, _ := stub(t, http.StatusOK, `{
"id": 1,
"group_key": "{}:{alertname=\"DiskFull\"}",
"title": "DiskFull (namespace=prod)",
"group_labels": {"alertname": "DiskFull", "namespace": "prod"},
"status": "triggered",
"severity": "critical",
"triggered_at": "2026-07-30T10:00:00Z"
}`)
inc, err := c.GetIncident(1)
if err != nil {
t.Fatalf("get: %v", err)
}
if inc.Title != "DiskFull (namespace=prod)" || inc.Severity != "critical" {
t.Errorf("unexpected incident %+v", inc)
}
if inc.GroupLabels["namespace"] != "prod" {
t.Errorf("expected group labels decoded, got %v", inc.GroupLabels)
}
if !inc.IsOpen() {
t.Error("an incident with no resolved_at is open")
}
if inc.IsSnoozed() {
t.Error("an incident with no snoozed_until is not snoozed")
}
if inc.AcknowledgedByID != nil || inc.AssignedToID != nil {
t.Error("expected acknowledgement and assignment to be absent")
}
}
// A snooze expires by falling into the past; the server sweeps nothing, so the
// client is what decides a stale snooze no longer counts.
func TestIncident_IsSnoozed(t *testing.T) {
past := time.Now().Add(-time.Hour)
future := time.Now().Add(time.Hour)
tests := []struct {
name string
until *time.Time
want bool
}{
{"never snoozed", nil, false},
{"snooze in the past has expired", &past, false},
{"snooze in the future holds", &future, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := (Incident{SnoozedUntil: tt.until}).IsSnoozed(); got != tt.want {
t.Errorf("expected %v, got %v", tt.want, got)
}
})
}
}
func TestIncident_IsOpen(t *testing.T) {
now := time.Now()
if !(Incident{}).IsOpen() {
t.Error("no resolved_at means open")
}
if (Incident{ResolvedAt: &now}).IsOpen() {
t.Error("resolved_at means closed")
}
}
func TestAlert_DecodesIncidentLink(t *testing.T) {
c, _ := stub(t, http.StatusOK, `{"id":3,"name":"DiskFull","status":"firing","incident_id":7}`)
a, err := c.GetAlert(3)
if err != nil {
t.Fatalf("get alert: %v", err)
}
if a.IncidentID == nil || *a.IncidentID != 7 {
t.Errorf("expected incident_id 7, got %v", a.IncidentID)
}
}
func TestListAlerts_ArchivedFilter(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListAlerts("", true, 50); err != nil {
t.Fatalf("list alerts: %v", err)
}
if got.path != "/api/alerts" || got.query != "archived=true&limit=50" {
t.Errorf("unexpected request %s?%s", got.path, got.query)
}
}
// MTTA and MTTR are null until something has been acknowledged or resolved. That
// is "no data", and it must not decode to a confident zero.
func TestIncidentStats_NullAveragesStayNil(t *testing.T) {
c, _ := stub(t, http.StatusOK,
`{"total":2,"triggered":2,"acknowledged":0,"resolved":0,"mtta_seconds":null,"mttr_seconds":null}`)
stats, err := c.GetIncidentStats()
if err != nil {
t.Fatalf("stats: %v", err)
}
if stats.Total != 2 || stats.Triggered != 2 {
t.Errorf("unexpected counts %+v", stats)
}
if stats.MTTASeconds != nil || stats.MTTRSeconds != nil {
t.Errorf("expected nil averages, got %v / %v", stats.MTTASeconds, stats.MTTRSeconds)
}
}
+138 -20
View File
@@ -2,20 +2,120 @@ package api
import "time" import "time"
// Alert is the server's record of what Alertmanager said. It is read-only:
// acknowledging, assigning, noting and resolving all happen on the Incident an
// alert belongs to.
type Alert struct { type Alert struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Fingerprint string `json:"fingerprint"` Fingerprint string `json:"fingerprint"`
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
Labels map[string]string `json:"labels"` Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"` Annotations map[string]string `json:"annotations"`
StartsAt time.Time `json:"starts_at"` StartsAt time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at"` EndsAt *time.Time `json:"ends_at"`
GeneratorURL string `json:"generator_url"` GeneratorURL string `json:"generator_url"`
ReceivedAt time.Time `json:"received_at"` ReceivedAt time.Time `json:"received_at"`
AcknowledgedByID *int64 `json:"acknowledged_by_id"` ArchivedAt *time.Time `json:"archived_at,omitempty"`
AcknowledgedBy string `json:"acknowledged_by"`
AcknowledgedAt *time.Time `json:"acknowledged_at"` // IncidentID is the most recent incident this alert belongs to. An alert row
// is reused across occurrences of the same fingerprint, so it belongs to a
// series of incidents over its life and this is only the newest.
IncidentID *int64 `json:"incident_id,omitempty"`
// ResolutionSource records why a resolved alert left the firing state:
// "alertmanager" for a real resolved webhook, "expiry" when the server
// inferred it after the alert stopped being refreshed.
ResolutionSource *string `json:"resolution_source,omitempty"`
}
// Incident statuses.
const (
StatusTriggered = "triggered"
StatusAcknowledged = "acknowledged"
StatusResolved = "resolved"
)
// Incident is the work item: what a person acknowledges, assigns, snoozes,
// discusses and resolves. Many alerts map to one incident, correlated by the
// groupKey Alertmanager computed from the operator's group_by configuration.
type Incident struct {
ID int64 `json:"id"`
GroupKey string `json:"group_key"`
Title string `json:"title"`
GroupLabels map[string]string `json:"group_labels"`
Status string `json:"status"`
// Severity is a high-water mark across the incident's alerts, never lowered,
// so a resolved incident still says how bad it got.
Severity string `json:"severity,omitempty"`
TriggeredAt time.Time `json:"triggered_at"`
AcknowledgedByID *int64 `json:"acknowledged_by_id,omitempty"`
AcknowledgedBy string `json:"acknowledged_by,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
AssignedToID *int64 `json:"assigned_to_id,omitempty"`
AssignedTo string `json:"assigned_to,omitempty"`
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
// ResolutionSource is "alerts" when every alert stopped firing, or "manual"
// when a person closed it. Treat the value set as open.
ResolutionSource *string `json:"resolution_source,omitempty"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
// Alerts is populated by GET /api/incidents/{id} only.
Alerts []Alert `json:"alerts,omitempty"`
}
// IsSnoozed reports whether the incident is currently quietened. A snooze
// expires by falling into the past; nothing on the server sweeps it.
func (i Incident) IsSnoozed() bool {
return i.SnoozedUntil != nil && i.SnoozedUntil.After(time.Now())
}
// IsOpen reports whether the incident is still work in progress.
func (i Incident) IsOpen() bool { return i.ResolvedAt == nil }
// Incident timeline event types written by the server. New ones may be added,
// so render unrecognised types generically rather than dropping them.
const (
EventTriggered = "triggered"
EventAlertAdded = "alert_added"
EventAlertResolved = "alert_resolved"
EventAcknowledged = "acknowledged"
EventUnacknowledged = "unacknowledged"
EventAssigned = "assigned"
EventSnoozed = "snoozed"
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
// the server acted rather than a person. On an "assigned" event the user is the
// assignee, not the actor.
type IncidentEvent struct {
ID int64 `json:"id"`
IncidentID int64 `json:"incident_id"`
Type string `json:"type"`
UserID *int64 `json:"user_id,omitempty"`
Username string `json:"username,omitempty"`
AlertID *int64 `json:"alert_id,omitempty"`
Detail string `json:"detail,omitempty"`
CreatedAt time.Time `json:"created_at"`
} }
type AlertStats struct { type AlertStats struct {
@@ -24,13 +124,16 @@ type AlertStats struct {
Resolved int `json:"resolved"` Resolved int `json:"resolved"`
} }
type Comment struct { // IncidentStats carries the queue counts plus mean time to acknowledge and to
ID int64 `json:"id"` // resolve. Both averages are nil until something has actually been acknowledged
AlertID int64 `json:"alert_id"` // or resolved — that is "no data", not zero.
UserID int64 `json:"user_id"` type IncidentStats struct {
Username string `json:"username"` Total int `json:"total"`
Content string `json:"content"` Triggered int `json:"triggered"`
CreatedAt time.Time `json:"created_at"` Acknowledged int `json:"acknowledged"`
Resolved int `json:"resolved"`
MTTASeconds *float64 `json:"mtta_seconds"`
MTTRSeconds *float64 `json:"mttr_seconds"`
} }
type TopAlert struct { type TopAlert struct {
@@ -62,6 +165,21 @@ type User struct {
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
CreatedAt time.Time `json:"created_at"` 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 { type APIKey struct {
+465 -159
View File
@@ -10,7 +10,7 @@ import (
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/api"
) )
// ── Enums ────────────────────────────────────────────────────────────────── // ── Enums ──────────────────────────────────────────────────────────────────
@@ -18,21 +18,29 @@ import (
type section int type section int
const ( const (
sectionAlerts section = iota // Incidents lead: they are the work. Alerts is the raw feed underneath.
sectionIncidents section = iota
sectionAlerts
sectionStats
sectionArchived
sectionSchedule sectionSchedule
sectionUsers sectionUsers
sectionCount = 6
) )
type mode int type mode int
const ( const (
modeDashboard mode = iota modeDashboard mode = iota
modeDetail modeIncidentDetail
modeComment modeAlertDetail
modeConfirmDelete modeNote
modeStats modeSnooze
modeScheduleUserPicker modeConfirm
modeUserPicker
modeUserCreate modeUserCreate
modeUserNotifyEdit
modeAPIKeyMenu modeAPIKeyMenu
modeAPIKeyCreate modeAPIKeyCreate
modeAPIKeyReveal modeAPIKeyReveal
@@ -42,28 +50,71 @@ const (
type confirmTarget int type confirmTarget int
const ( const (
confirmDeleteComment confirmTarget = iota confirmDeleteNote confirmTarget = iota
confirmResolveIncident
confirmDeleteScheduleEntry confirmDeleteScheduleEntry
confirmDeleteUser confirmDeleteUser
confirmReassignSchedule
) )
// pickerTarget says what the user picker is choosing a person for.
type pickerTarget int
const (
pickerSchedule pickerTarget = iota
pickerIncidentAssignee
)
// incidentFilters is the cycle the f key walks in the Incidents section. The
// empty string is the server default: open, unsnoozed incidents — the queue.
var incidentFilters = []string{"", api.StatusTriggered, api.StatusAcknowledged, api.StatusResolved, "snoozed"}
// alertFilters is the equivalent cycle for the raw alert feed.
var alertFilters = []string{"firing", "resolved", "", "archived"}
// incidentQuery translates a filter from the cycle into server query terms.
func incidentQuery(filter string) (status string, snoozed bool) {
if filter == "snoozed" {
return "", true
}
return filter, false
}
// filterLabel renders a filter for the status bar.
func filterLabel(filter string) string {
if filter == "" {
return "open"
}
return filter
}
// ── Messages ─────────────────────────────────────────────────────────────── // ── Messages ───────────────────────────────────────────────────────────────
// dashboard // dashboard
type connectedMsg struct{} type connectedMsg struct{}
type connectErrMsg struct{ err error } type connectErrMsg struct{ err error }
type incidentsFetchedMsg struct{ incidents []api.Incident }
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
type incidentActionDoneMsg struct {
incidents []api.Incident
status string
}
type alertsFetchedMsg struct{ alerts []api.Alert } type alertsFetchedMsg struct{ alerts []api.Alert }
type statsFetchedMsg struct{ stats api.AlertStats } type statsFetchedMsg struct {
incidents api.IncidentStats
alerts api.AlertStats
}
type fetchDataErrMsg struct{ err error } type fetchDataErrMsg struct{ err error }
type tickMsg time.Time type tickMsg time.Time
type clearStatusMsg struct{} type clearStatusMsg struct{}
// detail // detail
type alertDetailFetchedMsg struct { type incidentDetailFetchedMsg struct {
alert api.Alert incident api.Incident
comments []api.Comment timeline []api.IncidentEvent
} }
type alertDetailErrMsg struct{ err error } type alertDetailFetchedMsg struct{ alert api.Alert }
type detailErrMsg struct{ err error }
type actionErrMsg struct{ err error } type actionErrMsg struct{ err error }
type detailStatsFetchedMsg struct { type detailStatsFetchedMsg struct {
top []api.TopAlert top []api.TopAlert
@@ -93,6 +144,18 @@ type scheduleDay struct {
entry *api.ScheduleEntry 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 { type Model struct {
client *api.Client client *api.Client
serverURL string serverURL string
@@ -104,34 +167,55 @@ type Model struct {
height int height int
// Connection & dashboard // Connection & dashboard
connected bool connected bool
loading bool loading bool
err error err error
statusMsg string statusMsg string
alerts []api.Alert incidentStats *api.IncidentStats
stats *api.AlertStats alertStats *api.AlertStats
filterStatus string
alertTable table.Model
// Detail // Incidents
selectedAlert api.Alert incidents []api.Incident
comments []api.Comment incidentFilter string
commentCursor int incidentTable table.Model
detailLoading bool
detailViewport viewport.Model
// Comment compose // Alerts (read-only feed)
commentInput textinput.Model alerts []api.Alert
alertFilter string
alertTable table.Model
// Confirm delete // Archived incidents
confirmTarget confirmTarget archivedIncidents []api.Incident
pendingDeleteID int64 // comment ID archivedLoading bool
pendingDeleteEntry *api.ScheduleEntry archivedTable table.Model
// Incident detail
selectedIncident api.Incident
timeline []api.IncidentEvent
noteCursor int
detailLoading bool
detailViewport viewport.Model
// Alert detail
selectedAlert api.Alert
// Note compose & snooze
noteInput textinput.Model
snoozeInput textinput.Model
// Confirm
confirmTarget confirmTarget
pendingDeleteID int64 // note event ID
pendingDeleteEntry *api.ScheduleEntry
pendingAssign *pendingAssign
// Stats // Stats
topAlerts []api.TopAlert topAlerts []api.TopAlert
hourStats []api.HourStat hourStats []api.HourStat
dayStats []api.DayStat 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 statsLoading bool
statsViewport viewport.Model statsViewport viewport.Model
@@ -143,10 +227,11 @@ type Model struct {
scheduleLoading bool scheduleLoading bool
scheduleTable table.Model scheduleTable table.Model
// User picker (schedule assignment) // User picker (schedule assignment and incident assignee)
users []api.User users []api.User
usersLoading bool usersLoading bool
userPickerTable table.Model userPickerTable table.Model
pickerTarget pickerTarget
pickerAssignWeek bool pickerAssignWeek bool
// User management section // User management section
@@ -154,6 +239,7 @@ type Model struct {
selectedUser api.User selectedUser api.User
userFormInputs [2]textinput.Model userFormInputs [2]textinput.Model
userFormFocus int userFormFocus int
ntfyTopicInput textinput.Model
apiKeyNameInput textinput.Model apiKeyNameInput textinput.Model
apiKeyRevokeInput textinput.Model apiKeyRevokeInput textinput.Model
revealedAPIKey api.APIKey revealedAPIKey api.APIKey
@@ -165,9 +251,15 @@ type Model struct {
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model { func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model {
ts := defaultTableStyles() ts := defaultTableStyles()
incidentT := table.New(table.WithFocused(true))
incidentT.SetStyles(ts)
alertT := table.New(table.WithFocused(true)) alertT := table.New(table.WithFocused(true))
alertT.SetStyles(ts) alertT.SetStyles(ts)
archivedT := table.New(table.WithFocused(true))
archivedT.SetStyles(ts)
schedT := table.New(table.WithFocused(true)) schedT := table.New(table.WithFocused(true))
schedT.SetStyles(ts) schedT.SetStyles(ts)
@@ -177,9 +269,17 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
manageT := table.New(table.WithFocused(true)) manageT := table.New(table.WithFocused(true))
manageT.SetStyles(ts) manageT.SetStyles(ts)
ti := textinput.New() // Sized by the first tea.WindowSizeMsg; built here so it carries the default
ti.Placeholder = "type your comment…" // scroll keymap, which the zero value lacks.
ti.CharLimit = 1000 statsVP := viewport.New(0, 0)
noteIn := textinput.New()
noteIn.Placeholder = "type your note…"
noteIn.CharLimit = 1000
snoozeIn := textinput.New()
snoozeIn.Placeholder = "duration, e.g. 2h or 30m"
snoozeIn.CharLimit = 16
usernameIn := textinput.New() usernameIn := textinput.New()
usernameIn.Placeholder = "username" usernameIn.Placeholder = "username"
@@ -189,6 +289,10 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
emailIn.Placeholder = "email" emailIn.Placeholder = "email"
emailIn.CharLimit = 128 emailIn.CharLimit = 128
topicIn := textinput.New()
topicIn.Placeholder = "ntfy topic — empty clears it"
topicIn.CharLimit = 128
keyNameIn := textinput.New() keyNameIn := textinput.New()
keyNameIn.Placeholder = "key name (e.g. laptop)" keyNameIn.Placeholder = "key name (e.g. laptop)"
keyNameIn.CharLimit = 64 keyNameIn.CharLimit = 64
@@ -198,24 +302,35 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
revokeIn.CharLimit = 20 revokeIn.CharLimit = 20
now := time.Now().UTC() now := time.Now().UTC()
window := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
weekday := int(today.Weekday())
if weekday == 0 {
weekday = 7 // ISO: Sunday = 7
}
window := today.AddDate(0, 0, -(weekday - 1))
return Model{ return Model{
client: client, client: client,
serverURL: serverURL, serverURL: serverURL,
refreshInterval: refreshInterval, refreshInterval: refreshInterval,
activeSection: sectionAlerts, activeSection: sectionIncidents,
mode: modeDashboard, mode: modeDashboard,
loading: true, loading: true,
filterStatus: "firing", incidentFilter: "",
commentCursor: -1, alertFilter: "firing",
noteCursor: -1,
incidentTable: incidentT,
alertTable: alertT, alertTable: alertT,
commentInput: ti, archivedTable: archivedT,
statsViewport: statsVP,
noteInput: noteIn,
snoozeInput: snoozeIn,
scheduleWindow: window, scheduleWindow: window,
scheduleTable: schedT, scheduleTable: schedT,
userPickerTable: pickerT, userPickerTable: pickerT,
userManageTable: manageT, userManageTable: manageT,
userFormInputs: [2]textinput.Model{usernameIn, emailIn}, userFormInputs: [2]textinput.Model{usernameIn, emailIn},
ntfyTopicInput: topicIn,
apiKeyNameInput: keyNameIn, apiKeyNameInput: keyNameIn,
apiKeyRevokeInput: revokeIn, apiKeyRevokeInput: revokeIn,
help: help.New(), help: help.New(),
@@ -239,24 +354,28 @@ func defaultTableStyles() table.Styles {
return s return s
} }
func (m *Model) rebuildIncidentTable() {
m.incidentTable.SetColumns(incidentColumns(m.width))
m.incidentTable.SetRows(incidentRows(m.incidents))
m.incidentTable.SetHeight(tableHeight(m.height, 8))
}
func (m *Model) rebuildTable() { func (m *Model) rebuildTable() {
m.alertTable.SetColumns(alertColumns(m.width)) m.alertTable.SetColumns(alertColumns(m.width))
m.alertTable.SetRows(alertRows(m.alerts)) m.alertTable.SetRows(alertRows(m.alerts))
h := m.height - 8 m.alertTable.SetHeight(tableHeight(m.height, 8))
if h < 1 { }
h = 1
} func (m *Model) rebuildArchivedTable() {
m.alertTable.SetHeight(h) m.archivedTable.SetColumns(incidentColumns(m.width))
m.archivedTable.SetRows(incidentRows(m.archivedIncidents))
m.archivedTable.SetHeight(tableHeight(m.height, 8))
} }
func (m *Model) rebuildScheduleTable() { func (m *Model) rebuildScheduleTable() {
m.scheduleTable.SetColumns(scheduleColumns(m.width)) m.scheduleTable.SetColumns(scheduleColumns(m.width))
m.scheduleTable.SetRows(scheduleRows(m.scheduleDays)) m.scheduleTable.SetRows(scheduleRows(m.scheduleDays))
h := m.height - 10 m.scheduleTable.SetHeight(tableHeight(m.height, 10))
if h < 1 {
h = 1
}
m.scheduleTable.SetHeight(h)
} }
func (m *Model) rebuildUserPickerTable() { func (m *Model) rebuildUserPickerTable() {
@@ -266,41 +385,59 @@ func (m *Model) rebuildUserPickerTable() {
rows[i] = table.Row{u.Username, u.Email} rows[i] = table.Row{u.Username, u.Email}
} }
m.userPickerTable.SetRows(rows) m.userPickerTable.SetRows(rows)
h := m.height - 10 m.userPickerTable.SetHeight(tableHeight(m.height, 10))
if h < 1 {
h = 1
}
m.userPickerTable.SetHeight(h)
} }
func (m *Model) rebuildUserManageTable() { func (m *Model) rebuildUserManageTable() {
m.userManageTable.SetColumns(userManageColumns(m.width)) m.userManageTable.SetColumns(userManageColumns(m.width))
rows := make([]table.Row, len(m.users)) rows := make([]table.Row, len(m.users))
for i, u := range 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) m.userManageTable.SetRows(rows)
h := m.height - 10 m.userManageTable.SetHeight(tableHeight(m.height, 10))
}
func tableHeight(windowHeight, chrome int) int {
h := windowHeight - chrome
if h < 1 { if h < 1 {
h = 1 h = 1
} }
m.userManageTable.SetHeight(h) return h
} }
func (m *Model) refreshDetailContent() { func (m *Model) refreshDetailContent() {
if m.width == 0 { if m.width == 0 {
return return
} }
m.detailViewport.SetContent(buildDetailContent(m.selectedAlert, m.comments, m.commentCursor, m.width)) if m.mode == modeAlertDetail {
m.detailViewport.SetContent(buildAlertDetailContent(m.selectedAlert, m.width))
return
}
m.detailViewport.SetContent(
buildIncidentDetailContent(m.selectedIncident, m.timeline, m.noteCursor, m.width))
} }
func (m *Model) refreshStatsContent() { func (m *Model) refreshStatsContent() {
m.statsViewport.SetContent(buildStatsContent(m.topAlerts, m.hourStats, m.dayStats, m.width)) m.statsViewport.SetContent(
buildStatsContent(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 { func (m Model) detailViewportHeight() int {
h := m.height - 5 h := m.height - 5
if m.mode == modeComment { if m.mode == modeNote || m.mode == modeSnooze {
h -= 2 h -= 2
} }
if h < 1 { if h < 1 {
@@ -309,22 +446,57 @@ func (m Model) detailViewportHeight() int {
return h return h
} }
// noteEvents filters a timeline down to the deletable entries, which is what
// the [ and ] cursor walks.
func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
notes := make([]api.IncidentEvent, 0, len(timeline))
for _, e := range timeline {
if e.Type == api.EventNote {
notes = append(notes, e)
}
}
return notes
}
// ── Column definitions ───────────────────────────────────────────────────── // ── Column definitions ─────────────────────────────────────────────────────
func incidentColumns(width int) []table.Column {
const sevW, statusW, timeW = 9, 15, 12
titleW := width/2 - 10
if titleW < 20 {
titleW = 20
}
// 10 = bubbles' Padding(0, 1) on each of the five cells.
assigneeW := width - sevW - titleW - statusW - timeW - 10
if assigneeW < 8 {
assigneeW = 8
}
return []table.Column{
{Title: "Sev", Width: sevW},
{Title: "Incident", Width: titleW},
{Title: "Status", Width: statusW},
{Title: "Assignee", Width: assigneeW},
{Title: "Triggered", Width: timeW},
}
}
func alertColumns(width int) []table.Column { func alertColumns(width int) []table.Column {
nameW := width/2 - 8 const statusW, timeW = 10, 12
nameW := width/2 - 14
if nameW < 20 { if nameW < 20 {
nameW = 20 nameW = 20
} }
ackW := width - nameW - 10 - 12 - 6 // 10 = bubbles' Padding(0, 1) on each of the five cells.
if ackW < 8 { incW := width - nameW - statusW - 2*timeW - 10
ackW = 8 if incW < 8 {
incW = 8
} }
return []table.Column{ return []table.Column{
{Title: "Name", Width: nameW}, {Title: "Name", Width: nameW},
{Title: "Status", Width: 10}, {Title: "Status", Width: statusW},
{Title: "Started", Width: 12}, {Title: "Started", Width: timeW},
{Title: "Ack By", Width: ackW}, {Title: "Last Seen", Width: timeW},
{Title: "Incident", Width: incW},
} }
} }
@@ -355,24 +527,54 @@ func userPickerColumns(width int) []table.Column {
func userManageColumns(width int) []table.Column { func userManageColumns(width int) []table.Column {
createdW := 12 createdW := 12
usernameW := 25 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 { if emailW < 15 {
emailW = 15 emailW = 15
} }
return []table.Column{ return []table.Column{
{Title: "Username", Width: usernameW}, {Title: "Username", Width: usernameW},
{Title: "Email", Width: emailW}, {Title: "Email", Width: emailW},
{Title: "Ntfy Topic", Width: topicW},
{Title: "Created", Width: createdW}, {Title: "Created", Width: createdW},
} }
} }
// ── Row builders ─────────────────────────────────────────────────────────── // ── Row builders ───────────────────────────────────────────────────────────
func incidentRows(incidents []api.Incident) []table.Row {
now := time.Now()
rows := make([]table.Row, len(incidents))
for i, inc := range incidents {
severity := inc.Severity
if severity == "" {
severity = "—"
}
// bubbles' table renders plain strings, so a snoozed incident is marked
// in the status cell rather than styled.
status := inc.Status
if inc.IsSnoozed() {
status += " (zzz)"
}
assignee := inc.AssignedTo
if assignee == "" {
assignee = "—"
}
rows[i] = table.Row{severity, inc.Title, status, assignee, humanAgo(now, inc.TriggeredAt)}
}
return rows
}
func alertRows(alerts []api.Alert) []table.Row { func alertRows(alerts []api.Alert) []table.Row {
now := time.Now() now := time.Now()
rows := make([]table.Row, len(alerts)) rows := make([]table.Row, len(alerts))
for i, a := range alerts { for i, a := range alerts {
rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), a.AcknowledgedBy} incident := "—"
if a.IncidentID != nil {
incident = fmt.Sprintf("#%d", *a.IncidentID)
}
rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), incident}
} }
return rows return rows
} }
@@ -406,7 +608,7 @@ func buildScheduleDays(window time.Time, entries []api.ScheduleEntry) []schedule
for _, e := range entries { for _, e := range entries {
entryMap[e.Date] = e entryMap[e.Date] = e
} }
days := make([]scheduleDay, 14) days := make([]scheduleDay, 7)
for i := range days { for i := range days {
date := window.AddDate(0, 0, i) date := window.AddDate(0, 0, i)
day := scheduleDay{date: date} day := scheduleDay{date: date}
@@ -426,28 +628,49 @@ func humanAgo(now, t time.Time) string {
if d < 0 { if d < 0 {
d = 0 d = 0
} }
return humanDuration(d) + " ago"
}
// humanUntil renders a future deadline, used for snooze expiry.
func humanUntil(now, t time.Time) string {
d := t.Sub(now)
if d <= 0 {
return "expired"
}
return "in " + humanDuration(d)
}
func humanDuration(d time.Duration) string {
switch { switch {
case d < time.Minute: case d < time.Minute:
return "just now" return "moments"
case d < time.Hour: case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes())) return fmt.Sprintf("%dm", int(d.Minutes()))
case d < 24*time.Hour: case d < 24*time.Hour:
h := int(d.Hours()) h := int(d.Hours())
m := int(d.Minutes()) % 60 m := int(d.Minutes()) % 60
if m == 0 { if m == 0 {
return fmt.Sprintf("%dh ago", h) return fmt.Sprintf("%dh", h)
} }
return fmt.Sprintf("%dh %dm ago", h, m) return fmt.Sprintf("%dh %dm", h, m)
default: default:
days := int(d.Hours()) / 24 days := int(d.Hours()) / 24
h := int(d.Hours()) % 24 h := int(d.Hours()) % 24
if h == 0 { if h == 0 {
return fmt.Sprintf("%dd ago", days) return fmt.Sprintf("%dd", days)
} }
return fmt.Sprintf("%dd %dh ago", days, h) return fmt.Sprintf("%dd %dh", days, h)
} }
} }
// humanSeconds renders an MTTA/MTTR average.
func humanSeconds(secs *float64) string {
if secs == nil {
return "—"
}
return humanDuration(time.Duration(*secs) * time.Second)
}
// ── Commands ─────────────────────────────────────────────────────────────── // ── Commands ───────────────────────────────────────────────────────────────
func connectCmd(client *api.Client) tea.Cmd { func connectCmd(client *api.Client) tea.Cmd {
@@ -459,9 +682,36 @@ func connectCmd(client *api.Client) tea.Cmd {
} }
} }
func fetchAlertsCmd(client *api.Client, status string) tea.Cmd { func fetchIncidentsCmd(client *api.Client, filter string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
alerts, err := client.ListAlerts(status, 500) status, snoozed := incidentQuery(filter)
incidents, err := client.ListIncidents(status, false, snoozed, 500)
if err != nil {
return fetchDataErrMsg{err}
}
return incidentsFetchedMsg{incidents}
}
}
func fetchArchivedIncidentsCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
// Archived incidents are all resolved, so the status filter has to be
// widened past the server's open-only default or nothing comes back.
incidents, err := client.ListIncidents(api.StatusResolved, true, false, 500)
if err != nil {
return fetchDataErrMsg{err}
}
return archivedIncidentsFetchedMsg{incidents}
}
}
func fetchAlertsCmd(client *api.Client, filter string) tea.Cmd {
return func() tea.Msg {
status, archived := filter, false
if filter == "archived" {
status, archived = "", true
}
alerts, err := client.ListAlerts(status, archived, 500)
if err != nil { if err != nil {
return fetchDataErrMsg{err} return fetchDataErrMsg{err}
} }
@@ -471,11 +721,120 @@ func fetchAlertsCmd(client *api.Client, status string) tea.Cmd {
func fetchStatsCmd(client *api.Client) tea.Cmd { func fetchStatsCmd(client *api.Client) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
stats, err := client.GetAlertStats() incidents, err := client.GetIncidentStats()
if err != nil { if err != nil {
return fetchDataErrMsg{err} return fetchDataErrMsg{err}
} }
return statsFetchedMsg{*stats} alerts, err := client.GetAlertStats()
if err != nil {
return fetchDataErrMsg{err}
}
return statsFetchedMsg{incidents: *incidents, alerts: *alerts}
}
}
// incidentDetail reloads an incident and its timeline. Every detail-mode action
// funnels through it so the view always reflects what the server just did.
func incidentDetail(client *api.Client, id int64) tea.Msg {
incident, err := client.GetIncident(id)
if err != nil {
return detailErrMsg{err}
}
timeline, err := client.GetIncidentTimeline(id)
if err != nil {
return detailErrMsg{err}
}
return incidentDetailFetchedMsg{incident: *incident, timeline: timeline}
}
func fetchIncidentDetailCmd(client *api.Client, id int64) tea.Cmd {
return func() tea.Msg { return incidentDetail(client, id) }
}
// incidentActionCmd performs an action then reloads the detail view, reporting
// the server's error rather than a stale success.
func incidentActionCmd(client *api.Client, id int64, action func() error) tea.Cmd {
return func() tea.Msg {
if err := action(); err != nil {
return actionErrMsg{err}
}
return incidentDetail(client, id)
}
}
func acknowledgeIncidentCmd(client *api.Client, id int64) tea.Cmd {
return incidentActionCmd(client, id, func() error {
_, err := client.AcknowledgeIncident(id)
return err
})
}
func unacknowledgeIncidentCmd(client *api.Client, id int64) tea.Cmd {
return incidentActionCmd(client, id, func() error { return client.UnacknowledgeIncident(id) })
}
func resolveIncidentCmd(client *api.Client, id int64) tea.Cmd {
return incidentActionCmd(client, id, func() error {
_, err := client.ResolveIncident(id)
return err
})
}
func assignIncidentCmd(client *api.Client, id, userID int64) tea.Cmd {
return incidentActionCmd(client, id, func() error {
_, err := client.AssignIncident(id, userID)
return err
})
}
func snoozeIncidentCmd(client *api.Client, id int64, duration string) tea.Cmd {
return incidentActionCmd(client, id, func() error {
_, err := client.SnoozeIncident(id, duration)
return err
})
}
func unsnoozeIncidentCmd(client *api.Client, id int64) tea.Cmd {
return incidentActionCmd(client, id, func() error { return client.UnsnoozeIncident(id) })
}
func addNoteCmd(client *api.Client, id int64, content string) tea.Cmd {
return incidentActionCmd(client, id, func() error {
_, err := client.AddNote(id, content)
return err
})
}
func deleteNoteCmd(client *api.Client, id, eventID int64) tea.Cmd {
return incidentActionCmd(client, id, func() error { return client.DeleteNote(id, eventID) })
}
// archiveIncidentCmd archives from the list view, so it reloads the list rather
// than a detail pane.
func archiveIncidentCmd(client *api.Client, id int64, filter string) tea.Cmd {
return func() tea.Msg {
if _, err := client.ArchiveIncident(id); err != nil {
return actionErrMsg{err}
}
status, snoozed := incidentQuery(filter)
incidents, err := client.ListIncidents(status, false, snoozed, 500)
if err != nil {
return actionErrMsg{err}
}
return incidentActionDoneMsg{incidents: incidents, status: "Incident archived"}
}
}
func unarchiveIncidentCmd(client *api.Client, id int64) tea.Cmd {
return func() tea.Msg {
if err := client.UnarchiveIncident(id); err != nil {
return actionErrMsg{err}
}
incidents, err := client.ListIncidents(api.StatusResolved, true, false, 500)
if err != nil {
return actionErrMsg{err}
}
return archivedIncidentsFetchedMsg{incidents}
} }
} }
@@ -483,78 +842,9 @@ func fetchAlertDetailCmd(client *api.Client, alertID int64) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
alert, err := client.GetAlert(alertID) alert, err := client.GetAlert(alertID)
if err != nil { if err != nil {
return alertDetailErrMsg{err} return detailErrMsg{err}
} }
comments, err := client.GetComments(alertID) return alertDetailFetchedMsg{alert: *alert}
if err != nil {
return alertDetailErrMsg{err}
}
return alertDetailFetchedMsg{alert: *alert, comments: comments}
}
}
func acknowledgeCmd(client *api.Client, alertID int64) tea.Cmd {
return func() tea.Msg {
alert, err := client.AcknowledgeAlert(alertID)
if err != nil {
return actionErrMsg{err}
}
comments, err := client.GetComments(alertID)
if err != nil {
return actionErrMsg{err}
}
return alertDetailFetchedMsg{alert: *alert, comments: comments}
}
}
func unacknowledgeCmd(client *api.Client, alertID int64) tea.Cmd {
return func() tea.Msg {
if err := client.UnacknowledgeAlert(alertID); err != nil {
return actionErrMsg{err}
}
alert, err := client.GetAlert(alertID)
if err != nil {
return actionErrMsg{err}
}
comments, err := client.GetComments(alertID)
if err != nil {
return actionErrMsg{err}
}
return alertDetailFetchedMsg{alert: *alert, comments: comments}
}
}
func addCommentCmd(client *api.Client, alertID int64, content string) tea.Cmd {
return func() tea.Msg {
if _, err := client.AddComment(alertID, content); err != nil {
return actionErrMsg{err}
}
alert, err := client.GetAlert(alertID)
if err != nil {
return actionErrMsg{err}
}
comments, err := client.GetComments(alertID)
if err != nil {
return actionErrMsg{err}
}
return alertDetailFetchedMsg{alert: *alert, comments: comments}
}
}
func deleteCommentCmd(client *api.Client, alertID, commentID int64) tea.Cmd {
return func() tea.Msg {
if err := client.DeleteComment(alertID, commentID); err != nil {
return actionErrMsg{err}
}
alert, err := client.GetAlert(alertID)
if err != nil {
return actionErrMsg{err}
}
comments, err := client.GetComments(alertID)
if err != nil {
return actionErrMsg{err}
}
return alertDetailFetchedMsg{alert: *alert, comments: comments}
} }
} }
@@ -590,9 +880,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 { return func() tea.Msg {
if _, err := client.AssignSchedule(userID, dates); err != nil { if _, err := client.AssignSchedule(userID, dates, replace); err != nil {
return scheduleActionErrMsg{err} return scheduleActionErrMsg{err}
} }
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02")) entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
@@ -647,6 +937,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 { func deleteUserCmd(client *api.Client, userID int64) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
if err := client.DeleteUser(userID); err != nil { if err := client.DeleteUser(userID); err != nil {
+371
View File
@@ -0,0 +1,371 @@
package tui
import (
"testing"
"time"
"github.com/charmbracelet/bubbles/table"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
)
func TestNextFilter(t *testing.T) {
tests := []struct {
name string
cycle []string
current string
want string
}{
{"advances", incidentFilters, "", api.StatusTriggered},
{"advances again", incidentFilters, api.StatusTriggered, api.StatusAcknowledged},
{"wraps back to the open queue", incidentFilters, "snoozed", ""},
{"alerts advance", alertFilters, "firing", "resolved"},
{"alerts wrap", alertFilters, "archived", "firing"},
{"unknown current restarts the cycle", incidentFilters, "bogus", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := nextFilter(tt.cycle, tt.current); got != tt.want {
t.Errorf("expected %q, got %q", tt.want, got)
}
})
}
}
// "snoozed" is a pseudo-status in the filter cycle: the server has no such
// status, it is a separate query axis.
func TestIncidentQuery(t *testing.T) {
tests := []struct {
filter string
wantStatus string
wantSnoozed bool
}{
{"", "", false},
{api.StatusTriggered, api.StatusTriggered, false},
{api.StatusResolved, api.StatusResolved, false},
{"snoozed", "", true},
}
for _, tt := range tests {
t.Run(tt.filter, func(t *testing.T) {
status, snoozed := incidentQuery(tt.filter)
if status != tt.wantStatus || snoozed != tt.wantSnoozed {
t.Errorf("expected (%q, %v), got (%q, %v)",
tt.wantStatus, tt.wantSnoozed, status, snoozed)
}
})
}
}
func TestFilterLabel(t *testing.T) {
if got := filterLabel(""); got != "open" {
t.Errorf("the empty filter is the open queue, got %q", got)
}
if got := filterLabel("resolved"); got != "resolved" {
t.Errorf("expected resolved, got %q", got)
}
}
func TestNoteEvents(t *testing.T) {
timeline := []api.IncidentEvent{
{Type: api.EventTriggered},
{Type: api.EventNote, Detail: "first"},
{Type: api.EventAcknowledged},
{Type: api.EventNote, Detail: "second"},
}
notes := noteEvents(timeline)
if len(notes) != 2 {
t.Fatalf("expected 2 notes, got %d", len(notes))
}
if notes[0].Detail != "first" || notes[1].Detail != "second" {
t.Errorf("notes out of order: %v", notes)
}
if len(noteEvents(nil)) != 0 {
t.Error("an empty timeline has no notes")
}
}
func TestHumanDuration(t *testing.T) {
tests := []struct {
d time.Duration
want string
}{
{5 * time.Second, "moments"},
{90 * time.Second, "1m"},
{45 * time.Minute, "45m"},
{2 * time.Hour, "2h"},
{150 * time.Minute, "2h 30m"},
{48 * time.Hour, "2d"},
{50 * time.Hour, "2d 2h"},
}
for _, tt := range tests {
if got := humanDuration(tt.d); got != tt.want {
t.Errorf("humanDuration(%v) = %q, want %q", tt.d, got, tt.want)
}
}
}
func TestHumanAgo_ClampsFutureToNow(t *testing.T) {
now := time.Now()
// Server and client clocks disagree often enough that this must not render
// as a negative age.
if got := humanAgo(now, now.Add(time.Hour)); got != "moments ago" {
t.Errorf("expected a future timestamp to clamp, got %q", got)
}
}
func TestHumanUntil(t *testing.T) {
now := time.Now()
if got := humanUntil(now, now.Add(2*time.Hour)); got != "in 2h" {
t.Errorf("expected 'in 2h', got %q", got)
}
if got := humanUntil(now, now.Add(-time.Minute)); got != "expired" {
t.Errorf("a deadline in the past has expired, got %q", got)
}
}
// MTTA and MTTR are nil until something has been acknowledged or resolved, and
// that has to read as "no data" rather than an instant response.
func TestHumanSeconds(t *testing.T) {
if got := humanSeconds(nil); got != "—" {
t.Errorf("expected an em dash for no data, got %q", got)
}
secs := 150.0
if got := humanSeconds(&secs); got != "2m" {
t.Errorf("expected 2m, got %q", got)
}
}
func TestIncidentRows(t *testing.T) {
future := time.Now().Add(time.Hour)
rows := incidentRows([]api.Incident{
{Title: "DiskFull", Status: api.StatusTriggered, Severity: "critical",
AssignedTo: "admin", TriggeredAt: time.Now()},
{Title: "Unowned", Status: api.StatusTriggered, TriggeredAt: time.Now()},
{Title: "Quiet", Status: api.StatusTriggered, Severity: "info",
AssignedTo: "alice", SnoozedUntil: &future, TriggeredAt: time.Now()},
})
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
if rows[0][0] != "critical" || rows[0][3] != "admin" {
t.Errorf("unexpected first row %v", rows[0])
}
if rows[1][0] != "—" || rows[1][3] != "—" {
t.Errorf("missing severity and assignee should show an em dash, got %v", rows[1])
}
// bubbles' table renders plain strings, so snooze has to be marked in text.
if rows[2][2] != "triggered (zzz)" {
t.Errorf("expected a snooze marker in the status cell, got %q", rows[2][2])
}
}
func TestAlertRows_ShowIncidentLink(t *testing.T) {
id := int64(7)
rows := alertRows([]api.Alert{
{Name: "DiskFull", Status: "firing", IncidentID: &id},
{Name: "Orphan", Status: "resolved"},
})
if rows[0][4] != "#7" {
t.Errorf("expected #7, got %q", rows[0][4])
}
if rows[1][4] != "—" {
t.Errorf("an alert with no incident shows an em dash, got %q", rows[1][4])
}
}
// 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)
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.
func TestColumnWidthsFitTheTerminal(t *testing.T) {
for _, width := range []int{100, 110, 140, 200} {
for name, cols := range map[string][]int{
"incident": widths(incidentColumns(width)),
"alert": widths(alertColumns(width)),
} {
sum := 0
for _, w := range cols {
sum += w
}
const padding = 10 // bubbles applies Padding(0, 1) to each of five cells
if sum+padding != width {
t.Errorf("%s columns at width %d sum to %d+%d = %d",
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)
}
}
}
// Narrow terminals fall back to minimum widths, which legitimately overflow;
// what must not happen is a negative or zero column.
func TestColumnWidthsStayPositiveWhenNarrow(t *testing.T) {
for _, width := range []int{20, 40, 60} {
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)
}
}
}
}
func widths(cols []table.Column) []int {
out := make([]int, len(cols))
for i, c := range cols {
out[i] = c.Width
}
return out
}
func TestTableHeight_NeverGoesBelowOne(t *testing.T) {
if got := tableHeight(3, 10); got != 1 {
t.Errorf("expected a floor of 1, got %d", got)
}
if got := tableHeight(40, 8); got != 32 {
t.Errorf("expected 32, got %d", got)
}
}
func TestBuildScheduleDays(t *testing.T) {
monday := time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
days := buildScheduleDays(monday, []api.ScheduleEntry{
{Date: "2026-07-29", Username: "alice"},
})
if len(days) != 7 {
t.Fatalf("expected a 7-day window, got %d", len(days))
}
if days[2].entry == nil || days[2].entry.Username != "alice" {
t.Errorf("expected alice on the third day, got %+v", days[2].entry)
}
if days[0].entry != nil {
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)
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)
}
}
}
+55 -1
View File
@@ -1,6 +1,11 @@
package tui package tui
import "github.com/charmbracelet/lipgloss" import (
"strings"
"github.com/charmbracelet/lipgloss"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
)
var ( var (
colorPrimary = lipgloss.Color("69") // blue colorPrimary = lipgloss.Color("69") // blue
@@ -9,6 +14,11 @@ var (
colorResolved = lipgloss.Color("70") // green colorResolved = lipgloss.Color("70") // green
colorAccent = lipgloss.Color("214") // orange colorAccent = lipgloss.Color("214") // orange
colorSevCritical = lipgloss.Color("196") // red
colorSevError = lipgloss.Color("202") // dark orange
colorSevWarning = lipgloss.Color("214") // orange
colorSevInfo = lipgloss.Color("39") // cyan
styleHeader = lipgloss.NewStyle(). styleHeader = lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(colorPrimary). Foreground(colorPrimary).
@@ -42,4 +52,48 @@ var (
styleBold = lipgloss.NewStyle().Bold(true) styleBold = lipgloss.NewStyle().Bold(true)
styleSelected = lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) styleSelected = lipgloss.NewStyle().Foreground(colorPrimary).Bold(true)
styleAccent = lipgloss.NewStyle().Foreground(colorAccent) styleAccent = lipgloss.NewStyle().Foreground(colorAccent)
// 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)
// 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)
) )
// severityStyle 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 {
switch strings.ToLower(severity) {
case "critical":
return styleSevCritical
case "error":
return styleSevError
case "warning":
return styleSevWarning
case "info":
return styleSevInfo
default:
return styleMuted
}
}
// 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 {
switch status {
case api.StatusTriggered:
return styleTriggered
case api.StatusAcknowledged:
return styleAcknowledged
case api.StatusResolved:
return styleResolved
default:
return styleMuted
}
}
+574 -197
View File
File diff suppressed because it is too large Load Diff
+678
View File
@@ -0,0 +1,678 @@
package tui
import (
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
)
// press sends one key and returns the resulting model and command. A nil command
// means the model decided to do nothing, which is what most of these tests are
// really asserting.
func press(t *testing.T, m Model, key string) (Model, tea.Cmd) {
t.Helper()
var msg tea.KeyMsg
switch key {
case "esc":
msg = tea.KeyMsg{Type: tea.KeyEsc}
case "enter":
msg = tea.KeyMsg{Type: tea.KeyEnter}
case "tab":
msg = tea.KeyMsg{Type: tea.KeyTab}
default:
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
}
next, cmd := m.Update(msg)
return next.(Model), 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.width, m.height = 120, 40
m.connected = true
return m
}
// onIncident opens the incident detail view directly, skipping the fetch.
func onIncident(inc api.Incident, timeline []api.IncidentEvent) Model {
m := sized()
m.mode = modeIncidentDetail
m.selectedIncident = inc
m.timeline = timeline
m.noteCursor = -1
return m
}
func openIncidentFixture() api.Incident {
return api.Incident{ID: 1, Title: "DiskFull", Status: api.StatusTriggered,
Severity: "critical", TriggeredAt: time.Now()}
}
func resolvedIncidentFixture() api.Incident {
now := time.Now()
source := "manual"
inc := openIncidentFixture()
inc.Status = api.StatusResolved
inc.ResolvedAt = &now
inc.ResolutionSource = &source
return inc
}
// Resolving is terminal on the server: a later occurrence opens a new incident
// rather than reopening this one. A stray keypress must not be able to do that.
func TestResolve_AsksBeforeDoingIt(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, cmd := press(t, m, "R")
if m.mode != modeConfirm {
t.Fatalf("expected a confirmation prompt, got mode %v", m.mode)
}
if m.confirmTarget != confirmResolveIncident {
t.Errorf("expected the resolve target, got %v", m.confirmTarget)
}
if cmd != nil {
t.Error("nothing should be sent to the server before confirming")
}
if !containsAll(m.confirmPrompt(), "final", "new incident") {
t.Errorf("the prompt should say resolving is final, got %q", m.confirmPrompt())
}
}
func TestResolve_CancelReturnsToDetailWithoutActing(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "R")
m, cmd := press(t, m, "n")
if m.mode != modeIncidentDetail {
t.Errorf("expected to land back on the incident, got mode %v", m.mode)
}
if cmd != nil {
t.Error("cancelling must not act")
}
}
func TestResolve_ConfirmActs(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "R")
m, cmd := press(t, m, "y")
if cmd == nil {
t.Error("confirming should issue the resolve")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to return to the incident, got mode %v", m.mode)
}
}
// The server answers 409 on all of these; saying so up front beats a round trip.
func TestResolvedIncident_RejectsWorkflowActions(t *testing.T) {
for _, key := range []string{"a", "A", "R", "s", "z", "Z"} {
t.Run(key, func(t *testing.T) {
m := onIncident(resolvedIncidentFixture(), nil)
m, cmd := press(t, m, key)
if cmd == nil {
t.Error("expected a status message command")
}
if m.statusMsg == "" {
t.Error("expected an explanation in the status line")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to stay on the incident, got mode %v", m.mode)
}
})
}
}
func TestOpenIncident_AcknowledgeTwiceIsRejected(t *testing.T) {
inc := openIncidentFixture()
id := int64(2)
at := time.Now()
inc.Status = api.StatusAcknowledged
inc.AcknowledgedByID = &id
inc.AcknowledgedBy = "alice"
inc.AcknowledgedAt = &at
m, _ := press(t, onIncident(inc, nil), "a")
if !containsAll(m.statusMsg, "already acknowledged", "alice") {
t.Errorf("expected to be told who holds it, got %q", m.statusMsg)
}
}
func TestOpenIncident_UnacknowledgeRequiresAnAcknowledgement(t *testing.T) {
m, _ := press(t, onIncident(openIncidentFixture(), nil), "A")
if m.statusMsg != "not acknowledged" {
t.Errorf("expected 'not acknowledged', got %q", m.statusMsg)
}
}
func TestOpenIncident_UnsnoozeRequiresASnooze(t *testing.T) {
m, _ := press(t, onIncident(openIncidentFixture(), nil), "Z")
if m.statusMsg != "not snoozed" {
t.Errorf("expected 'not snoozed', got %q", m.statusMsg)
}
}
// Archiving unresolved work only hides it, so the client refuses rather than
// letting the queue be cleared by pressing x.
func TestArchive_RefusesOpenIncident(t *testing.T) {
t.Run("from the detail view", func(t *testing.T) {
m, cmd := press(t, onIncident(openIncidentFixture(), nil), "x")
if cmd == nil || m.statusMsg == "" {
t.Error("expected a refusal message")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to stay put, got mode %v", m.mode)
}
})
t.Run("from the queue", func(t *testing.T) {
m := sized()
m.incidents = []api.Incident{openIncidentFixture()}
m.rebuildIncidentTable()
m, _ = press(t, m, "x")
if m.statusMsg == "" {
t.Error("expected a refusal message")
}
})
}
func TestArchive_AllowedOnResolvedIncident(t *testing.T) {
m := sized()
m.incidents = []api.Incident{resolvedIncidentFixture()}
m.rebuildIncidentTable()
m, cmd := press(t, m, "x")
if cmd == nil {
t.Error("archiving a resolved incident should act")
}
if m.statusMsg != "" {
t.Errorf("expected no refusal, got %q", m.statusMsg)
}
}
func TestSnooze_PromptThenSubmit(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "z")
if m.mode != modeSnooze {
t.Fatalf("expected the snooze prompt, got mode %v", m.mode)
}
// Typing goes to the input, not the key handler.
for _, r := range "2h" {
m, _ = press(t, m, string(r))
}
if m.snoozeInput.Value() != "2h" {
t.Fatalf("expected the typed duration, got %q", m.snoozeInput.Value())
}
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Error("expected the snooze to be sent")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to return to the incident, got mode %v", m.mode)
}
}
func TestSnooze_EmptyInputDoesNothing(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "z")
m, cmd := press(t, m, "enter")
if cmd != nil {
t.Error("an empty duration should not be sent")
}
if m.mode != modeSnooze {
t.Errorf("expected to stay on the prompt, got mode %v", m.mode)
}
}
func TestNote_EscapeAbandonsWithoutPosting(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m, _ = press(t, m, "c")
if m.mode != modeNote {
t.Fatalf("expected the note prompt, got mode %v", m.mode)
}
m, cmd := press(t, m, "esc")
if cmd != nil {
t.Error("escaping must not post the note")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to return to the incident, got mode %v", m.mode)
}
}
func TestNoteCursor_WrapsOverNotesOnly(t *testing.T) {
timeline := []api.IncidentEvent{
{ID: 1, Type: api.EventTriggered},
{ID: 2, Type: api.EventNote, Detail: "first"},
{ID: 3, Type: api.EventAcknowledged},
{ID: 4, Type: api.EventNote, Detail: "second"},
}
m := onIncident(openIncidentFixture(), timeline)
m, _ = press(t, m, "]")
if m.noteCursor != 0 {
t.Fatalf("expected the first note, got %d", m.noteCursor)
}
m, _ = press(t, m, "]")
if m.noteCursor != 1 {
t.Fatalf("expected the second note, got %d", m.noteCursor)
}
m, _ = press(t, m, "]")
if m.noteCursor != 0 {
t.Errorf("expected to wrap to the first note, got %d", m.noteCursor)
}
m, _ = press(t, m, "[")
if m.noteCursor != 1 {
t.Errorf("expected to wrap backwards to the last note, got %d", m.noteCursor)
}
}
func TestDeleteNote_RequiresASelection(t *testing.T) {
m := onIncident(openIncidentFixture(), []api.IncidentEvent{{Type: api.EventTriggered}})
m, _ = press(t, m, "d")
if m.mode == modeConfirm {
t.Error("nothing is selected, so there is nothing to confirm")
}
if !containsAll(m.statusMsg, "select a note") {
t.Errorf("expected guidance, got %q", m.statusMsg)
}
}
func TestDeleteNote_ConfirmsThenActs(t *testing.T) {
timeline := []api.IncidentEvent{{ID: 9, Type: api.EventNote, Detail: "hi"}}
m := onIncident(openIncidentFixture(), timeline)
m, _ = press(t, m, "]")
m, _ = press(t, m, "d")
if m.mode != modeConfirm || m.confirmTarget != confirmDeleteNote {
t.Fatalf("expected a delete confirmation, got mode %v target %v", m.mode, m.confirmTarget)
}
if m.pendingDeleteID != 9 {
t.Errorf("expected the selected note's id, got %d", m.pendingDeleteID)
}
m, cmd := press(t, m, "y")
if cmd == nil {
t.Error("confirming should issue the delete")
}
if m.mode != modeIncidentDetail {
t.Errorf("expected to return to the incident, got mode %v", m.mode)
}
}
// ── 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, sectionStats, sectionArchived, sectionSchedule,
sectionUsers, sectionIncidents}
for i, expected := range want {
m, _ = press(t, m, "tab")
if m.activeSection != expected {
t.Fatalf("after %d tabs expected section %v, got %v", i+1, expected, m.activeSection)
}
}
}
func TestFilter_CyclesPerSection(t *testing.T) {
m := sized()
m, _ = press(t, m, "f")
if m.incidentFilter != api.StatusTriggered {
t.Errorf("expected the incident filter to advance, got %q", m.incidentFilter)
}
m.activeSection = sectionAlerts
m, _ = press(t, m, "f")
if m.alertFilter != "resolved" {
t.Errorf("expected the alert filter to advance, got %q", m.alertFilter)
}
if m.incidentFilter != api.StatusTriggered {
t.Error("the two filters are independent")
}
}
// 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
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
// through to the incident.
func TestAlertDetail_IsReadOnly(t *testing.T) {
m := sized()
m.mode = modeAlertDetail
m.selectedAlert = api.Alert{ID: 3, Name: "DiskFull", Status: "firing"}
for _, key := range []string{"a", "A", "R", "c", "x", "z"} {
next, cmd := press(t, m, key)
if cmd != nil {
t.Errorf("key %q should do nothing on an alert", key)
}
if next.mode != modeAlertDetail {
t.Errorf("key %q changed mode to %v", key, next.mode)
}
}
}
func TestAlertDetail_JumpToIncident(t *testing.T) {
m := sized()
m.mode = modeAlertDetail
t.Run("without an incident", func(t *testing.T) {
m.selectedAlert = api.Alert{ID: 3, Name: "Orphan"}
next, _ := press(t, m, "i")
if next.mode != modeAlertDetail || next.statusMsg == "" {
t.Errorf("expected a refusal, got mode %v msg %q", next.mode, next.statusMsg)
}
})
t.Run("with an incident", func(t *testing.T) {
id := int64(7)
m.selectedAlert = api.Alert{ID: 3, Name: "DiskFull", IncidentID: &id}
next, cmd := press(t, m, "i")
if next.mode != modeIncidentDetail {
t.Fatalf("expected the incident view, got mode %v", next.mode)
}
if next.selectedIncident.ID != 7 {
t.Errorf("expected incident 7, got %d", next.selectedIncident.ID)
}
if cmd == nil {
t.Error("expected the incident to be fetched")
}
})
}
// A refresh underneath a prompt would move the ground under the user.
func TestRefreshTick_SkipsModalStates(t *testing.T) {
modal := []mode{modeNote, modeSnooze, modeConfirm, modeUserPicker, modeUserCreate}
for _, md := range modal {
m := sized()
m.mode = md
if cmd := m.refreshActiveSection(); cmd != nil {
t.Errorf("mode %v should not auto-refresh", md)
}
}
m := sized()
if cmd := m.refreshActiveSection(); cmd == nil {
t.Error("the dashboard should auto-refresh")
}
}
func TestIncidentsFetched_ClearsLoading(t *testing.T) {
m := sized()
m.loading = true
next, _ := m.Update(incidentsFetchedMsg{incidents: []api.Incident{openIncidentFixture()}})
got := next.(Model)
if got.loading {
t.Error("expected loading to clear")
}
if len(got.incidents) != 1 {
t.Errorf("expected the incidents stored, got %d", len(got.incidents))
}
}
// A note deleted elsewhere must not leave the cursor pointing past the end.
func TestIncidentDetailFetched_ClampsNoteCursor(t *testing.T) {
m := onIncident(openIncidentFixture(), nil)
m.noteCursor = 3
next, _ := m.Update(incidentDetailFetchedMsg{
incident: openIncidentFixture(),
timeline: []api.IncidentEvent{{Type: api.EventTriggered}},
})
if got := next.(Model).noteCursor; got != -1 {
t.Errorf("expected the cursor reset, got %d", got)
}
}
func containsAll(s string, subs ...string) bool {
for _, sub := range subs {
if !strings.Contains(s, sub) {
return false
}
}
return true
}
+541 -235
View File
@@ -7,10 +7,11 @@ import (
"time" "time"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/api"
) )
var sectionNames = []string{"Alerts", "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 { func (m Model) View() string {
if m.width == 0 { if m.width == 0 {
@@ -27,11 +28,7 @@ func (m Model) View() string {
func (m Model) renderHeader() string { func (m Model) renderHeader() string {
title := styleHeader.Render("terdut-tui") title := styleHeader.Render("terdut-tui")
right := styleMuted.Render(m.serverURL) right := styleMuted.Render(m.serverURL)
gap := m.width - lipgloss.Width(title) - lipgloss.Width(right) return spread(title, right, m.width)
if gap < 0 {
gap = 0
}
return title + strings.Repeat(" ", gap) + right
} }
func (m Model) renderTabs() string { func (m Model) renderTabs() string {
@@ -57,25 +54,27 @@ func (m Model) renderBody() string {
} }
switch m.mode { switch m.mode {
case modeDetail: case modeIncidentDetail, modeAlertDetail:
return m.renderDetail() return m.renderDetail()
case modeComment: case modeNote:
return m.renderCommentCompose() return m.renderPrompt(styleHeader.Render("Note: ") + m.noteInput.View())
case modeConfirmDelete: case modeSnooze:
return m.renderPrompt(styleHeader.Render("Snooze for: ") + m.snoozeInput.View())
case modeConfirm:
switch m.confirmTarget { switch m.confirmTarget {
case confirmDeleteComment: case confirmDeleteNote, confirmResolveIncident:
return m.renderDetail() return m.renderDetail()
case confirmDeleteUser: case confirmDeleteUser:
return m.renderUsers() return m.renderUsers()
default: default:
return m.renderSchedule() return m.renderSchedule()
} }
case modeStats: case modeUserPicker:
return m.renderStats()
case modeScheduleUserPicker:
return m.renderUserPicker() return m.renderUserPicker()
case modeUserCreate: case modeUserCreate:
return m.renderUserCreate() return m.renderUserCreate()
case modeUserNotifyEdit:
return m.renderUserNotifyEdit()
case modeAPIKeyMenu: case modeAPIKeyMenu:
return m.renderAPIKeyMenu() return m.renderAPIKeyMenu()
case modeAPIKeyCreate: case modeAPIKeyCreate:
@@ -90,103 +89,130 @@ func (m Model) renderBody() string {
} }
func (m Model) renderFooter() string { func (m Model) renderFooter() string {
switch m.mode { withStatus := func(actions string) string {
case modeDetail: rendered := styleFooter.Render(actions)
actions := styleFooter.Render(" a·ack A·unack c·comment [/]·select d·del s·assign S·stats esc·back")
if m.statusMsg != "" { if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions return styleStatus.Render(" "+m.statusMsg) + "\n" + rendered
} }
return "\n" + actions return "\n" + rendered
}
case modeComment: switch m.mode {
case modeIncidentDetail:
if !m.selectedIncident.IsOpen() {
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 esc·back")
case modeAlertDetail:
return withStatus(" i·open incident esc·back")
case modeNote:
return "\n" + styleFooter.Render(" enter·submit esc·cancel") return "\n" + styleFooter.Render(" enter·submit esc·cancel")
case modeConfirmDelete: case modeSnooze:
var deleteDesc string return "\n" + styleFooter.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)")
switch m.confirmTarget {
case confirmDeleteComment:
if m.commentCursor >= 0 && m.commentCursor < len(m.comments) {
deleteDesc = fmt.Sprintf("comment by %s", m.comments[m.commentCursor].Username)
} else {
deleteDesc = "comment"
}
case confirmDeleteScheduleEntry:
if m.pendingDeleteEntry != nil {
deleteDesc = fmt.Sprintf("on-call for %s (%s)", m.pendingDeleteEntry.Date, m.pendingDeleteEntry.Username)
} else {
deleteDesc = "schedule entry"
}
case confirmDeleteUser:
deleteDesc = fmt.Sprintf("user %s (cascades all API keys)", m.selectedUser.Username)
}
return "\n" + styleError.Render(fmt.Sprintf(" Delete %s? [y/N]", deleteDesc))
case modeStats: case modeConfirm:
if m.statusMsg != "" { return "\n" + styleError.Render(" "+m.confirmPrompt())
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" esc·back")
}
return "\n" + styleFooter.Render(" esc·back")
case modeScheduleUserPicker: case modeUserPicker:
mode := "day" if m.pickerTarget == pickerIncidentAssignee {
return withStatus(" j/k·navigate enter·assign incident esc·cancel")
}
scope := "day"
if m.pickerAssignWeek { if m.pickerAssignWeek {
mode = "week" scope = "week"
} }
return "\n" + styleFooter.Render(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", mode)) return withStatus(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", scope))
case modeUserCreate: case modeUserCreate:
if m.statusMsg != "" { return withStatus(" tab·next field enter·create esc·cancel")
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" tab·next field enter·create esc·cancel")
} case modeUserNotifyEdit:
return "\n" + styleFooter.Render(" tab·next field enter·create esc·cancel") return withStatus(" enter·save esc·cancel (empty clears the topic)")
case modeAPIKeyMenu: case modeAPIKeyMenu:
return "\n" + styleFooter.Render(" n·new key r·revoke by ID esc·back") return withStatus(" n·new key r·revoke by ID esc·back")
case modeAPIKeyCreate: case modeAPIKeyCreate:
if m.statusMsg != "" { return withStatus(" enter·create esc·back")
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" enter·create esc·back")
}
return "\n" + styleFooter.Render(" enter·create esc·back")
case modeAPIKeyReveal: case modeAPIKeyReveal:
footer := " c·copy to clipboard esc·done" return withStatus(" c·copy to clipboard esc·done")
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(footer)
}
return "\n" + styleFooter.Render(footer)
case modeAPIKeyRevokeByID: case modeAPIKeyRevokeByID:
if m.statusMsg != "" { return withStatus(" enter·revoke esc·back")
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" enter·revoke esc·back")
}
return "\n" + styleFooter.Render(" enter·revoke esc·back")
default: default:
if m.activeSection == sectionSchedule { switch m.activeSection {
actions := styleFooter.Render(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit") case sectionIncidents:
if m.statusMsg != "" { return withStatus(" enter·detail x·archive f·filter r·refresh tab·section q·quit")
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions case sectionAlerts:
} return withStatus(" enter·detail f·filter r·refresh tab·section q·quit")
return "\n" + actions 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 t·topic d·delete k·API keys r·refresh tab·section q·quit")
} }
if m.activeSection == sectionUsers { return "\n" + styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
actions := styleFooter.Render(" n·new user d·delete k·API keys r·refresh tab·section q·quit") }
if m.statusMsg != "" { }
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
} func (m Model) confirmPrompt() string {
return "\n" + actions switch m.confirmTarget {
case confirmDeleteNote:
return "Delete this note? [y/N]"
case confirmResolveIncident:
// Manual resolution is terminal on the server, so say so before asking.
return "Resolve incident? This is final — a new occurrence opens a new incident. [y/N]"
case confirmDeleteScheduleEntry:
if m.pendingDeleteEntry != nil {
return fmt.Sprintf("Delete on-call for %s (%s)? [y/N]",
m.pendingDeleteEntry.Date, m.pendingDeleteEntry.Username)
} }
helpView := styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp())) return "Delete schedule entry? [y/N]"
if m.statusMsg != "" { case confirmDeleteUser:
status := styleStatus.Render(m.statusMsg) return fmt.Sprintf("Delete user %s (cascades all API keys)? [y/N]", m.selectedUser.Username)
gap := m.width - lipgloss.Width(status) - lipgloss.Width(helpView) case confirmReassignSchedule:
if gap < 0 { if p := m.pendingAssign; p != nil {
gap = 0 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 "\n" + status + strings.Repeat(" ", gap) + helpView
} }
return "\n" + helpView 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]
} }
} }
@@ -194,8 +220,14 @@ func (m Model) renderFooter() string {
func (m Model) renderDashboard() string { func (m Model) renderDashboard() string {
switch m.activeSection { switch m.activeSection {
case sectionIncidents:
return m.renderIncidents()
case sectionAlerts: case sectionAlerts:
return m.renderAlerts() return m.renderAlerts()
case sectionStats:
return m.renderStats()
case sectionArchived:
return m.renderArchived()
case sectionSchedule: case sectionSchedule:
return m.renderSchedule() return m.renderSchedule()
case sectionUsers: case sectionUsers:
@@ -204,6 +236,90 @@ func (m Model) renderDashboard() string {
return "" return ""
} }
func (m Model) renderIncidents() string {
bar := m.renderIncidentStatsBar()
var content string
switch {
case m.loading && len(m.incidents) == 0:
content = styleMuted.Render(" Loading incidents…")
case len(m.incidents) == 0:
content = styleMuted.Render(
fmt.Sprintf(" No %s incidents.", filterLabel(m.incidentFilter)))
default:
content = m.incidentTable.View()
}
return lipgloss.JoinVertical(lipgloss.Left, bar, content)
}
func (m Model) renderAlerts() string {
bar := m.renderAlertStatsBar()
var content string
switch {
case m.loading && len(m.alerts) == 0:
content = styleMuted.Render(" Loading alerts…")
case len(m.alerts) == 0:
content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", filterLabel(m.alertFilter)))
default:
content = m.alertTable.View()
}
return lipgloss.JoinVertical(lipgloss.Left, bar, content)
}
func (m Model) renderArchived() string {
if m.archivedLoading {
return "\n" + styleMuted.Render(" Loading archived incidents…")
}
if len(m.archivedIncidents) == 0 {
return "\n" + styleMuted.Render(" No archived incidents.")
}
return "\n" + m.archivedTable.View()
}
func (m Model) renderIncidentStatsBar() string {
var triggered, acked, resolved int
mtta, mttr := "—", "—"
if m.incidentStats != nil {
triggered = m.incidentStats.Triggered
acked = m.incidentStats.Acknowledged
resolved = m.incidentStats.Resolved
mtta = humanSeconds(m.incidentStats.MTTASeconds)
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)),
)
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.incidentFilter)))
return spread(left, right, m.width)
}
func (m Model) renderAlertStatsBar() string {
total, firing, resolved := 0, 0, 0
if m.alertStats != nil {
total = m.alertStats.Total
firing = m.alertStats.Firing
resolved = m.alertStats.Resolved
}
left := fmt.Sprintf(" Total: %d %s %s",
total,
styleFiring.Render(fmt.Sprintf("Firing: %d", firing)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
)
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.alertFilter)))
return spread(left, right, m.width)
}
// spread pushes left and right to the edges of a width.
func spread(left, right string, width int) string {
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 0 {
gap = 0
}
return left + strings.Repeat(" ", gap) + right
}
// ── Schedule ─────────────────────────────────────────────────────────────── // ── Schedule ───────────────────────────────────────────────────────────────
func (m Model) renderSchedule() string { func (m Model) renderSchedule() string {
@@ -211,7 +327,6 @@ func (m Model) renderSchedule() string {
return "\n" + styleMuted.Render(" Loading schedule…") return "\n" + styleMuted.Render(" Loading schedule…")
} }
// On-call header
var onCallLine string var onCallLine string
if m.currentOnCall != nil { if m.currentOnCall != nil {
onCallLine = fmt.Sprintf(" On-call today: %s", onCallLine = fmt.Sprintf(" On-call today: %s",
@@ -220,17 +335,12 @@ func (m Model) renderSchedule() string {
onCallLine = styleMuted.Render(" On-call today: nobody scheduled") onCallLine = styleMuted.Render(" On-call today: nobody scheduled")
} }
// Window label
from := m.scheduleWindow from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 13) to := m.scheduleWindow.AddDate(0, 0, 6)
windowLabel := styleMuted.Render(fmt.Sprintf(" %s — %s", windowLabel := styleMuted.Render(fmt.Sprintf(" %s — %s",
from.Format("Jan 02"), to.Format("Jan 02, 2006"))) from.Format("Jan 02"), to.Format("Jan 02, 2006")))
gap := m.width - lipgloss.Width(onCallLine) - lipgloss.Width(windowLabel) header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n"
if gap < 0 {
gap = 0
}
header := "\n" + onCallLine + strings.Repeat(" ", gap) + windowLabel + "\n"
return header + m.scheduleTable.View() return header + m.scheduleTable.View()
} }
@@ -239,9 +349,15 @@ func (m Model) renderUserPicker() string {
return "\n" + styleMuted.Render(" Loading users…") return "\n" + styleMuted.Render(" Loading users…")
} }
if m.pickerTarget == pickerIncidentAssignee {
header := fmt.Sprintf("\n Assign %s to:\n\n",
styleBold.Render(m.selectedIncident.Title))
return header + m.userPickerTable.View()
}
var scope string var scope string
cursor := m.scheduleTable.Cursor() cursor := m.scheduleTable.Cursor()
if cursor < len(m.scheduleDays) { if cursor >= 0 && cursor < len(m.scheduleDays) {
d := m.scheduleDays[cursor].date d := m.scheduleDays[cursor].date
if m.pickerAssignWeek { if m.pickerAssignWeek {
weekday := int(d.Weekday()) weekday := int(d.Weekday())
@@ -253,182 +369,329 @@ func (m Model) renderUserPicker() string {
_, week := d.ISOWeek() _, week := d.ISOWeek()
scope = fmt.Sprintf("week W%02d (%s–%s)", scope = fmt.Sprintf("week W%02d (%s–%s)",
week, monday.Format("Jan 02"), sunday.Format("Jan 02")) week, monday.Format("Jan 02"), sunday.Format("Jan 02"))
} else if d.Format("2006-01-02") == time.Now().UTC().Format("2006-01-02") {
scope = "Today (" + d.Format("Mon") + ")"
} else { } else {
if d.Format("2006-01-02") == time.Now().UTC().Format("2006-01-02") { scope = d.Format("Jan 02 (Mon)")
scope = "Today (" + d.Format("Mon") + ")"
} else {
scope = d.Format("Jan 02 (Mon)")
}
} }
} }
header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", styleBold.Render(scope))
styleBold.Render(scope))
return header + m.userPickerTable.View() return header + m.userPickerTable.View()
} }
func (m Model) renderAlerts() string {
statsBar := m.renderStatsBar()
var content string
if m.loading && len(m.alerts) == 0 {
content = styleMuted.Render(" Loading alerts…")
} else if len(m.alerts) == 0 {
label := m.filterStatus
if label == "" {
label = "all"
}
content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", label))
} else {
content = m.alertTable.View()
}
return lipgloss.JoinVertical(lipgloss.Left, statsBar, content)
}
func (m Model) renderStatsBar() string {
total, firing, resolved := 0, 0, 0
if m.stats != nil {
total = m.stats.Total
firing = m.stats.Firing
resolved = m.stats.Resolved
}
filterLabel := m.filterStatus
if filterLabel == "" {
filterLabel = "all"
}
left := fmt.Sprintf(" Total: %d %s %s",
total,
styleFiring.Render(fmt.Sprintf("Firing: %d", firing)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
)
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel))
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 0 {
gap = 0
}
return left + strings.Repeat(" ", gap) + right
}
// ── Detail ───────────────────────────────────────────────────────────────── // ── Detail ─────────────────────────────────────────────────────────────────
func (m Model) renderDetail() string { func (m Model) renderDetail() string {
if m.detailLoading { if m.detailLoading {
return "\n" + styleMuted.Render(" Loading alert details…") return "\n" + styleMuted.Render(" Loading…")
} }
return m.detailViewport.View() return m.detailViewport.View()
} }
func (m Model) renderCommentCompose() string { // renderPrompt puts an input line under the detail pane.
vp := m.detailViewport.View() func (m Model) renderPrompt(prompt string) string {
sep := styleMuted.Render(strings.Repeat("─", m.width)) sep := styleMuted.Render(strings.Repeat("─", m.width))
prompt := styleHeader.Render("Comment: ") + m.commentInput.View() return m.detailViewport.View() + "\n" + sep + "\n" + prompt
return vp + "\n" + sep + "\n" + prompt
} }
// ── Stats ────────────────────────────────────────────────────────────────── // ── Stats ──────────────────────────────────────────────────────────────────
func (m Model) renderStats() string { func (m Model) renderStats() string {
if m.statsLoading { // 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" + styleMuted.Render(" Loading statistics…") return "\n" + styleMuted.Render(" Loading statistics…")
} }
return m.statsViewport.View() return m.statsViewport.View()
} }
// line renders s in a style and terminates it.
//
// The newline has to stay outside Render: lipgloss pads every line of a styled
// block out to its widest line, so a trailing newline inside the block produces
// a second line made entirely of padding, and whatever is written next starts
// after that padding instead of at the left margin.
func line(style lipgloss.Style, s string) string {
return style.Render(s) + "\n"
}
// ── Content builders ─────────────────────────────────────────────────────── // ── Content builders ───────────────────────────────────────────────────────
func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width int) string { func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string {
now := time.Now() now := time.Now()
var b strings.Builder var b strings.Builder
contentW := width - 4 contentW := width - 4
// Name + status header // Title + status header
name := styleAlertName.Render(alert.Name) title := styleAlertName.Render(inc.Title)
var statusStr string status := incidentStatusStyle(inc.Status).Render(incidentStatusLabel(inc))
if alert.Status == "firing" { if inc.Severity != "" {
statusStr = styleFiring.Render("● FIRING") status += " " + severityStyle(inc.Severity).Render(strings.ToUpper(inc.Severity))
}
gap := contentW - lipgloss.Width(title) - lipgloss.Width(status)
if gap < 1 {
gap = 1
}
b.WriteString("\n " + title + strings.Repeat(" ", gap) + status + "\n\n")
// Timing and ownership
b.WriteString(fmt.Sprintf(" Triggered: %s (%s)\n",
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)))
} else { } else {
statusStr = styleResolved.Render("✓ RESOLVED") b.WriteString(line(styleMuted, " Assigned: nobody"))
}
nameGap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
if nameGap < 1 {
nameGap = 1
}
b.WriteString("\n " + name + strings.Repeat(" ", nameGap) + statusStr + "\n\n")
// Timeline
b.WriteString(fmt.Sprintf(" Started: %s (%s)\n",
alert.StartsAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, alert.StartsAt)))
if alert.EndsAt != nil {
b.WriteString(fmt.Sprintf(" Ended: %s\n", alert.EndsAt.UTC().Format("2006-01-02 15:04 UTC")))
}
if alert.GeneratorURL != "" {
url := alert.GeneratorURL
if len(url) > contentW-12 {
url = url[:contentW-15] + "…"
}
b.WriteString(fmt.Sprintf(" Source: %s\n", url))
}
b.WriteString("\n")
// Labels
if len(alert.Labels) > 0 {
b.WriteString(divider("Labels", width))
for _, k := range sortedKeys(alert.Labels) {
v := alert.Labels[k]
if len(v) > contentW-24 {
v = v[:contentW-27] + "…"
}
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, v))
}
b.WriteString("\n")
} }
// Annotations if inc.AcknowledgedByID != nil {
if len(alert.Annotations) > 0 {
b.WriteString(divider("Annotations", width))
for _, k := range sortedKeys(alert.Annotations) {
v := alert.Annotations[k]
if len(v) > contentW-24 {
v = v[:contentW-27] + "…"
}
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, v))
}
b.WriteString("\n")
}
// Acknowledgement
b.WriteString(divider("Acknowledgement", width))
if alert.AcknowledgedByID != nil {
ackAt := "" ackAt := ""
if alert.AcknowledgedAt != nil { if inc.AcknowledgedAt != nil {
ackAt = " at " + alert.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC") ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC")
} }
b.WriteString(styleResolved.Render(fmt.Sprintf(" ✓ Acknowledged by %s%s\n", alert.AcknowledgedBy, ackAt))) b.WriteString(line(styleResolved,
fmt.Sprintf(" Acked: %s%s", inc.AcknowledgedBy, ackAt)))
} else { } else {
b.WriteString(styleMuted.Render(" Not acknowledged\n")) b.WriteString(line(styleMuted, " Acked: not acknowledged"))
}
if inc.IsSnoozed() {
b.WriteString(line(styleSnoozed, fmt.Sprintf(" Snoozed: until %s (%s)",
inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil))))
}
if inc.ResolvedAt != nil {
source := ""
if inc.ResolutionSource != nil {
source = " · " + *inc.ResolutionSource
}
b.WriteString(fmt.Sprintf(" Resolved: %s (%s)%s\n",
inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source))
}
if inc.ArchivedAt != nil {
b.WriteString(line(styleMuted, " Archived: "+
inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC")))
} }
b.WriteString("\n") b.WriteString("\n")
// Comments // Group labels — the correlation Alertmanager applied.
b.WriteString(divider(fmt.Sprintf("Comments (%d)", len(comments)), width)) if len(inc.GroupLabels) > 0 {
if len(comments) == 0 { b.WriteString(divider("Grouped By", width))
b.WriteString(styleMuted.Render(" No comments yet.\n")) for _, k := range sortedKeys(inc.GroupLabels) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(inc.GroupLabels[k], contentW-24)))
}
b.WriteString("\n")
}
// Member alerts
b.WriteString(divider(fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width))
if len(inc.Alerts) == 0 {
b.WriteString(line(styleMuted, " No alerts."))
} else { } else {
for i, c := range comments { for _, a := range inc.Alerts {
prefix := " " marker := styleFiring.Render("●")
authorLine := fmt.Sprintf("%s%s • %s", prefix, styleBold.Render(c.Username), humanAgo(now, c.CreatedAt)) if a.Status != "firing" {
if i == cursor { marker = styleResolved.Render("✓")
authorLine = styleSelected.Render("> ") + styleBold.Render(c.Username) +
styleMuted.Render(fmt.Sprintf(" • %s", humanAgo(now, c.CreatedAt)))
} }
b.WriteString(authorLine + "\n") instance := a.Labels["instance"]
b.WriteString(" " + c.Content + "\n\n") if instance == "" {
instance = a.Fingerprint
}
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))))
}
}
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))
if len(timeline) == 0 {
b.WriteString(line(styleMuted, " Nothing recorded yet."))
} else {
noteIndex := 0
for _, e := range timeline {
when := styleMuted.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)
if noteIndex == cursor {
marker = styleSelected.Render("> ")
author = styleSelected.Render(e.Username)
}
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when))
b.WriteString(" " + e.Detail + "\n")
noteIndex++
} }
} }
return b.String() return b.String()
} }
func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string { // incidentStatusLabel is the headline badge for an incident.
func incidentStatusLabel(inc api.Incident) string {
switch inc.Status {
case api.StatusTriggered:
if inc.IsSnoozed() {
return "● TRIGGERED (snoozed)"
}
return "● TRIGGERED"
case api.StatusAcknowledged:
if inc.IsSnoozed() {
return "◐ ACKNOWLEDGED (snoozed)"
}
return "◐ ACKNOWLEDGED"
case api.StatusResolved:
return "✓ RESOLVED"
default:
return strings.ToUpper(inc.Status)
}
}
// eventLabel renders one timeline entry as a sentence. Unrecognised types fall
// back to their raw name rather than vanishing — the server may add more.
func eventLabel(e api.IncidentEvent) string {
who := e.Username
switch e.Type {
case api.EventTriggered:
return " Incident opened"
case api.EventAlertAdded:
if e.AlertID != nil {
return fmt.Sprintf(" Alert #%d joined", *e.AlertID)
}
return " Alert joined"
case api.EventAlertResolved:
if e.AlertID != nil {
return fmt.Sprintf(" Alert #%d resolved", *e.AlertID)
}
return " Alert resolved"
case api.EventAcknowledged:
return " Acknowledged by " + who
case api.EventUnacknowledged:
return " Acknowledgement cleared by " + who
case api.EventAssigned:
// On an assigned event the user is the assignee, not the actor.
return " Assigned to " + who
case api.EventSnoozed:
if e.Detail != "" {
return " Snoozed until " + e.Detail
}
return " Snoozed"
case api.EventUnsnoozed:
return " Snooze cleared by " + who
case api.EventResolved:
if who != "" {
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 != "" {
label += " · " + e.Detail
}
return label
}
}
// 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(alert api.Alert, width int) string {
now := time.Now()
var b strings.Builder
contentW := width - 4
name := styleAlertName.Render(alert.Name)
var statusStr string
if alert.Status == "firing" {
statusStr = styleFiring.Render("● FIRING")
} else {
label := "✓ RESOLVED"
if alert.ResolutionSource != nil {
label += " · " + *alert.ResolutionSource
}
statusStr = styleResolved.Render(label)
}
gap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
if gap < 1 {
gap = 1
}
b.WriteString("\n " + name + strings.Repeat(" ", gap) + statusStr + "\n\n")
b.WriteString(fmt.Sprintf(" Started: %s (%s)\n",
alert.StartsAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, alert.StartsAt)))
b.WriteString(fmt.Sprintf(" Last Seen: %s (%s)\n",
alert.ReceivedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, alert.ReceivedAt)))
if alert.EndsAt != nil {
b.WriteString(fmt.Sprintf(" Ended: %s\n", alert.EndsAt.UTC().Format("2006-01-02 15:04 UTC")))
}
if alert.GeneratorURL != "" {
b.WriteString(fmt.Sprintf(" Source: %s\n", truncate(alert.GeneratorURL, contentW-12)))
}
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")))
} else {
b.WriteString(line(styleMuted, " Incident: none"))
}
b.WriteString("\n")
if len(alert.Labels) > 0 {
b.WriteString(divider("Labels", width))
for _, k := range sortedKeys(alert.Labels) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Labels[k], contentW-24)))
}
b.WriteString("\n")
}
if len(alert.Annotations) > 0 {
b.WriteString(divider("Annotations", width))
for _, k := range sortedKeys(alert.Annotations) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Annotations[k], contentW-24)))
}
b.WriteString("\n")
}
// Alerts carry no workflow state: it all lives on the incident.
b.WriteString(divider("", width))
b.WriteString(line(styleMuted,
" 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 {
barWidth := width/2 - 10 barWidth := width/2 - 10
if barWidth < 8 { if barWidth < 8 {
barWidth = 8 barWidth = 8
@@ -440,10 +703,32 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da
var b strings.Builder var b strings.Builder
b.WriteString("\n") b.WriteString("\n")
// Top alerts // Response times first: they are what a rota is actually judged on.
b.WriteString(divider("Incident Response", width))
if incidents == nil {
b.WriteString(line(styleMuted, " No data."))
} else {
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total",
styleBold.Render(fmt.Sprintf("%d", incidents.Total))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Triggered",
styleTriggered.Render(fmt.Sprintf("%d", incidents.Triggered))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Acknowledged",
styleAcknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Resolved",
styleResolved.Render(fmt.Sprintf("%d", incidents.Resolved))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to acknowledge",
styleBold.Render(humanSeconds(incidents.MTTASeconds))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve",
styleBold.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("\n")
b.WriteString(divider("Top Alerts", width)) b.WriteString(divider("Top Alerts", width))
if len(top) == 0 { if len(top) == 0 {
b.WriteString(styleMuted.Render(" No data.\n")) b.WriteString(line(styleMuted, " No data."))
} else { } else {
maxCount := top[0].Count maxCount := top[0].Count
for i, a := range top { for i, a := range top {
@@ -453,7 +738,6 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da
} }
b.WriteString("\n") b.WriteString("\n")
// By hour
b.WriteString(divider("Alerts by Hour (UTC)", width)) b.WriteString(divider("Alerts by Hour (UTC)", width))
if len(byHour) > 0 { if len(byHour) > 0 {
maxCount := 0 maxCount := 0
@@ -467,11 +751,10 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da
b.WriteString(fmt.Sprintf(" %2dh %-*s %d\n", h.Hour, barWidth, bar, h.Count)) b.WriteString(fmt.Sprintf(" %2dh %-*s %d\n", h.Hour, barWidth, bar, h.Count))
} }
} else { } else {
b.WriteString(styleMuted.Render(" No data.\n")) b.WriteString(line(styleMuted, " No data."))
} }
b.WriteString("\n") b.WriteString("\n")
// By day
b.WriteString(divider("Alerts by Day", width)) b.WriteString(divider("Alerts by Day", width))
if len(byDay) > 0 { if len(byDay) > 0 {
maxCount := 0 maxCount := 0
@@ -485,7 +768,7 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da
b.WriteString(fmt.Sprintf(" %-4s %-*s %d\n", d.DayName[:3], barWidth, bar, d.Count)) b.WriteString(fmt.Sprintf(" %-4s %-*s %d\n", d.DayName[:3], barWidth, bar, d.Count))
} }
} else { } else {
b.WriteString(styleMuted.Render(" No data.\n")) b.WriteString(line(styleMuted, " No data."))
} }
return b.String() return b.String()
@@ -517,9 +800,19 @@ func (m Model) renderUserCreate() string {
emailLabel + m.userFormInputs[1].View() + "\n" emailLabel + m.userFormInputs[1].View() + "\n"
} }
func (m Model) renderUserNotifyEdit() string {
header := fmt.Sprintf("\n Push notifications for %s\n", styleBold.Render(m.selectedUser.Username))
hint := line(styleMuted,
" 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 := styleSelected.Render(" Topic: ")
return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n"
}
func (m Model) renderAPIKeyMenu() string { func (m Model) renderAPIKeyMenu() string {
header := fmt.Sprintf("\n API keys for %s\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n API keys for %s\n", styleBold.Render(m.selectedUser.Username))
warning := styleMuted.Render(" Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.\n") warning := line(styleMuted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.")
options := "\n" + options := "\n" +
styleAccent.Render(" n") + " · create a new API key\n" + styleAccent.Render(" n") + " · create a new API key\n" +
styleAccent.Render(" r") + " · revoke a key by ID\n" styleAccent.Render(" r") + " · revoke a key by ID\n"
@@ -553,7 +846,7 @@ func (m Model) renderAPIKeyReveal() string {
func (m Model) renderAPIKeyRevokeByID() string { func (m Model) renderAPIKeyRevokeByID() string {
header := fmt.Sprintf("\n Revoke API key for %s\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n Revoke API key for %s\n", styleBold.Render(m.selectedUser.Username))
hint := styleMuted.Render(" Enter the integer key ID (shown when the key was created).\n") hint := line(styleMuted, " Enter the integer key ID (shown when the key was created).")
label := styleSelected.Render(" Key ID: ") label := styleSelected.Render(" Key ID: ")
return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n" return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n"
} }
@@ -561,7 +854,10 @@ func (m Model) renderAPIKeyRevokeByID() string {
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
func divider(title string, width int) string { func divider(title string, width int) string {
prefix := "── " + title + " " prefix := "── "
if title != "" {
prefix += title + " "
}
remaining := width - len(prefix) - 2 remaining := width - len(prefix) - 2
if remaining > 0 { if remaining > 0 {
prefix += strings.Repeat("─", remaining) prefix += strings.Repeat("─", remaining)
@@ -589,9 +885,19 @@ func renderBarWidth(count, maxCount, maxWidth int) int {
return w 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 { func truncate(s string, max int) string {
if len(s) <= max { if max < 1 {
return ""
}
r := []rune(s)
if len(r) <= max {
return s return s
} }
return s[:max-1] + "…" return string(r[:max-1]) + "…"
} }
+346
View File
@@ -0,0 +1,346 @@
package tui
import (
"regexp"
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
)
// ansi matches the escape sequences lipgloss emits when it decides the output
// supports colour, so assertions can be made against the text alone.
var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func plain(s string) string { return ansi.ReplaceAllString(s, "") }
func mustContain(t *testing.T, got string, wants ...string) {
t.Helper()
got = plain(got)
for _, w := range wants {
if !strings.Contains(got, w) {
t.Errorf("expected output to contain %q\n--- got ---\n%s", w, got)
}
}
}
func TestIncidentDetail_RendersTheWholeStory(t *testing.T) {
now := time.Now()
ackID := int64(1)
alertID := int64(3)
inc := api.Incident{
ID: 1, Title: "DiskFull (namespace=prod)", Status: api.StatusAcknowledged,
Severity: "critical",
GroupLabels: map[string]string{"alertname": "DiskFull", "namespace": "prod"},
TriggeredAt: now.Add(-2 * time.Hour),
AssignedTo: "admin", AcknowledgedByID: &ackID, AcknowledgedBy: "admin",
AcknowledgedAt: &now,
Alerts: []api.Alert{
{ID: 3, Name: "DiskFull", Status: "firing",
Labels: map[string]string{"instance": "node-1"}, ReceivedAt: now},
{ID: 4, Name: "DiskFull", Status: "resolved",
Labels: map[string]string{"instance": "node-2"}, ReceivedAt: now},
},
}
timeline := []api.IncidentEvent{
{Type: api.EventTriggered, CreatedAt: now},
{Type: api.EventAssigned, Username: "admin", CreatedAt: now},
{Type: api.EventAlertAdded, AlertID: &alertID, CreatedAt: now},
{Type: api.EventAcknowledged, Username: "admin", CreatedAt: now},
{Type: api.EventNote, Username: "admin", Detail: "draining node-2", CreatedAt: now},
}
out := buildIncidentDetailContent(inc, timeline, -1, 110)
mustContain(t, out,
"DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL",
"Assigned:", "admin",
"Grouped By", "namespace", "prod",
"Alerts (2)", "node-1", "node-2",
"Timeline (5 events, 1 notes)",
"Incident opened", "Assigned to admin", "Alert #3 joined", "Acknowledged by admin",
"admin wrote", "draining node-2",
)
}
func TestIncidentDetail_ShowsSnooze(t *testing.T) {
future := time.Now().Add(2 * time.Hour)
inc := api.Incident{
Title: "Noisy", Status: api.StatusTriggered,
TriggeredAt: time.Now(), SnoozedUntil: &future,
}
// 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),
"TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h")
}
// An expired snooze is not a snooze, so it must not be reported as one.
func TestIncidentDetail_HidesExpiredSnooze(t *testing.T) {
past := time.Now().Add(-time.Hour)
inc := api.Incident{
Title: "Noisy", Status: api.StatusTriggered,
TriggeredAt: time.Now(), SnoozedUntil: &past,
}
if strings.Contains(plain(buildIncidentDetailContent(inc, nil, -1, 110)), "Snoozed:") {
t.Error("an expired snooze should not be rendered")
}
}
func TestIncidentDetail_ShowsResolutionSource(t *testing.T) {
now := time.Now()
source := "manual"
inc := api.Incident{
Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour),
ResolvedAt: &now, ResolutionSource: &source,
}
mustContain(t, buildIncidentDetailContent(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")
}
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")
}
func TestIncidentDetail_MarksSelectedNote(t *testing.T) {
now := time.Now()
timeline := []api.IncidentEvent{
{Type: api.EventNote, Username: "admin", Detail: "first", CreatedAt: now},
{Type: api.EventNote, Username: "alice", Detail: "second", CreatedAt: now},
}
inc := api.Incident{Title: "X", Status: api.StatusTriggered, TriggeredAt: now}
out := plain(buildIncidentDetailContent(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)
}
if strings.Contains(line, "admin wrote") && strings.HasPrefix(line, "> ") {
t.Errorf("expected the unselected note unmarked, got %q", line)
}
}
}
func TestIncidentStatusLabel(t *testing.T) {
future := time.Now().Add(time.Hour)
tests := []struct {
name string
inc api.Incident
want string
}{
{"triggered", api.Incident{Status: api.StatusTriggered}, "● TRIGGERED"},
{"acknowledged", api.Incident{Status: api.StatusAcknowledged}, "◐ ACKNOWLEDGED"},
{"resolved", api.Incident{Status: api.StatusResolved}, "✓ RESOLVED"},
{"snoozed", api.Incident{Status: api.StatusTriggered, SnoozedUntil: &future},
"● TRIGGERED (snoozed)"},
// A status this client does not know about still has to render.
{"unknown", api.Incident{Status: "escalated"}, "ESCALATED"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := incidentStatusLabel(tt.inc); got != tt.want {
t.Errorf("expected %q, got %q", tt.want, got)
}
})
}
}
// The server may add event types after this client ships. An unknown one must
// still appear on the timeline rather than silently vanishing.
func TestEventLabel_UnknownTypeFallsBackToItsName(t *testing.T) {
got := eventLabel(api.IncidentEvent{Type: "escalated", Detail: "to sre-oncall"})
mustContain(t, got, "escalated", "to sre-oncall")
}
func TestEventLabel_KnownTypes(t *testing.T) {
alertID := int64(9)
tests := []struct {
event api.IncidentEvent
want string
}{
{api.IncidentEvent{Type: api.EventTriggered}, "Incident opened"},
{api.IncidentEvent{Type: api.EventAlertAdded, AlertID: &alertID}, "Alert #9 joined"},
{api.IncidentEvent{Type: api.EventAlertResolved, AlertID: &alertID}, "Alert #9 resolved"},
{api.IncidentEvent{Type: api.EventAcknowledged, Username: "bo"}, "Acknowledged by bo"},
{api.IncidentEvent{Type: api.EventAssigned, Username: "bo"}, "Assigned to bo"},
{api.IncidentEvent{Type: api.EventSnoozed, Detail: "2026-08-01T00:00:00Z"},
"Snoozed until 2026-08-01T00:00:00Z"},
{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) {
mustContain(t, eventLabel(tt.event), tt.want)
})
}
}
// 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(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)
alert := api.Alert{
ID: 3, Name: "DiskFull", Status: "firing", StartsAt: now.Add(-time.Hour),
ReceivedAt: now, IncidentID: &id,
Labels: map[string]string{"instance": "node-1", "severity": "critical"},
Annotations: map[string]string{"summary": "disk 90%"},
}
mustContain(t, buildAlertDetailContent(alert, 110),
"DiskFull", "FIRING", "Incident:", "#7", "press i to open it",
"instance", "node-1", "summary", "disk 90%",
"Alerts are read-only")
}
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")
}
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")
}
// 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)
mustContain(t, out, "Incident Response", "Mean time to acknowledge", "—",
"nothing has been acknowledged or resolved yet")
}
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)
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")
}
func TestView_TabsAndDashboardRender(t *testing.T) {
m := sized()
m.incidents = []api.Incident{{
ID: 1, Title: "DiskFull", Status: api.StatusTriggered, Severity: "critical",
AssignedTo: "admin", TriggeredAt: time.Now(),
}}
m.incidentStats = &api.IncidentStats{Triggered: 1}
m.rebuildIncidentTable()
mustContain(t, m.View(),
"Incidents", "Alerts", "Stats", "Archived", "Schedule", "Users",
"Triggered: 1", "filter: open",
"DiskFull", "critical", "admin",
"enter·detail")
}
func TestView_EmptyStates(t *testing.T) {
m := sized()
m.loading = false
mustContain(t, m.View(), "No open incidents.")
m.activeSection = sectionArchived
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)
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
m.err = errFixture{}
mustContain(t, m.View(), "Error:", "Press r to retry")
}
// The footer is the only place the terminal states are explained, so the
// destructive one has to be visible before it is pressed.
func TestFooter_IncidentDetailOffersResolveOnlyWhileOpen(t *testing.T) {
open := onIncident(openIncidentFixture(), nil)
mustContain(t, open.renderFooter(), "R·resolve", "z·snooze", "a·ack")
closed := onIncident(resolvedIncidentFixture(), nil)
if strings.Contains(plain(closed.renderFooter()), "R·resolve") {
t.Error("a resolved incident should not offer resolve")
}
mustContain(t, closed.renderFooter(), "x·archive", "c·note")
}
func TestView_ZeroWidthRendersNothing(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute)
if m.View() != "" {
t.Error("expected no output before the first window size message")
}
}
type errFixture struct{}
func (errFixture) Error() string { return "connection refused" }
+10 -3
View File
@@ -12,7 +12,14 @@ import (
"strings" "strings"
) )
const releaseAPI = "https://api.github.com/repos/yeniklas/terdut-tui/releases/latest" // Gitea's release payload carries the same tag_name, and its attachments the same name
// and browser_download_url, so the types below are unchanged from the GitHub original.
//
// A binary installed before the move still polls api.github.com and will never see a
// release published here. That GitHub repository is still in place, so such a build
// reports itself up to date rather than erroring -- its last GitHub release is the
// bridge, and crossing it is a one-time manual download.
const releaseAPI = "https://git.ryuvia.com/api/v1/repos/niklas/terdut-tui/releases/latest"
type release struct { type release struct {
TagName string `json:"tag_name"` TagName string `json:"tag_name"`
@@ -125,7 +132,7 @@ func fetchLatest() (*release, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
@@ -134,7 +141,7 @@ func fetchLatest() (*release, error) {
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned %s", resp.Status) return nil, fmt.Errorf("Gitea API returned %s", resp.Status)
} }
var rel release var rel release
+4 -4
View File
@@ -6,10 +6,10 @@ import (
"os" "os"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/api"
"github.com/yeniklas/terdut-tui/internal/config" "git.ryuvia.com/niklas/terdut-tui/internal/config"
"github.com/yeniklas/terdut-tui/internal/tui" "git.ryuvia.com/niklas/terdut-tui/internal/tui"
"github.com/yeniklas/terdut-tui/internal/updater" "git.ryuvia.com/niklas/terdut-tui/internal/updater"
) )
var version = "dev" var version = "dev"