28 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The same collision hit two destructive keys:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This does strand already-installed binaries, which still poll api.github.com.
The GitHub repository is left in place and untouched, so they report themselves
up to date rather than erroring; its last release is the bridge, and crossing it
is a one-time manual download.
2026-08-19 20:41:05 +02:00
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
32 changed files with 8655 additions and 861 deletions
+63
View File
@@ -0,0 +1,63 @@
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
# The Makefile is the single definition of the gate -- gofmt with both of its failure
# modes handled, go vet, and the tests -- so this is exactly what a developer and the
# release skill run. See the comments on the targets for why each is shaped as it is.
- name: Format, vet and test
run: make fmt lint test
+129
View File
@@ -0,0 +1,129 @@
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. The pattern is defined once, by
# `make dist` (and `make dist-assets`, which the release skill checks the published release
# against) -- see DIST_TARGETS in the Makefile before touching it.
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" .
# Same target CI and the release skill run; a tag that fails it publishes nothing.
- name: Format, vet and test
run: make fmt lint 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" .
# The Makefile owns the target list and the asset names -- see DIST_TARGETS there for
# why the naming pattern cannot change.
- name: Build every target
env:
REF_NAME: ${{ github.ref_name }}
run: make dist VERSION="$REF_NAME"
# 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"
# The release notes are the tag's own message, minus its subject line: the
# tag body is the changelog for this project, and without this the release
# page stays empty. Only an annotated tag has one, and only a release with
# no notes is filled, so a re-run repairs a release created empty without
# overwriting notes somebody has since edited by hand.
#
# There is no jq in this image, so the JSON string is escaped by hand:
# backslashes first (or the ones added next would double), then quotes and
# tabs, then each line end becomes a literal \n.
if [ "$(git cat-file -t "$REF_NAME")" = tag ] \
&& printf '%s' "$body" | grep -q '"body":""'; then
notes=$(git tag -l --format='%(contents)' "$REF_NAME" | sed '1,2d')
if [ -n "$notes" ]; then
notes_json=$(printf '%s\n' "$notes" \
| sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r$//' \
| awk 'BEGIN { ORS = "\\n" } { print }')
echo "setting release notes from the tag message"
curl -sf -X PATCH -H "$auth" -H 'Content-Type: application/json' \
-d "{\"body\":\"$notes_json\"}" "$API/releases/$release_id" > /dev/null
fi
fi
for f in dist/*; do
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
+1
View File
@@ -1,2 +1,3 @@
terdut-tui terdut-tui
.graymatter/ .graymatter/
dist/
+9
View File
@@ -0,0 +1,9 @@
# Read by the `release` skill (~/.claude/skills/release).
#
# terdut-tui publishes binaries to a Gitea release and nothing else: no image, no Helm
# chart, no wrapper in Ryuvia/charts. KIND=binary tells the skill to gate, tag, wait for
# release.yaml and verify the published assets, and to skip the chart steps.
KIND=binary
# English, like the rest of the terdut projects.
PROSE_LANG=en
+85 -6
View File
@@ -1,6 +1,46 @@
# 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.20.0+** (team-scoped API).
## 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`.
The server is multi-team: incidents, alerts and the schedule belong to a team, and
the caller only sees their own teams. `Model.activeTeamID` (0 = all) narrows the
incident and alert lists via `team_id`; the schedule is per team and uses
`Model.scheduleTeam()`. Users have `is_admin`, and the TUI mirrors the server's
permission rules up front (`canEditSchedule`, `canManageUser`, `isAdmin`) so a 403 is
explained before the round trip, not after. The server has no version endpoint;
an old one is recognised by `GET /api/teams` answering 404 (`errServerTooOld`).
All user actions target incidents. Two server behaviours the UI has to respect:
manual resolve is **terminal** (hence the confirmation prompt), and snooze is the
non-destructive "not now" alternative.
## Release
Say **"Release"** (or "Release X.Y.Z") and the `release` skill runs it. This repo is
`KIND=binary` in `.release.conf`: it publishes binaries to a Gitea release and has no image,
chart or wrapper-chart PR. The run is gate, commit, push, tag, wait for `release.yaml`, then
`verify-release`. Preconditions and the plan, without side effects:
```sh
~/.claude/skills/release/scripts/release-preflight # state + suggested version
~/.claude/skills/release/scripts/release-preflight vX.Y.Z # validate that release
```
- `make fmt lint test` is the gate, and it **is** what `ci.yaml` and `release.yaml` run.
- `make dist VERSION=vX.Y.Z` builds the four binaries; `make dist-assets` lists their names.
The pattern `terdut-tui-<tag>-<goos>-<goarch>` is the self-updater's contract with every
installed binary, so changing it breaks self-update.
- **The annotated tag's message becomes the release notes** (`release.yaml` copies it, minus
its subject line). Write it for a reader of the release page. Never move a published tag.
## Tech stack ## Tech stack
@@ -12,16 +52,17 @@ TUI client for [terdut-server](https://github.com/terdut-server), a Prometheus A
## Project layout ## Project layout
``` ```
main.go CLI entry point: flags, config load, health check, start TUI main.go CLI entry point: flags, config load, start TUI
internal/api/client.go REST API client — one method per endpoint internal/api/client.go REST API client — one method per endpoint
internal/config/config.go Config loader (~/.config/terdut-tui/config.yaml) internal/config/config.go Config loader (~/.config/terdut-tui/config.yaml)
internal/theme/ Colour themes: semantic tokens, built-ins, user file loader
internal/tui/ Bubbletea UI internal/tui/ Bubbletea UI
model.go Model struct, mode/section constants, Init(), tea.Cmd constructors model.go Model struct, mode/section constants, Init(), tea.Cmd constructors
update.go Update() — dispatch only, no API calls inline update.go Update() — dispatch only, no API calls inline
view.go View() — pure rendering view.go View() — pure rendering
keys.go keyMap (bubbles/key pattern) keys.go keyMap (bubbles/key pattern)
styles.go All lipgloss styles styles.go Styles struct — every lipgloss style, built from a theme
internal/updater/updater.go Self-update via GitHub Releases internal/updater/updater.go Self-update via Gitea releases
``` ```
## Architecture rules ## Architecture rules
@@ -30,6 +71,9 @@ internal/updater/updater.go Self-update via GitHub Releases
2. **`View()` is pure** — no side effects, no state mutations. 2. **`View()` is pure** — no side effects, no state mutations.
3. **All state in `Model`** — no globals. 3. **All state in `Model`** — no globals.
4. **All styles in `styles.go`** — never use lipgloss inline in `view.go`. 4. **All styles in `styles.go`** — never use lipgloss inline in `view.go`.
Styles live on `Model.styles`, built once by `newStyles(theme.Theme)`; the
handful of free functions in `view.go` take a `Styles` as their first
argument. No colour literal appears outside `internal/theme`.
## Config ## Config
@@ -37,11 +81,24 @@ Location: `~/.config/terdut-tui/config.yaml`
```yaml ```yaml
server_url: https://terdut.example.com server_url: https://terdut.example.com
api_key: <64-char hex key> username: niklas # optional, prefills the sign-in form
refresh_interval: 30 # seconds, optional, default 30 refresh_interval: 30 # seconds, optional, default 30
theme: gruvbox-dark # optional, default gruvbox-dark
team: Ops # optional, team name or id to start on, default all
``` ```
The API key is a one-time secret generated by terdut-server (`POST /api/users/{id}/api-keys`). Built-in themes are `gruvbox-dark` and `gruvbox-light`; user themes are YAML
files in `~/.config/terdut-tui/themes/`, optionally `extends:`-ing a built-in.
See the README for the token list.
There is no API key in the config. The TUI signs in as a user (`POST /api/login`, the
same session cookie as the web UI) and `internal/session` keeps the token in
`~/.config/terdut-tui/session.json`, mode 0600, keyed by server URL. The client
attaches `terdut_session` itself rather than using a cookie jar, because a jar drops the
server's Secure cookie over plain http. It must never send `Authorization` as well: the
server judges a request with that header on it alone. A 401 from anything (`msgError`
in `update.go`) returns to the sign-in form and clears `Model`. A user with no password
cannot sign in, and the server answers it like a wrong one, so the form says so.
## Running ## Running
@@ -57,6 +114,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 +129,19 @@ 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 |
| 7 | Teams: `T` switcher, per-team schedule, admin/disabled markers (server v0.20) |
| 8 | Sign in as a user instead of an API key (server v0.10+ session cookie) |
<!-- graymatter:instructions:begin — managed by `graymatter init`; edits inside this block are overwritten -->
## 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 -->
+66
View File
@@ -0,0 +1,66 @@
VERSION := $(shell git describe --tags --always --dirty)
.PHONY: build install test lint fmt dist dist-assets release-vars
build:
go build -ldflags "-X main.version=$(VERSION)" -o terdut-tui .
install:
go install -ldflags "-X main.version=$(VERSION)" .
# ci.yaml and release.yaml run `make fmt lint test`, so a green gate here and a green
# pipeline are the same code rather than two descriptions of it. It is also what the
# release skill runs before tagging.
# Covers the API client against a stub server, the sign-in flow, the Update state
# machine, and View rendering -- all pure enough to test without a terminal.
test: ## Run the test suite
go test ./...
lint: ## go vet
go vet ./...
# This exists because `go vet` does not look at import order: the move to
# git.ryuvia.com rewrote every import path without re-sorting, the new path sorts
# before github.com/..., and both repos sat unformatted through a green CI run and
# a release before anyone noticed.
#
# Both of gofmt's failure modes need handling, and they are not alike. A file that
# is merely misformatted is listed on stdout with exit 0 -- so the failure has to
# be raised by hand. A file that does not parse is the opposite: nothing on stdout
# and exit 2, which a naive `[ -n "$$unformatted" ]` reads as success. The first
# draft of this target had exactly that hole.
fmt: ## Fail on files that are not gofmt'd
@if ! unformatted=$$(gofmt -l .); then \
echo "gofmt could not parse the tree:"; gofmt -l .; exit 1; \
fi; \
if [ -n "$$unformatted" ]; then \
echo "not gofmt'd:"; echo "$$unformatted"; gofmt -d .; exit 1; \
fi
# What a release publishes. The asset names matter beyond being tidy: internal/updater
# looks for exactly terdut-tui-<tag>-<goos>-<goarch> in the latest release and reports
# every available name when it cannot find one. Renaming the pattern here breaks
# self-update for every installed binary.
DIST_TARGETS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64
dist: ## Build every release binary into dist/ (VERSION=vX.Y.Z to name them)
@set -eu; mkdir -p dist; \
for target in $(DIST_TARGETS); do \
goos="$${target%/*}"; goarch="$${target#*/}"; \
out="dist/terdut-tui-$(VERSION)-$$goos-$$goarch"; \
echo "building $$out"; \
GOOS="$$goos" GOARCH="$$goarch" go build -ldflags "-X main.version=$(VERSION)" -o "$$out" .; \
done
# The names dist produces, one per line, so the release skill can check the published
# release has every one of them rather than a count.
dist-assets: ## Print the asset names a release of VERSION carries
@for target in $(DIST_TARGETS); do \
echo "terdut-tui-$(VERSION)-$${target%/*}-$${target#*/}"; \
done
# Read by the release skill for a repo that publishes binaries and no image or chart
# (KIND=binary in .release.conf). There is nothing to say about images or charts.
release-vars: ## Print the variables the release process reads
@printf 'APP=terdut-tui\n'
+198 -12
View File
@@ -1,22 +1,82 @@
# 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
- **On-call schedule** — visual calendar of who is on duty, assign and remove entries - **Timeline** — the full history of an incident, system events, pages and notes together
- **User management** — add and remove users, manage API keys - **Alert feed** — the raw read-only alerts underneath, each linked to its incident
- **Teams** — switch between your teams, or see all of them at once
- **On-call schedule** — visual calendar of who is on duty in a team, assign and remove entries
- **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day
- **User management** — add and remove users, manage API keys, set each user's ntfy topic
> Requires terdut-server **v0.20.0 or later**. The server became team-scoped in
> v0.12 and this client follows it; earlier servers answer 404 for `/api/teams`
> and the TUI says so on start. Use terdut-tui v0.9.x with servers before v0.12.
> Escalation ladders, invites, integrations and the admin settings stay in the
> server's web UI.
## Teams
Everything the server returns is scoped to the teams your key's user belongs
to. The header shows which are on screen, and `T` steps through *all* → each of
your teams in turn. With several teams showing, incident and alert rows carry a
Team column.
The schedule is one team's rota, so the Schedule section shows the active team,
or with *all* showing the first team you own. Only a team's owners, and
administrators, can change its rota; anyone else gets the reason in the status
bar instead of a picker. The picker offers only that team's members, because the
server refuses anybody else. Stats always cover all your teams; the server
cannot narrow them to one.
Administrators are the only users who can create or delete users, or act on
someone else's password, topic or API keys. Everyone can manage their own.
## Alerts and incidents
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.
## 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
@@ -25,11 +85,69 @@ Create `~/.config/terdut-tui/config.yaml`:
```yaml ```yaml
server_url: https://terdut.example.com server_url: https://terdut.example.com
api_key: <your-api-key> username: niklas # optional, prefills the sign-in form
refresh_interval: 30 # seconds, optional refresh_interval: 30 # seconds, optional
theme: gruvbox-dark # optional, this is the default
team: Ops # optional, a team name or id to start on; default is all
``` ```
The API key is generated in terdut-server. See the server documentation for how to bootstrap a user and issue an API key. ## Signing in
The TUI signs in the way the web UI does: with a user account's username and
password, on a form shown at start. It keeps the server's session, not the
password, in `~/.config/terdut-tui/session.json` (readable by you only), so the
next start resumes it. The server's sessions last 30 days and slide with use.
When it has ended, or the account is disabled or the session is ended from the web
UI, the TUI returns to the form and says so. `L` signs out, which also ends the
session on the server and deletes the saved one.
The account needs a password, since that is what signing in uses. A user
created only for API access has none and cannot sign in: the server answers it
exactly like a wrong password. Set one in the web UI, or have an administrator
press `p` on that user in Users. Too many failed attempts are rate limited by
the server for a few minutes.
> **Upgrading from v0.10.0 and earlier:** `api_key` in `config.yaml` is no longer
> used. Remove it and sign in. API keys still exist on the server, and `k` in
> Users still manages them, for whatever else uses them.
## Themes
Two themes ship with the client: `gruvbox-dark` (the default) and
`gruvbox-light`. Both colour foregrounds only — the terminal supplies the
background — so pick the one that matches the background you already run.
To make your own, drop a file in `~/.config/terdut-tui/themes/` and name it in
`theme:`. `extends` inherits a built-in, so a file only has to list what it
changes:
```yaml
# ~/.config/terdut-tui/themes/mine.yaml
extends: gruvbox-dark
primary: "#d3869b"
accent: "#fabd2f"
```
A file may shadow a built-in name — `themes/gruvbox-dark.yaml` is how you tweak
the default without renaming it.
Without `extends`, every token must be set. The twelve are:
| Token | Where it shows |
|---|---|
| `primary` | header, active tab, selected row, cursors |
| `on_primary` | text drawn *on* `primary` — the active tab and selected row |
| `text` | incident titles and other emphasis |
| `muted` | secondary text, dividers, footer, table headers |
| `accent` | status line, acknowledged incidents, the by-day chart |
| `firing` | firing alerts, triggered incidents, the by-hour chart |
| `resolved` | resolved alerts and incidents, the top-alerts chart |
| `error` | error banners |
| `sev_critical`, `sev_error`, `sev_warning`, `sev_info` | the `severity` label |
Values are hex (`#83a598` or `#abc`) or an ANSI palette index (`0`–`255`) if you
would rather follow your terminal's own colours. Colours are downsampled
automatically on 256- and 16-colour terminals, and `NO_COLOR` is honoured.
## Usage ## Usage
@@ -41,13 +159,81 @@ 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 |
| `L` | Sign out |
| `T` | Switch team: all → each of your teams (when you have more than one) |
| `q` | Quit | | `q` | Quit |
The sections, in `tab` order: Incidents · Alerts · Stats · Archived · Schedule · Users.
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. The header line names the team whose rota this is,
and "On-call today" lists everyone on call across your teams.
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 |
| `p` | Set the selected user's web UI password — asks for the current one when it is your own |
In Users, `k` and `d` act on the selected row, so move with `↑`/`↓` there rather
than `k`. The Flags column marks administrators and disabled accounts. `n` and `d`
are for administrators; `t`, `k` and `p` work on your own row, or on anyone's if you
are one.
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/yeniklas/terdut-tui module git.ryuvia.com/niklas/terdut-tui
go 1.25.9 go 1.25.9
+464 -66
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@@ -11,32 +12,222 @@ import (
"time" "time"
) )
// SessionCookie is the cookie terdut-server's web UI signs in with.
const SessionCookie = "terdut_session"
// Client talks to terdut-server as the user who signed in. Login trades a
// username and password for a session, the same one the web UI holds, and every
// request after it carries that session's cookie.
//
// The cookie is attached by hand rather than through a cookie jar: the server
// marks it Secure behind https, and a jar drops a Secure cookie it is handed
// over plain http, which would make a local server unusable for no reason. There
// is nothing else a jar would do here — the token is opaque and does not change
// while the session lives.
type Client struct { type Client struct {
baseURL string baseURL string
httpClient *http.Client httpClient *http.Client
apiKey string session string
} }
func NewClient(baseURL, apiKey string) *Client { func NewClient(baseURL string) *Client {
return &Client{ return &Client{
baseURL: strings.TrimRight(baseURL, "/"), baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
}, },
} }
} }
// SetSession resumes a session from a token saved earlier.
func (c *Client) SetSession(token string) { c.session = token }
// HasSession reports whether there is a session to try. It says nothing about
// whether the server still honours it.
func (c *Client) HasSession() bool { return c.session != "" }
// Login signs in and returns the session token, which the client also keeps and
// sends from then on. The server answers a wrong password, an unknown user and
// an account with no password all with the same 401, so the caller cannot tell
// them apart. Too many failures come back as 429.
func (c *Client) Login(username, password string) (string, error) {
body := struct {
Username string `json:"username"`
Password string `json:"password"`
}{Username: username, Password: password}
req, err := c.newRequestWithBody(http.MethodPost, "/api/login", body)
if err != nil {
return "", err
}
// A stale session must not ride along on the request that replaces it.
req.Header.Del("Cookie")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", statusError(resp)
}
for _, ck := range resp.Cookies() {
if ck.Name == SessionCookie && ck.Value != "" {
c.session = ck.Value
return ck.Value, nil
}
}
return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie)
}
// AuthConfig asks how the server can be signed in to. It is unauthenticated, so
// it works before anybody has signed in.
func (c *Client) AuthConfig() (AuthConfig, error) {
var cfg AuthConfig
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/api/auth/config", nil)
if err != nil {
return cfg, err
}
req.Header.Set("Accept", "application/json")
err = c.do(req, &cfg)
return cfg, err
}
// The ways a device login poll can end other than with a session.
var (
// ErrDevicePending means nobody has approved yet: poll again after the
// interval.
ErrDevicePending = errors.New("waiting for approval")
// ErrDeviceSlowDown means the server was polled faster than it asked. It is
// not a failure; poll again, a little slower.
ErrDeviceSlowDown = errors.New("polling too fast")
// ErrDeviceExpired means the person took too long, or the server forgot the
// login. ErrDeviceDenied means they refused it.
ErrDeviceExpired = errors.New("the sign-in expired")
ErrDeviceDenied = errors.New("the sign-in was refused")
)
// StartDeviceLogin asks the server to begin a device login.
func (c *Client) StartDeviceLogin() (*DeviceLogin, error) {
req, err := c.newRequestWithBody(http.MethodPost, "/api/oidc/device", struct{}{})
if err != nil {
return nil, err
}
req.Header.Del("Cookie")
var d DeviceLogin
if err := c.do(req, &d); err != nil {
return nil, err
}
if d.DeviceCode == "" || d.UserCode == "" || d.VerificationURL == "" {
return nil, errors.New("server started a sign-in but sent no code")
}
return &d, nil
}
// PollDeviceLogin asks whether the person has approved. On approval it returns
// the session token, which the client also keeps; until then it returns one of
// the ErrDevice* errors.
func (c *Client) PollDeviceLogin(deviceCode string) (string, error) {
req, err := c.newRequestWithBody(http.MethodPost, "/api/oidc/device/token",
struct {
DeviceCode string `json:"device_code"`
}{deviceCode})
if err != nil {
return "", err
}
req.Header.Del("Cookie")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusAccepted:
return "", ErrDevicePending
case http.StatusTooManyRequests:
return "", ErrDeviceSlowDown
case http.StatusGone:
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error == "denied" {
return "", ErrDeviceDenied
}
return "", ErrDeviceExpired
}
if resp.StatusCode >= 400 {
return "", statusError(resp)
}
for _, ck := range resp.Cookies() {
if ck.Name == SessionCookie && ck.Value != "" {
c.session = ck.Value
return ck.Value, nil
}
}
return "", fmt.Errorf("server signed us in but sent no %s cookie", SessionCookie)
}
// Logout ends the session on the server and forgets it here.
func (c *Client) Logout() error {
req, err := c.newRequest(http.MethodPost, "/api/logout")
if err != nil {
return err
}
err = c.do(req, nil)
c.session = ""
return err
}
// authorize puts the session on a request.
func (c *Client) authorize(req *http.Request) {
req.Header.Set("Accept", "application/json")
if c.session != "" {
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: c.session})
}
}
func (c *Client) newRequest(method, path string) (*http.Request, error) { func (c *Client) newRequest(method, path string) (*http.Request, error) {
req, err := http.NewRequest(method, c.baseURL+path, nil) req, err := http.NewRequest(method, c.baseURL+path, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Authorization", "Bearer "+c.apiKey) c.authorize(req)
req.Header.Set("Accept", "application/json")
return req, nil return req, nil
} }
// StatusError is a response the server answered with a 4xx or 5xx. Message is
// the server's own {"error": ...} text, empty when the body carried none.
type StatusError struct {
Code int
Message string
}
func (e *StatusError) Error() string {
if e.Message != "" {
return fmt.Sprintf("server returned %d: %s", e.Code, e.Message)
}
return fmt.Sprintf("server returned %d", e.Code)
}
// IsUnauthorized reports whether err is the server refusing the session: it
// expired, was ended elsewhere, or belongs to an account since disabled.
func IsUnauthorized(err error) bool {
var se *StatusError
return errors.As(err, &se) && se.Code == http.StatusUnauthorized
}
func statusError(resp *http.Response) error {
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
return &StatusError{Code: resp.StatusCode, Message: e.Error}
}
func (c *Client) do(req *http.Request, out any) error { func (c *Client) do(req *http.Request, out any) error {
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -45,14 +236,7 @@ func (c *Client) do(req *http.Request, out any) error {
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
var e struct { return statusError(resp)
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error != "" {
return fmt.Errorf("server returned %d: %s", resp.StatusCode, e.Error)
}
return fmt.Errorf("server returned %d", resp.StatusCode)
} }
if out != nil { if out != nil {
@@ -61,12 +245,24 @@ 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. teamID limits them to one team; 0 means every team
func (c *Client) ListAlerts(status string, limit int) ([]Alert, error) { // the caller belongs to. status may be "firing", "resolved", or "" for all.
// Set archived=true to fetch only archived alerts; false returns only non-archived.
//
// 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(teamID int64, status string, archived bool, limit int) ([]Alert, error) {
q := url.Values{} q := url.Values{}
if teamID > 0 {
q.Set("team_id", strconv.FormatInt(teamID, 10))
}
if status != "" { if status != "" {
q.Set("status", status) q.Set("status", status)
} }
if archived {
q.Set("archived", "true")
}
if limit > 0 { if limit > 0 {
q.Set("limit", strconv.Itoa(limit)) q.Set("limit", strconv.Itoa(limit))
} }
@@ -102,8 +298,7 @@ func (c *Client) newRequestWithBody(method, path string, body any) (*http.Reques
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Authorization", "Bearer "+c.apiKey) c.authorize(req)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
return req, nil return req, nil
} }
@@ -117,49 +312,189 @@ 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. teamID limits it to one team; 0 means
// every team the caller belongs to. status may be "triggered",
// "acknowledged", "resolved", or "" for the server default of open incidents
// 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(teamID int64, status string, archived, snoozed bool, limit int) ([]Incident, error) {
q := url.Values{}
if teamID > 0 {
q.Set("team_id", strconv.FormatInt(teamID, 10))
}
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)
}
// GetSimilarIncidents lists earlier resolved incidents that look like this one
// and have notes. Servers before the similar-incidents endpoint answer 404; the
// caller treats any error as "nothing to show".
func (c *Client) GetSimilarIncidents(id int64) ([]SimilarIncident, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d/similar", id))
if err != nil {
return nil, err
}
var similar []SimilarIncident
return similar, c.do(req, &similar)
}
func (c *Client) AcknowledgeIncident(id int64) (*Incident, error) {
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. pinned files it as the
// resolution note: what fixed the incident, shown on similar ones later.
func (c *Client) AddNote(incidentID int64, content string, pinned bool) (*IncidentEvent, error) {
req, err := c.newRequestWithBody(http.MethodPost,
fmt.Sprintf("/api/incidents/%d/notes", incidentID), map[string]any{"content": content, "pinned": pinned})
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 {
@@ -187,8 +522,37 @@ func (c *Client) GetStatsByDay() ([]DayStat, error) {
return result, c.do(req, &result) return result, c.do(req, &result)
} }
func (c *Client) GetSchedule(from, to string) ([]ScheduleEntry, error) { // ── Teams ──────────────────────────────────────────────────────────────────
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/schedule?from=%s&to=%s", from, to))
// ListTeams returns the teams the caller belongs to, with the caller's role in
// each. Everything else the server returns is scoped to these. A server that
// predates teams (v0.12) answers 404, which is how the TUI spots one.
func (c *Client) ListTeams() ([]Team, error) {
req, err := c.newRequest(http.MethodGet, "/api/teams")
if err != nil {
return nil, err
}
var teams []Team
return teams, c.do(req, &teams)
}
// ListTeamMembers returns who belongs to a team. A schedule can only be given to
// its own members, so this is the assignee list for one.
func (c *Client) ListTeamMembers(teamID int64) ([]TeamMember, error) {
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/teams/%d/members", teamID))
if err != nil {
return nil, err
}
var members []TeamMember
return members, c.do(req, &members)
}
// ── Schedule ───────────────────────────────────────────────────────────────
// GetSchedule returns a team's on-call entries between two YYYY-MM-DD dates.
func (c *Client) GetSchedule(teamID int64, from, to string) ([]ScheduleEntry, error) {
q := url.Values{"from": {from}, "to": {to}}
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/teams/%d/schedule?%s", teamID, q.Encode()))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -196,41 +560,30 @@ func (c *Client) GetSchedule(from, to string) ([]ScheduleEntry, error) {
return entries, c.do(req, &entries) return entries, c.do(req, &entries)
} }
// GetCurrentOnCall returns today's on-call entry, or nil if nobody is scheduled. // GetCurrentOnCall returns today's on-call entries, one per team that has
func (c *Client) GetCurrentOnCall() (*ScheduleEntry, error) { // somebody scheduled. It is empty, not an error, when nobody is.
func (c *Client) GetCurrentOnCall() ([]ScheduleEntry, error) {
req, err := c.newRequest(http.MethodGet, "/api/schedule/current") req, err := c.newRequest(http.MethodGet, "/api/schedule/current")
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err := c.httpClient.Do(req) var entries []ScheduleEntry
if err != nil { return entries, c.do(req, &entries)
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode >= 400 {
var e struct{ Error string `json:"error"` }
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error != "" {
return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, e.Error)
}
return nil, fmt.Errorf("server returned %d", resp.StatusCode)
}
var entry ScheduleEntry
if err := json.NewDecoder(resp.Body).Decode(&entry); err != nil {
return nil, err
}
return &entry, nil
} }
func (c *Client) AssignSchedule(userID int64, dates []string) ([]ScheduleEntry, error) { // AssignSchedule puts one team member on call for the given dates. Only a team
// owner or an administrator may.
//
// The server holds one person per day and refuses a date somebody already has,
// 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(teamID, userID int64, dates []string, replace bool) ([]ScheduleEntry, error) {
body := struct { body := struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Dates []string `json:"dates"` Dates []string `json:"dates"`
}{UserID: userID, Dates: dates} Replace bool `json:"replace,omitempty"`
req, err := c.newRequestWithBody(http.MethodPost, "/api/schedule", body) }{UserID: userID, Dates: dates, Replace: replace}
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/teams/%d/schedule", teamID), body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -238,14 +591,16 @@ func (c *Client) AssignSchedule(userID int64, dates []string) ([]ScheduleEntry,
return entries, c.do(req, &entries) return entries, c.do(req, &entries)
} }
func (c *Client) DeleteScheduleEntry(id int64) error { func (c *Client) DeleteScheduleEntry(teamID, id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/schedule/%d", id)) req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/teams/%d/schedule/%d", teamID, id))
if err != nil { if err != nil {
return err return err
} }
return c.do(req, nil) return c.do(req, nil)
} }
// ── Users ──────────────────────────────────────────────────────────────────
func (c *Client) ListUsers() ([]User, error) { func (c *Client) ListUsers() ([]User, error) {
req, err := c.newRequest(http.MethodGet, "/api/users") req, err := c.newRequest(http.MethodGet, "/api/users")
if err != nil { if err != nil {
@@ -268,6 +623,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 {
@@ -296,7 +668,33 @@ func (c *Client) DeleteAPIKey(userID, keyID int64) error {
return c.do(req, nil) return c.do(req, nil)
} }
// HealthCheck calls GET /healthz (unauthenticated path, no auth needed but we send it anyway). // Me returns the user the API key belongs to, and whether they have a web UI
// password.
func (c *Client) Me() (*Me, error) {
req, err := c.newRequest(http.MethodGet, "/api/me")
if err != nil {
return nil, err
}
var me Me
return &me, c.do(req, &me)
}
// SetPassword sets a user's web UI password. current is only checked by the
// server when a user changes their own existing password; pass "" otherwise.
func (c *Client) SetPassword(userID int64, password, current string) error {
body := struct {
Password string `json:"password"`
CurrentPassword string `json:"current_password,omitempty"`
}{Password: password, CurrentPassword: current}
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/password", userID), body)
if err != nil {
return err
}
return c.do(req, nil)
}
// HealthCheck calls GET /healthz, which is unauthenticated and does no database
// check, so it says the process is up, not that the API key works.
func (c *Client) HealthCheck() error { func (c *Client) HealthCheck() error {
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil) req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
if err != nil { if err != nil {
+689
View File
@@ -0,0 +1,689 @@
package api
import (
"encoding/json"
"errors"
"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
cookie string
// authz is the Authorization header, which the client no longer sends at all.
authz string
}
// stub serves one canned response and records the request that fetched it.
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.authz = string(body), r.Header.Get("Authorization")
if ck, err := r.Cookie(SessionCookie); err == nil {
got.cookie = ck.Value
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
io.WriteString(w, response)
}))
t.Cleanup(srv.Close)
c := NewClient(srv.URL)
c.SetSession("test-session")
return c, got
}
func TestClient_SendsTheSessionCookie(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListIncidents(0, "", false, false, 0); err != nil {
t.Fatalf("list: %v", err)
}
if got.cookie != "test-session" {
t.Errorf("expected the session cookie, got %q", got.cookie)
}
// The server judges a request with an Authorization header on that alone and
// never falls back to the cookie, so sending one would defeat the session.
if got.authz != "" {
t.Errorf("expected no Authorization header, got %q", got.authz)
}
}
// Login has to work over plain http, where a cookie jar would discard the
// Secure cookie a server behind https sets.
func TestLogin_KeepsTheSessionFromTheCookie(t *testing.T) {
var body string
var sentCookie bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
body = string(b)
_, err := r.Cookie(SessionCookie)
sentCookie = err == nil
http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "fresh", Path: "/", HttpOnly: true, Secure: true})
w.WriteHeader(http.StatusOK)
io.WriteString(w, `{"user":{"id":1},"has_password":true}`)
}))
t.Cleanup(srv.Close)
c := NewClient(srv.URL)
c.SetSession("stale")
token, err := c.Login("niklas", "correct horse")
if err != nil {
t.Fatalf("login: %v", err)
}
if token != "fresh" || !c.HasSession() {
t.Errorf("expected the new token to be kept, got %q", token)
}
if body != `{"username":"niklas","password":"correct horse"}` {
t.Errorf("unexpected body %q", body)
}
if sentCookie {
t.Error("a stale session must not ride along on the login that replaces it")
}
}
func TestLogin_RefusalCarriesTheServersWords(t *testing.T) {
c, _ := stub(t, http.StatusUnauthorized, `{"error":"invalid username or password"}`)
_, err := c.Login("niklas", "wrong")
if !IsUnauthorized(err) || !strings.Contains(err.Error(), "invalid username or password") {
t.Errorf("expected the server's 401 message, got %v", err)
}
c, _ = stub(t, http.StatusTooManyRequests, `{"error":"too many attempts"}`)
if _, err := c.Login("niklas", "wrong"); err == nil || IsUnauthorized(err) {
t.Errorf("a rate limit is not an authentication failure, got %v", err)
}
}
func TestLogin_NoCookieIsAnError(t *testing.T) {
c, _ := stub(t, http.StatusOK, `{}`)
if _, err := c.Login("niklas", "pw"); err == nil {
t.Error("a 200 without a session cookie is not a sign-in")
}
}
func TestLogout_ForgetsTheSession(t *testing.T) {
c, got := stub(t, http.StatusNoContent, ``)
if err := c.Logout(); err != nil {
t.Fatalf("logout: %v", err)
}
if got.method != "POST" || got.path != "/api/logout" || got.cookie != "test-session" {
t.Errorf("unexpected request %s %s cookie=%q", got.method, got.path, got.cookie)
}
if c.HasSession() {
t.Error("the session should be gone locally")
}
}
func TestIsUnauthorized(t *testing.T) {
if !IsUnauthorized(&StatusError{Code: 401}) {
t.Error("a 401 is unauthorized")
}
if IsUnauthorized(&StatusError{Code: 403}) || IsUnauthorized(errors.New("x")) || IsUnauthorized(nil) {
t.Error("only a 401 means the session is refused; a 403 is a permission")
}
}
// 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", false); return err },
http.MethodPost, "/api/incidents/7/notes", ""},
{"similar", func(c *Client) error { _, err := c.GetSimilarIncidents(7); return err },
http.MethodGet, "/api/incidents/7/similar", `[]`},
{"delete note", func(c *Client) error { return c.DeleteNote(7, 12) },
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
teamID int64
status string
archived bool
snoozed bool
limit int
want string
}{
{"default is the open queue", 0, "", false, false, 0, ""},
{"status", 0, "triggered", false, false, 0, "status=triggered"},
{"archived", 0, "resolved", true, false, 0, "archived=true&status=resolved"},
{"snoozed", 0, "", false, true, 0, "snoozed=true"},
{"limit", 0, "", false, false, 500, "limit=500"},
{"one team", 4, "", false, false, 0, "team_id=4"},
{"no team means all of them", 0, "triggered", false, false, 0, "status=triggered"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.ListIncidents(tt.teamID, 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(9, 3, []string{"2026-07-27"}, false); err != nil {
t.Fatalf("assign: %v", err)
}
if got.method != "POST" || got.path != "/api/teams/9/schedule" {
t.Errorf("expected POST /api/teams/9/schedule, got %s %s", got.method, got.path)
}
if got.body != `{"user_id":3,"dates":["2026-07-27"]}` {
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(9, 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: the server answers with an
// empty list, one entry per team that has somebody scheduled.
func TestGetCurrentOnCall_ListsOnePerTeam(t *testing.T) {
c, got := stub(t, http.StatusOK, `[
{"id":1,"team_id":1,"team_name":"Ops","user_id":5,"username":"alice","date":"2026-09-23"},
{"id":2,"team_id":2,"team_name":"Dev","user_id":6,"username":"bob","date":"2026-09-23"}]`)
entries, err := c.GetCurrentOnCall()
if err != nil {
t.Fatalf("on call: %v", err)
}
if got.path != "/api/schedule/current" {
t.Errorf("unexpected path %q", got.path)
}
if len(entries) != 2 || entries[0].TeamName != "Ops" || entries[1].Username != "bob" {
t.Errorf("unexpected entries %+v", entries)
}
c, _ = stub(t, http.StatusOK, `[]`)
if entries, err := c.GetCurrentOnCall(); err != nil || len(entries) != 0 {
t.Errorf("expected no entries and no error, got %v, %v", entries, err)
}
}
// Schedules belong to a team, so every call for one has to say which.
func TestSchedule_IsPerTeam(t *testing.T) {
c, got := stub(t, http.StatusOK, `[]`)
if _, err := c.GetSchedule(7, "2026-09-21", "2026-09-27"); err != nil {
t.Fatalf("get schedule: %v", err)
}
if got.path != "/api/teams/7/schedule" || got.query != "from=2026-09-21&to=2026-09-27" {
t.Errorf("unexpected request %s?%s", got.path, got.query)
}
c, got = stub(t, http.StatusNoContent, ``)
if err := c.DeleteScheduleEntry(7, 12); err != nil {
t.Fatalf("delete: %v", err)
}
if got.method != "DELETE" || got.path != "/api/teams/7/schedule/12" {
t.Errorf("unexpected request %s %s", got.method, got.path)
}
}
func TestTeams(t *testing.T) {
c, got := stub(t, http.StatusOK, `[{"id":3,"name":"Ops","created_at":"2026-09-20T10:00:00Z","role":"owner"}]`)
teams, err := c.ListTeams()
if err != nil {
t.Fatalf("list teams: %v", err)
}
if got.path != "/api/teams" || len(teams) != 1 || teams[0].Role != RoleOwner || teams[0].Name != "Ops" {
t.Errorf("unexpected %s %+v", got.path, teams)
}
c, got = stub(t, http.StatusOK, `[{"team_id":3,"user_id":5,"username":"alice","role":"member"}]`)
members, err := c.ListTeamMembers(3)
if err != nil {
t.Fatalf("list members: %v", err)
}
if got.path != "/api/teams/3/members" || len(members) != 1 || members[0].UserID != 5 {
t.Errorf("unexpected %s %+v", got.path, members)
}
}
// A server that predates teams has no /api/teams, and the TUI recognises one by
// that 404, so it must come back as a StatusError carrying the code.
func TestListTeams_OldServerIs404(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `{"error":"not found"}`)
_, err := c.ListTeams()
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Errorf("expected a 404 StatusError, got %v", err)
}
}
func TestUser_DecodesAdminAndDisabled(t *testing.T) {
var u User
if err := json.Unmarshal([]byte(
`{"id":1,"username":"a","is_admin":true,"disabled_at":"2026-09-22T08:00:00Z"}`), &u); err != nil {
t.Fatalf("decode: %v", err)
}
if !u.IsAdmin || !u.IsDisabled() {
t.Errorf("expected an admin who is disabled, got %+v", u)
}
var other User
if err := json.Unmarshal([]byte(`{"id":2,"username":"b","is_admin":false}`), &other); err != nil || other.IsDisabled() {
t.Errorf("a user with no disabled_at must not be disabled")
}
}
// 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(0, "", 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)
}
}
func TestClient_Me(t *testing.T) {
c, got := stub(t, http.StatusOK, `{"user":{"id":3,"username":"erik"},"has_password":true}`)
me, err := c.Me()
if err != nil {
t.Fatalf("me: %v", err)
}
if got.method != http.MethodGet || got.path != "/api/me" {
t.Errorf("expected GET /api/me, got %s %s", got.method, got.path)
}
if me.User.ID != 3 || !me.HasPassword {
t.Errorf("unexpected decode %+v", me)
}
}
func TestClient_SetPassword(t *testing.T) {
c, got := stub(t, http.StatusNoContent, ``)
if err := c.SetPassword(2, "a brand new secret", ""); err != nil {
t.Fatalf("set password: %v", err)
}
if got.method != http.MethodPut || got.path != "/api/users/2/password" {
t.Errorf("expected PUT /api/users/2/password, got %s %s", got.method, got.path)
}
// Setting someone else's password carries no current_password at all,
// rather than an empty one.
if got.body != `{"password":"a brand new secret"}` {
t.Errorf("unexpected body %s", got.body)
}
c, got = stub(t, http.StatusNoContent, ``)
c.SetPassword(1, "a brand new secret", "the old one")
if !strings.Contains(got.body, `"current_password":"the old one"`) {
t.Errorf("current password missing from %s", got.body)
}
}
// Older servers have no /api/me; the caller tells that apart by the status
// code, so the typed error has to carry it.
func TestClient_StatusErrorKeepsCodeAndMessage(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `404 page not found`)
_, err := c.Me()
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Fatalf("expected a 404 StatusError, got %v", err)
}
if err.Error() != "server returned 404" {
t.Errorf("message changed: %q", err.Error())
}
}
func TestAuthConfig_ReadsWhatTheServerOffers(t *testing.T) {
c, got := stub(t, http.StatusOK,
`{"password_login":false,"oidc":{"enabled":true,"name":"Authentik"},"device_login":true}`)
cfg, err := c.AuthConfig()
if err != nil {
t.Fatal(err)
}
if got.method != http.MethodGet || got.path != "/api/auth/config" {
t.Errorf("wrong request: %s %s", got.method, got.path)
}
if cfg.PasswordLogin || !cfg.OIDC.Enabled || cfg.OIDC.Name != "Authentik" || !cfg.DeviceLogin {
t.Errorf("config: %+v", cfg)
}
}
func TestAuthConfig_OldServerAnswers404(t *testing.T) {
c, _ := stub(t, http.StatusNotFound, `{"error":"not found"}`)
_, err := c.AuthConfig()
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Errorf("want a 404 StatusError, got %v", err)
}
}
func TestStartDeviceLogin_SendsNoSessionAndReturnsTheCodes(t *testing.T) {
c, got := stub(t, http.StatusOK, `{"device_code":"dev","user_code":"BCDF-GHJK",
"verification_url":"https://terdut.example.com/device?code=BCDF-GHJK","interval":5,"expires_in":600}`)
d, err := c.StartDeviceLogin()
if err != nil {
t.Fatal(err)
}
if got.method != http.MethodPost || got.path != "/api/oidc/device" {
t.Errorf("wrong request: %s %s", got.method, got.path)
}
// A stale session must not ride along on a request that replaces it.
if got.cookie != "" {
t.Errorf("sent the old session %q", got.cookie)
}
if d.DeviceCode != "dev" || d.UserCode != "BCDF-GHJK" || d.Interval != 5 || d.ExpiresIn != 600 ||
d.VerificationURL != "https://terdut.example.com/device?code=BCDF-GHJK" {
t.Errorf("login: %+v", d)
}
}
func TestStartDeviceLogin_AReplyWithoutCodesIsAnError(t *testing.T) {
c, _ := stub(t, http.StatusOK, `{}`)
if _, err := c.StartDeviceLogin(); err == nil {
t.Error("an empty reply must not be taken for a started login")
}
}
func TestPollDeviceLogin_Outcomes(t *testing.T) {
for _, tc := range []struct {
name string
status int
body string
want error
}{
{"pending", http.StatusAccepted, `{"status":"pending"}`, ErrDevicePending},
{"slow down", http.StatusTooManyRequests, `{"error":"slow_down"}`, ErrDeviceSlowDown},
{"expired", http.StatusGone, `{"error":"expired"}`, ErrDeviceExpired},
{"denied", http.StatusGone, `{"error":"denied"}`, ErrDeviceDenied},
} {
t.Run(tc.name, func(t *testing.T) {
c, got := stub(t, tc.status, tc.body)
tok, err := c.PollDeviceLogin("dev")
if !errors.Is(err, tc.want) || tok != "" {
t.Errorf("got %q, %v; want %v", tok, err, tc.want)
}
if got.path != "/api/oidc/device/token" || !strings.Contains(got.body, `"device_code":"dev"`) {
t.Errorf("wrong request: %s %s", got.path, got.body)
}
if c.HasSession() && c.session == "" {
t.Error("session state corrupted")
}
})
}
}
func TestPollDeviceLogin_ApprovalKeepsTheSessionFromTheCookie(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "granted"})
io.WriteString(w, `{"user":{}}`)
}))
t.Cleanup(srv.Close)
c := NewClient(srv.URL)
tok, err := c.PollDeviceLogin("dev")
if err != nil || tok != "granted" {
t.Fatalf("got %q, %v", tok, err)
}
if !c.HasSession() {
t.Error("the client must keep the session it was given")
}
}
func TestPollDeviceLogin_ApprovalWithoutACookieIsAnError(t *testing.T) {
c, _ := stub(t, http.StatusOK, `{"user":{}}`)
c.SetSession("")
if _, err := c.PollDeviceLogin("dev"); err == nil {
t.Error("a 200 with no session cookie is not a sign-in")
}
}
+242 -20
View File
@@ -2,20 +2,168 @@ 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"` TeamID int64 `json:"team_id"`
Name string `json:"name"` TeamName string `json:"team_name,omitempty"`
Status string `json:"status"` Fingerprint string `json:"fingerprint"`
Labels map[string]string `json:"labels"` Name string `json:"name"`
Annotations map[string]string `json:"annotations"` Status string `json:"status"`
StartsAt time.Time `json:"starts_at"` Labels map[string]string `json:"labels"`
EndsAt *time.Time `json:"ends_at"` Annotations map[string]string `json:"annotations"`
GeneratorURL string `json:"generator_url"` StartsAt time.Time `json:"starts_at"`
ReceivedAt time.Time `json:"received_at"` EndsAt *time.Time `json:"ends_at"`
AcknowledgedByID *int64 `json:"acknowledged_by_id"` GeneratorURL string `json:"generator_url"`
AcknowledgedBy string `json:"acknowledged_by"` ReceivedAt time.Time `json:"received_at"`
AcknowledgedAt *time.Time `json:"acknowledged_at"` ArchivedAt *time.Time `json:"archived_at,omitempty"`
// 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, "deadman" for a
// dead man's switch that came back. Treat the value set as open.
ResolutionSource *string `json:"resolution_source,omitempty"`
}
// AuthConfig is how the server can be signed in to, from the unauthenticated
// GET /api/auth/config. A server too old to have the endpoint answers 404, which
// callers treat as "passwords only".
type AuthConfig struct {
PasswordLogin bool `json:"password_login"`
OIDC struct {
Enabled bool `json:"enabled"`
Name string `json:"name"`
} `json:"oidc"`
// DeviceLogin is whether the server can sign in a client that has no browser,
// by showing a code (see StartDeviceLogin).
DeviceLogin bool `json:"device_login"`
}
// DeviceLogin is a sign-in the server has started for this client: the person
// opens VerificationURL, checks UserCode, and approves; the client polls with
// DeviceCode until the server hands over a session.
type DeviceLogin struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_url"`
// Interval is how many seconds to wait between polls, and ExpiresIn how many
// the person has to approve.
Interval int `json:"interval"`
ExpiresIn int `json:"expires_in"`
}
// Incident statuses.
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"`
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
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, "manual"
// when a person closed it, or "recovered" when a dead man's switch came back.
// Treat the value set as open.
ResolutionSource *string `json:"resolution_source,omitempty"`
// EscalationLevel is how far up the team's escalation ladder the incident has
// climbed (0 = not escalated). EscalationDueAt is when the next step fires,
// and is nil once the ladder is exhausted or the incident is acknowledged.
EscalationLevel int `json:"escalation_level"`
EscalationDueAt *time.Time `json:"escalation_due_at,omitempty"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
// 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"
// A note marked as what fixed the incident. The server leads similar
// incidents with these.
EventResolutionNote = "resolution_note"
// Written when a team's dead man's switch stops reporting.
EventDeadmanSilent = "deadman_silent"
// Written by the server's notifier from the delivery result, not at enqueue.
// 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 +172,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 {
@@ -51,6 +202,8 @@ type DayStat struct {
type ScheduleEntry struct { type ScheduleEntry struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
TeamName string `json:"team_name,omitempty"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
Date string `json:"date"` // YYYY-MM-DD Date string `json:"date"` // YYYY-MM-DD
@@ -62,6 +215,63 @@ 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"`
// IsAdmin marks a system administrator: the only kind of user who can create
// or delete users and act on other people's passwords and keys.
IsAdmin bool `json:"is_admin"`
// DisabledAt is set when an administrator has disabled the account. A
// disabled user cannot sign in or use their keys.
DisabledAt *time.Time `json:"disabled_at,omitempty"`
// NtfyTopic is where this user's push notifications go. Nil and empty mean
// 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
}
// IsDisabled reports whether the account has been disabled.
func (u User) IsDisabled() bool { return u.DisabledAt != nil }
// Me is GET /api/me: the caller, and whether they can sign in to the web UI.
type Me struct {
User User `json:"user"`
HasPassword bool `json:"has_password"`
}
// Team roles.
const (
RoleOwner = "owner"
RoleMember = "member"
)
// Team is a group that owns integrations, incidents, a schedule and an
// escalation ladder. Role is the caller's role in it, and is only present on
// the caller's own team lists (GET /api/teams).
type Team struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
Role string `json:"role,omitempty"`
}
// TeamMember is one person's membership of a team.
type TeamMember struct {
TeamID int64 `json:"team_id"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
} }
type APIKey struct { type APIKey struct {
@@ -72,3 +282,15 @@ type APIKey struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"` LastUsedAt *time.Time `json:"last_used_at"`
} }
// SimilarIncident is an earlier, resolved incident with the same signature
// (alert name plus stable group labels) as the one being viewed. ResolutionNotes
// are its "what fixed it" notes; NoteCount counts its plain notes.
type SimilarIncident struct {
ID int64 `json:"id"`
Title string `json:"title"`
TriggeredAt time.Time `json:"triggered_at"`
ResolvedAt time.Time `json:"resolved_at"`
NoteCount int `json:"note_count"`
ResolutionNotes []IncidentEvent `json:"resolution_notes"`
}
+32 -6
View File
@@ -13,14 +13,33 @@ const defaultRefreshInterval = 30 * time.Second
type Config struct { type Config struct {
ServerURL string ServerURL string
APIKey string Username string // optional, prefills the sign-in form
RefreshInterval time.Duration RefreshInterval time.Duration
Theme string
// LegacyAPIKey is set when the file still has an `api_key`. The TUI signs in
// with a user account now and ignores it; this is only so it can say so.
LegacyAPIKey bool
// Team is the team to start on, by name or id. Empty shows every team the
// key's user belongs to.
Team string
// Auth is how to sign in when the server offers a choice: "sso" starts a
// single sign-on login straight away, "password" (or empty) shows the
// password form. The server decides what is on offer; this only picks the
// default among it.
Auth string
} }
type rawConfig struct { type rawConfig struct {
ServerURL string `yaml:"server_url"` ServerURL string `yaml:"server_url"`
APIKey string `yaml:"api_key"` Username string `yaml:"username,omitempty"`
APIKey string `yaml:"api_key,omitempty"` // no longer used; see Config.LegacyAPIKey
RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds
Theme string `yaml:"theme,omitempty"`
Team string `yaml:"team,omitempty"`
Auth string `yaml:"auth,omitempty"`
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -33,7 +52,7 @@ func Load() (*Config, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n api_key: <your-api-key>", path) return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n username: <your-username> # optional, prefills the sign-in form\n theme: gruvbox-dark # optional\n team: Ops # optional, team to start on\n auth: sso # optional, sso or password: how to sign in by default", path)
} }
return nil, fmt.Errorf("cannot read config file: %w", err) return nil, fmt.Errorf("cannot read config file: %w", err)
} }
@@ -46,8 +65,11 @@ func Load() (*Config, error) {
if raw.ServerURL == "" { if raw.ServerURL == "" {
return nil, fmt.Errorf("config: 'server_url' is required") return nil, fmt.Errorf("config: 'server_url' is required")
} }
if raw.APIKey == "" {
return nil, fmt.Errorf("config: 'api_key' is required") switch raw.Auth {
case "", "password", "sso":
default:
return nil, fmt.Errorf("config: 'auth' must be sso or password, not %q", raw.Auth)
} }
interval := defaultRefreshInterval interval := defaultRefreshInterval
@@ -57,7 +79,11 @@ func Load() (*Config, error) {
return &Config{ return &Config{
ServerURL: raw.ServerURL, ServerURL: raw.ServerURL,
APIKey: raw.APIKey, Username: raw.Username,
LegacyAPIKey: raw.APIKey != "",
RefreshInterval: interval, RefreshInterval: interval,
Theme: raw.Theme,
Team: raw.Team,
Auth: raw.Auth,
}, nil }, nil
} }
+84
View File
@@ -0,0 +1,84 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func writeConfig(t *testing.T, body string) {
t.Helper()
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
if err := os.MkdirAll(filepath.Join(dir, "terdut-tui"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "terdut-tui", "config.yaml"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
func TestLoad_TeamIsOptional(t *testing.T) {
writeConfig(t, "server_url: https://terdut.example.com\n")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Team != "" {
t.Errorf("expected no default team, got %q", cfg.Team)
}
writeConfig(t, "server_url: https://terdut.example.com\nteam: Ops\n")
cfg, err = Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Team != "Ops" {
t.Errorf("expected team Ops, got %q", cfg.Team)
}
}
// Signing in replaced the API key, so a config that has only a server URL is
// complete, and one that still carries an api_key is noted rather than refused.
func TestLoad_NoAPIKeyNeeded(t *testing.T) {
writeConfig(t, "server_url: https://terdut.example.com\nusername: niklas\n")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Username != "niklas" || cfg.LegacyAPIKey {
t.Errorf("unexpected config %+v", cfg)
}
writeConfig(t, "server_url: https://terdut.example.com\napi_key: old\n")
cfg, err = Load()
if err != nil {
t.Fatalf("a leftover api_key must not stop the TUI starting: %v", err)
}
if !cfg.LegacyAPIKey {
t.Error("expected the leftover api_key to be noted")
}
}
func TestLoad_AuthIsOptionalAndChecked(t *testing.T) {
for _, tc := range []struct {
yaml, want string
bad bool
}{
{"", "", false},
{"auth: password\n", "password", false},
{"auth: sso\n", "sso", false},
{"auth: oidc\n", "", true},
} {
writeConfig(t, "server_url: https://terdut.example.com\n"+tc.yaml)
cfg, err := Load()
switch {
case tc.bad && err == nil:
t.Errorf("%q: expected an error", tc.yaml)
case !tc.bad && err != nil:
t.Errorf("%q: %v", tc.yaml, err)
case !tc.bad && cfg.Auth != tc.want:
t.Errorf("%q: auth %q, want %q", tc.yaml, cfg.Auth, tc.want)
}
}
}
+98
View File
@@ -0,0 +1,98 @@
// Package session keeps the signed-in session between runs, so the TUI does not
// ask for a password every time it starts.
//
// What is stored is the server's session token, not the password: it is what the
// web UI keeps in a cookie, it expires on the server's schedule (30 days, sliding
// with use) and signing out or a password change ends it. It is still a
// credential, so the file is readable by its owner only.
package session
import (
"encoding/json"
"os"
"path/filepath"
"strings"
)
type file struct {
ServerURL string `json:"server_url"`
Token string `json:"token"`
}
func path() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "terdut-tui", "session.json"), nil
}
// normalise makes two spellings of one server compare equal.
func normalise(serverURL string) string {
return strings.TrimRight(serverURL, "/")
}
// Load returns the saved token for serverURL, or "" when there is none. A session
// saved for a different server is not offered to this one, and an unreadable file
// is the same as no file: the worst outcome is being asked to sign in.
func Load(serverURL string) string {
p, err := path()
if err != nil {
return ""
}
data, err := os.ReadFile(p)
if err != nil {
return ""
}
var f file
if json.Unmarshal(data, &f) != nil || normalise(f.ServerURL) != normalise(serverURL) {
return ""
}
return f.Token
}
// Save stores the token for serverURL, replacing whatever was there.
func Save(serverURL, token string) error {
p, err := path()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return err
}
data, err := json.Marshal(file{ServerURL: normalise(serverURL), Token: token})
if err != nil {
return err
}
// Written beside the target and renamed over it, so a crash cannot leave a
// half-written token, and created 0600 so it is never briefly world-readable.
tmp, err := os.CreateTemp(filepath.Dir(p), ".session-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := os.Chmod(tmp.Name(), 0o600); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), p)
}
// Clear forgets the saved session. Having none to forget is not an error.
func Clear() error {
p, err := path()
if err != nil {
return err
}
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package session
import (
"os"
"path/filepath"
"testing"
)
func isolate(t *testing.T) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
}
func TestSaveThenLoad(t *testing.T) {
isolate(t)
if got := Load("https://terdut.example.com"); got != "" {
t.Fatalf("expected no session yet, got %q", got)
}
if err := Save("https://terdut.example.com", "tok"); err != nil {
t.Fatalf("save: %v", err)
}
if got := Load("https://terdut.example.com"); got != "tok" {
t.Errorf("expected tok, got %q", got)
}
// A trailing slash is the same server.
if got := Load("https://terdut.example.com/"); got != "tok" {
t.Errorf("a trailing slash should not lose the session, got %q", got)
}
}
// A token is only ever valid for the server that issued it; offering it to a
// different one would send a credential to somebody it was never meant for.
func TestLoadIgnoresAnotherServersSession(t *testing.T) {
isolate(t)
if err := Save("https://a.example.com", "tok"); err != nil {
t.Fatal(err)
}
if got := Load("https://b.example.com"); got != "" {
t.Errorf("a session for another server must not be reused, got %q", got)
}
}
func TestSaveIsOwnerOnly(t *testing.T) {
isolate(t)
if err := Save("https://a.example.com", "tok"); err != nil {
t.Fatal(err)
}
p, _ := path()
info, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Errorf("the session file holds a credential and must be 0600, got %o", perm)
}
entries, _ := os.ReadDir(filepath.Dir(p))
for _, e := range entries {
if e.Name() != "session.json" {
t.Errorf("a temporary file was left behind: %s", e.Name())
}
}
}
func TestClear(t *testing.T) {
isolate(t)
if err := Clear(); err != nil {
t.Errorf("clearing nothing is not an error, got %v", err)
}
if err := Save("https://a.example.com", "tok"); err != nil {
t.Fatal(err)
}
if err := Clear(); err != nil {
t.Fatalf("clear: %v", err)
}
if got := Load("https://a.example.com"); got != "" {
t.Errorf("expected the session gone, got %q", got)
}
}
func TestLoadToleratesGarbage(t *testing.T) {
isolate(t)
p, _ := path()
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
if got := Load("https://a.example.com"); got != "" {
t.Errorf("an unreadable file means no session, got %q", got)
}
}
+88
View File
@@ -0,0 +1,88 @@
package theme
import "sort"
// The gruvbox palettes, in the author's original names. Dark uses the bright
// variants and light the faded ones, which is what keeps each readable against
// its own background.
const (
darkBg0 = "#282828"
darkFg1 = "#ebdbb2"
darkGray = "#928374"
darkRed = "#fb4934"
darkGrn = "#b8bb26"
darkYel = "#fabd2f"
darkBlu = "#83a598"
darkAqua = "#8ec07c"
darkOrng = "#fe8019"
lightBg0 = "#fbf1c7"
lightFg1 = "#3c3836"
lightFg4 = "#7c6f64"
lightRed = "#9d0006"
lightGrn = "#79740e"
lightYel = "#b57614"
lightBlu = "#076678"
lightAqua = "#427b58"
lightOrng = "#af3a03"
)
// GruvboxDark is the default scheme. It assumes a dark terminal background:
// themes colour foregrounds only, so the terminal supplies the canvas.
var GruvboxDark = Theme{
Name: "gruvbox-dark",
Primary: darkBlu,
OnPrimary: darkBg0,
Text: darkFg1,
Muted: darkGray,
Accent: darkOrng,
Firing: darkRed,
Resolved: darkGrn,
Error: darkRed,
SevCritical: darkRed,
SevError: darkOrng,
SevWarning: darkYel,
SevInfo: darkAqua,
}
// GruvboxLight is the same scheme against a light terminal background.
var GruvboxLight = Theme{
Name: "gruvbox-light",
Primary: lightBlu,
OnPrimary: lightBg0,
Text: lightFg1,
Muted: lightFg4,
Accent: lightOrng,
Firing: lightRed,
Resolved: lightGrn,
Error: lightRed,
SevCritical: lightRed,
SevError: lightOrng,
SevWarning: lightYel,
SevInfo: lightAqua,
}
// Default is the theme used when the config names none.
var Default = GruvboxDark
var builtins = map[string]Theme{
GruvboxDark.Name: GruvboxDark,
GruvboxLight.Name: GruvboxLight,
}
// BuiltinNames lists the compiled-in themes, sorted, for error messages and
// documentation.
func BuiltinNames() []string {
names := make([]string, 0, len(builtins))
for name := range builtins {
names = append(names, name)
}
sort.Strings(names)
return names
}
+196
View File
@@ -0,0 +1,196 @@
package theme
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/charmbracelet/lipgloss"
"gopkg.in/yaml.v3"
)
// rawTheme is the on-disk form. Every token is a pointer so an absent key is
// distinguishable from an empty one, which is what lets 'extends' overwrite
// only what the file actually mentions.
type rawTheme struct {
Extends *string `yaml:"extends"`
Primary *string `yaml:"primary"`
OnPrimary *string `yaml:"on_primary"`
Text *string `yaml:"text"`
Muted *string `yaml:"muted"`
Accent *string `yaml:"accent"`
Firing *string `yaml:"firing"`
Resolved *string `yaml:"resolved"`
Error *string `yaml:"error"`
SevCritical *string `yaml:"sev_critical"`
SevError *string `yaml:"sev_error"`
SevWarning *string `yaml:"sev_warning"`
SevInfo *string `yaml:"sev_info"`
}
// binding ties a YAML key to its raw value and the field it fills, so parsing,
// merging and the missing-token report all walk the same list.
type binding struct {
key string
src *string
dst *lipgloss.Color
}
func bindings(r *rawTheme, t *Theme) []binding {
return []binding{
{"primary", r.Primary, &t.Primary},
{"on_primary", r.OnPrimary, &t.OnPrimary},
{"text", r.Text, &t.Text},
{"muted", r.Muted, &t.Muted},
{"accent", r.Accent, &t.Accent},
{"firing", r.Firing, &t.Firing},
{"resolved", r.Resolved, &t.Resolved},
{"error", r.Error, &t.Error},
{"sev_critical", r.SevCritical, &t.SevCritical},
{"sev_error", r.SevError, &t.SevError},
{"sev_warning", r.SevWarning, &t.SevWarning},
{"sev_info", r.SevInfo, &t.SevInfo},
}
}
// tokenKeys lists the colour keys a theme file may set, in the order they are
// documented.
func tokenKeys() []string {
bs := bindings(&rawTheme{}, &Theme{})
keys := make([]string, len(bs))
for i, b := range bs {
keys[i] = b.key
}
return keys
}
func isTokenKey(k string) bool {
if k == "extends" {
return true
}
for _, want := range tokenKeys() {
if k == want {
return true
}
}
return false
}
// Load resolves a theme by name. An empty name is the default; otherwise a file
// in the user's themes directory wins over a built-in of the same name, so the
// documented way to tweak a built-in is to shadow it rather than rename it.
func Load(name string) (Theme, error) {
if name == "" {
return Default, nil
}
dir, err := os.UserConfigDir()
if err != nil {
// Built-ins do not need the disk, so a missing config directory only
// matters for user themes.
if t, ok := builtins[name]; ok {
return t, nil
}
return Theme{}, fmt.Errorf("cannot determine config directory: %w", err)
}
return loadFrom(filepath.Join(dir, "terdut-tui", "themes"), name)
}
func loadFrom(dir, name string) (Theme, error) {
if strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
return Theme{}, fmt.Errorf("invalid theme name %q: a theme is a bare name, not a path", name)
}
path := filepath.Join(dir, name+".yaml")
data, err := os.ReadFile(path)
switch {
case err == nil:
return parse(name, data)
case !os.IsNotExist(err):
return Theme{}, fmt.Errorf("cannot read theme file %s: %w", path, err)
}
if t, ok := builtins[name]; ok {
return t, nil
}
return Theme{}, fmt.Errorf("unknown theme %q\n\nBuilt-in themes: %s\nOr define your own at %s",
name, strings.Join(BuiltinNames(), ", "), path)
}
func parse(name string, data []byte) (Theme, error) {
// Check the keys before decoding, so a typo'd token reports itself by name
// alongside the ones that would have worked rather than surfacing yaml's
// message about an internal Go type.
var keys map[string]yaml.Node
if err := yaml.Unmarshal(data, &keys); err != nil {
return Theme{}, fmt.Errorf("invalid theme %q: %w", name, err)
}
for k := range keys {
if !isTokenKey(k) {
return Theme{}, fmt.Errorf("theme %q: unknown key %q\n\nValid keys: extends, %s",
name, k, strings.Join(tokenKeys(), ", "))
}
}
var raw rawTheme
if err := yaml.Unmarshal(data, &raw); err != nil {
return Theme{}, fmt.Errorf("invalid theme %q: %w", name, err)
}
t := Theme{Name: name}
if raw.Extends != nil {
base, ok := builtins[*raw.Extends]
if !ok {
return Theme{}, fmt.Errorf("theme %q: 'extends' names unknown theme %q (built-ins: %s)",
name, *raw.Extends, strings.Join(BuiltinNames(), ", "))
}
t = base
t.Name = name
}
var missing []string
for _, b := range bindings(&raw, &t) {
if b.src == nil {
if raw.Extends == nil {
missing = append(missing, b.key)
}
continue
}
c, err := parseColor(*b.src)
if err != nil {
return Theme{}, fmt.Errorf("theme %q: %s: %w", name, b.key, err)
}
*b.dst = c
}
if len(missing) > 0 {
return Theme{}, fmt.Errorf("theme %q is missing %s\n\nEither set every token or add 'extends: %s' to inherit the rest",
name, strings.Join(missing, ", "), Default.Name)
}
return t, nil
}
var hexColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// parseColor accepts what lipgloss can actually render: a hex value, or an ANSI
// palette index for people who would rather follow their terminal's colours.
func parseColor(s string) (lipgloss.Color, error) {
if hexColor.MatchString(s) {
return lipgloss.Color(s), nil
}
// strconv.Itoa round-trips to reject "+7" and "007", which lipgloss would
// pass to the terminal verbatim.
if n, err := strconv.Atoi(s); err == nil && n >= 0 && n <= 255 && strconv.Itoa(n) == s {
return lipgloss.Color(s), nil
}
return "", fmt.Errorf("invalid colour %q, want a hex value like \"#83a598\" or an ANSI index 0-255", s)
}
+180
View File
@@ -0,0 +1,180 @@
package theme
import (
"os"
"path/filepath"
"strings"
"testing"
)
// write drops a theme file into dir and returns the directory, so each test
// works against its own themes directory rather than the user's.
func write(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name+".yaml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoad_EmptyNameIsTheDefault(t *testing.T) {
got, err := Load("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Name != Default.Name {
t.Errorf("got theme %q, want %q", got.Name, Default.Name)
}
}
func TestLoadFrom_BuiltinsResolveWithoutAFile(t *testing.T) {
dir := t.TempDir()
for _, name := range BuiltinNames() {
got, err := loadFrom(dir, name)
if err != nil {
t.Fatalf("%s: unexpected error: %v", name, err)
}
if got.Name != name {
t.Errorf("got theme %q, want %q", got.Name, name)
}
if got.Primary == "" {
t.Errorf("%s: primary is unset", name)
}
}
}
func TestLoadFrom_ExtendsOverridesOnlyWhatIsNamed(t *testing.T) {
dir := t.TempDir()
write(t, dir, "mine", "extends: gruvbox-dark\nprimary: \"#d3869b\"\n")
got, err := loadFrom(dir, "mine")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Name != "mine" {
t.Errorf("got name %q, want %q", got.Name, "mine")
}
if got.Primary != "#d3869b" {
t.Errorf("got primary %q, want the override", got.Primary)
}
if got.Muted != GruvboxDark.Muted {
t.Errorf("got muted %q, want inherited %q", got.Muted, GruvboxDark.Muted)
}
if got.SevInfo != GruvboxDark.SevInfo {
t.Errorf("got sev_info %q, want inherited %q", got.SevInfo, GruvboxDark.SevInfo)
}
}
func TestLoadFrom_UserFileShadowsABuiltin(t *testing.T) {
dir := t.TempDir()
write(t, dir, "gruvbox-dark", "extends: gruvbox-dark\naccent: \"#fabd2f\"\n")
got, err := loadFrom(dir, "gruvbox-dark")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Accent != "#fabd2f" {
t.Errorf("got accent %q, want the shadowing file's value", got.Accent)
}
}
func TestLoadFrom_WithoutExtendsEveryTokenIsRequired(t *testing.T) {
dir := t.TempDir()
write(t, dir, "partial", "primary: \"#83a598\"\n")
_, err := loadFrom(dir, "partial")
if err == nil {
t.Fatal("expected an error for a theme missing tokens")
}
for _, want := range []string{"muted", "sev_info", "extends"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error should mention %q, got: %v", want, err)
}
}
}
func TestLoadFrom_CompleteThemeNeedsNoExtends(t *testing.T) {
dir := t.TempDir()
write(t, dir, "full", `primary: "#000001"
on_primary: "#000002"
text: "#000003"
muted: "#000004"
accent: "#000005"
firing: "#000006"
resolved: "#000007"
error: "#000008"
sev_critical: "#000009"
sev_error: "#00000a"
sev_warning: "#00000b"
sev_info: "#00000c"
`)
got, err := loadFrom(dir, "full")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Primary != "#000001" || got.SevInfo != "#00000c" {
t.Errorf("tokens not applied: %+v", got)
}
}
func TestLoadFrom_Errors(t *testing.T) {
tests := []struct {
name string
file string // empty means: write no file at all
body string
want string
}{
{"unknown name", "", "", "unknown theme"},
{"unknown key", "typo", "extends: gruvbox-dark\nprimry: \"#83a598\"\n", `unknown key "primry"`},
{"unknown key lists the valid ones", "typo2", "primry: \"#83a598\"\n", "Valid keys: extends, primary,"},
{"bad colour", "bad", "extends: gruvbox-dark\nprimary: notacolour\n", "invalid colour"},
{"bad colour names the token", "badkey", "extends: gruvbox-dark\nsev_warning: \"#gggggg\"\n", "sev_warning"},
{"unknown base", "orphan", "extends: solarized\nprimary: \"#83a598\"\n", "unknown theme \"solarized\""},
{"malformed yaml", "broken", "extends: [\n", "invalid theme"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
name := tt.file
if name == "" {
name = "missing"
} else {
write(t, dir, name, tt.body)
}
_, err := loadFrom(dir, name)
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), tt.want) {
t.Errorf("error should mention %q, got: %v", tt.want, err)
}
})
}
}
func TestLoadFrom_RejectsPathsAsNames(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"../secrets", "sub/theme", ".."} {
if _, err := loadFrom(dir, name); err == nil {
t.Errorf("%q: expected an error", name)
}
}
}
func TestParseColor(t *testing.T) {
ok := []string{"#83a598", "#FFF", "#abc", "0", "15", "255"}
for _, s := range ok {
if _, err := parseColor(s); err != nil {
t.Errorf("parseColor(%q) = %v, want no error", s, err)
}
}
bad := []string{"", "83a598", "#ab", "#abcd", "#gggggg", "256", "-1", "+7", "007", "red"}
for _, s := range bad {
if _, err := parseColor(s); err == nil {
t.Errorf("parseColor(%q) = nil, want an error", s)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
// Package theme resolves the named colour scheme the TUI renders with. A theme
// is a flat set of semantic tokens — roles like "muted" or "firing", never hues
// — so a new scheme is a table of colours rather than a change to the views.
package theme
import "github.com/charmbracelet/lipgloss"
// Theme is the palette the UI draws from. Every token is a foreground except
// OnPrimary, which is the text colour for the two places that invert: the
// active tab and the selected table row.
//
// Colours are truecolor hex; lipgloss downsamples them for 256- and 16-colour
// terminals and drops them entirely under NO_COLOR, so themes do not carry
// fallbacks of their own.
type Theme struct {
Name string
Primary lipgloss.Color // header, tab highlight, selection
OnPrimary lipgloss.Color // text drawn on a Primary background
Text lipgloss.Color // default emphasis foreground
Muted lipgloss.Color // secondary text, dividers, borders
Accent lipgloss.Color // status line, acknowledged, by-day chart
Firing lipgloss.Color
Resolved lipgloss.Color
Error lipgloss.Color
SevCritical lipgloss.Color
SevError lipgloss.Color
SevWarning lipgloss.Color
SevInfo lipgloss.Color
}
+255
View File
@@ -0,0 +1,255 @@
package tui
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/session"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
tea "github.com/charmbracelet/bubbletea"
)
// signedOut is a model with no session, so it starts on the sign-in form.
func signedOut(serverURL string) Model {
m := NewModel(api.NewClient(serverURL), serverURL, time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40
return m
}
func TestStartsOnTheFormWithoutASession(t *testing.T) {
m := signedOut("http://test")
if m.mode != modeLogin {
t.Fatalf("expected the sign-in form, got mode %v", m.mode)
}
// Nothing is connected without a session. The one thing started is asking
// how the server can be signed in to, and a server too old to be asked (404)
// must leave the password form as it was.
srv := httptest.NewServer(http.NotFoundHandler())
t.Cleanup(srv.Close)
cmd := signedOut(srv.URL).Init()
if cmd == nil {
t.Fatal("expected the form to ask the server how it can be signed in to")
}
msg, ok := cmd().(authConfigMsg)
if !ok {
t.Fatalf("expected authConfigMsg, got %#v", cmd())
}
if !msg.cfg.PasswordLogin || msg.cfg.DeviceLogin {
t.Errorf("an old server offers passwords only, got %+v", msg.cfg)
}
}
func TestStartsConnectedWithASavedSession(t *testing.T) {
c := api.NewClient("http://test")
c.SetSession("saved")
m := NewModel(c, "http://test", time.Minute, theme.GruvboxDark)
if m.mode == modeLogin {
t.Fatal("a saved session should be tried before asking for a password")
}
if m.Init() == nil {
t.Error("expected the saved session to be tried on start")
}
}
func TestLoginForm_ChecksBothFieldsBeforeSending(t *testing.T) {
m := signedOut("http://test")
m, cmd := press(t, m, "enter")
if cmd != nil || m.loggingIn || !strings.Contains(m.loginErr, "username") {
t.Errorf("an empty form must not be sent, got err %q", m.loginErr)
}
m = typeInto(t, m, "niklas")
m, cmd = press(t, m, "enter")
if cmd != nil || m.loggingIn || !strings.Contains(m.loginErr, "password") {
t.Errorf("a missing password must not be sent, got err %q", m.loginErr)
}
}
func TestLoginForm_QIsTypedNotQuit(t *testing.T) {
m := signedOut("http://test")
m = typeInto(t, m, "quentin")
if got := m.loginInputs[loginUsername].Value(); got != "quentin" {
t.Errorf("q is a letter in a username, got %q", got)
}
}
// The whole flow against a server: type both fields, submit, and the session is
// kept for the next run.
func TestLogin_SignsInAndSavesTheSession(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
if r.URL.Path != "/api/login" || string(b) != `{"username":"niklas","password":"secret-pass"}` {
t.Errorf("unexpected request %s %s", r.URL.Path, b)
}
http.SetCookie(w, &http.Cookie{Name: api.SessionCookie, Value: "tok-1", Path: "/"})
io.WriteString(w, `{}`)
}))
t.Cleanup(srv.Close)
m := signedOut(srv.URL)
m = typeInto(t, m, "niklas")
m, _ = press(t, m, "tab")
m = typeInto(t, m, "secret-pass")
m, cmd := press(t, m, "enter")
if !m.loggingIn || cmd == nil {
t.Fatal("expected the sign-in to be under way")
}
msg := cmd()
if _, ok := msg.(loginDoneMsg); !ok {
t.Fatalf("expected loginDoneMsg, got %#v", msg)
}
if got := session.Load(srv.URL); got != "tok-1" {
t.Errorf("expected the session saved for next time, got %q", got)
}
next, connect := m.Update(msg)
m = next.(Model)
if m.mode != modeDashboard || m.loggingIn || connect == nil {
t.Errorf("expected to move on and connect, got mode %v", m.mode)
}
if m.loginInputs[loginPassword].Value() != "" {
t.Error("the password must not be kept once it has been used")
}
}
func TestLogin_WrongPasswordStaysOnTheForm(t *testing.T) {
m := signedOut("http://test")
m.loggingIn = true
next, _ := m.Update(loginErrMsg{&api.StatusError{Code: 401, Message: "invalid username or password"}})
m = next.(Model)
if m.mode != modeLogin || m.loggingIn {
t.Fatalf("expected to be back on the form, got mode %v", m.mode)
}
// The server gives the same 401 for an account with no password, so the
// message has to say so or it reads as a typo.
if !strings.Contains(m.loginErr, "no password") {
t.Errorf("expected the no-password hint, got %q", m.loginErr)
}
if m.loginFocus != loginPassword {
t.Error("focus should return to the password to retry")
}
next, _ = m.Update(loginErrMsg{&api.StatusError{Code: 429, Message: "slow down"}})
if got := next.(Model).loginErr; !strings.Contains(got, "too many") {
t.Errorf("expected the rate limit explained, got %q", got)
}
next, _ = m.Update(loginErrMsg{errors.New("dial tcp: refused")})
if got := next.(Model).loginErr; !strings.Contains(got, "refused") {
t.Errorf("other errors should show as they are, got %q", got)
}
}
// A 401 anywhere means the session is gone. Every action would fail the same
// way, so it goes back to the form instead, without keeping the old data.
func TestUnauthorized_ReturnsToTheFormAndDropsTheData(t *testing.T) {
for name, msg := range map[string]tea.Msg{
"refresh": fetchDataErrMsg{&api.StatusError{Code: 401}},
"action": actionErrMsg{&api.StatusError{Code: 401}},
"schedule": scheduleActionErrMsg{&api.StatusError{Code: 401}},
"connect": connectErrMsg{&api.StatusError{Code: 401}},
} {
t.Run(name, func(t *testing.T) {
m := sized()
m.incidents = []api.Incident{{ID: 1, Title: "secret incident"}}
m.teams = twoTeams()
m.isAdmin = true
m.loginInputs[loginUsername].SetValue("niklas")
next, cmd := m.Update(msg)
m = next.(Model)
if m.mode != modeLogin || m.connected {
t.Fatalf("expected the sign-in form, got mode %v connected=%v", m.mode, m.connected)
}
if len(m.incidents) != 0 || len(m.teams) != 0 || m.isAdmin {
t.Error("the previous session's data must not survive into the next sign-in")
}
if cmd == nil {
t.Error("the dead session should be forgotten on disk")
}
if !strings.Contains(m.loginNote, "session") {
t.Errorf("expected the reason, got %q", m.loginNote)
}
if m.loginInputs[loginUsername].Value() != "niklas" || m.loginFocus != loginPassword {
t.Error("the username should be kept so only the password is retyped")
}
if strings.Contains(m.View(), "secret incident") {
t.Error("nothing from the old session may still be on screen")
}
})
}
}
// A 403 is a permission, not a lost session: it must not sign anybody out.
func TestForbidden_DoesNotSignOut(t *testing.T) {
m := sized()
next, _ := m.Update(actionErrMsg{&api.StatusError{Code: 403, Message: "administrator access required"}})
if got := next.(Model); got.mode == modeLogin || !got.connected {
t.Error("a 403 must leave the session alone")
}
}
func TestLogout(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
m := sized()
m.teams = twoTeams()
m.incidents = []api.Incident{{ID: 1}}
m, cmd := press(t, m, "L")
if cmd == nil {
t.Fatal("L should sign out")
}
next, _ := m.Update(logoutDoneMsg{})
m = next.(Model)
if m.mode != modeLogin || m.connected || len(m.incidents) != 0 {
t.Errorf("expected the form with nothing loaded, got mode %v", m.mode)
}
if !strings.Contains(m.loginNote, "signed out") {
t.Errorf("expected it to say so, got %q", m.loginNote)
}
}
// Signing out and in again must not leave two refresh timers running, each
// re-arming itself for ever.
func TestSigningInAgainStartsNoSecondTimer(t *testing.T) {
m := sized()
next, _ := m.Update(connectedMsg{})
m = next.(Model)
if !m.ticking {
t.Fatal("the first connect starts the refresh timer")
}
m = m.requireLogin("x")
if !m.ticking {
t.Fatal("the timer is still running while signed out")
}
// Ticks while signed out must do nothing rather than fetch.
if _, cmd := m.Update(tickMsg(time.Now())); cmd == nil {
t.Error("the timer keeps ticking")
}
if cmd := m.refreshActiveSection(); cmd != nil {
t.Error("no refresh should be attempted while signed out")
}
}
func TestLoginView_HidesThePasswordAndShowsTheNote(t *testing.T) {
m := signedOut("https://terdut.example.com").WithLogin("niklas", "api_key in config.yaml is no longer used")
if m.loginFocus != loginPassword {
t.Error("with the username known the cursor should start on the password")
}
m = typeInto(t, m, "hunter2-hunter2")
view := m.View()
for _, want := range []string{"Sign in to https://terdut.example.com", "niklas", "api_key in config.yaml is no longer used"} {
if !strings.Contains(view, want) {
t.Errorf("expected %q on the form:\n%s", want, view)
}
}
if strings.Contains(view, "hunter2") {
t.Error("the password must be masked")
}
}
+1066 -212
View File
File diff suppressed because it is too large Load Diff
+396
View File
@@ -0,0 +1,396 @@
package tui
import (
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/table"
)
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()},
}, false)
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])
}
}
// Rows only carry a team cell when the columns have a Team header for it, or
// every cell after it would sit under the wrong heading.
func TestRows_TeamCellMatchesTeamColumn(t *testing.T) {
now := time.Now()
incRows := incidentRows([]api.Incident{{Title: "DiskFull", TeamName: "Ops", Status: api.StatusTriggered, TriggeredAt: now}}, true)
if got, want := len(incRows[0]), len(incidentColumns(120, true)); got != want {
t.Errorf("incident row has %d cells for %d columns", got, want)
}
if incRows[0][2] != "Ops" {
t.Errorf("expected the team after the title, got %v", incRows[0])
}
alRows := alertRows([]api.Alert{{Name: "DiskFull", StartsAt: now, ReceivedAt: now}}, true)
if got, want := len(alRows[0]), len(alertColumns(120, true)); got != want {
t.Errorf("alert row has %d cells for %d columns", got, want)
}
if alRows[0][1] != "—" {
t.Errorf("a missing team name should show an em dash, got %v", alRows[0])
}
if got, want := len(incidentRows([]api.Incident{{}}, false)[0]), len(incidentColumns(120, false)); got != want {
t.Errorf("incident row has %d cells for %d columns without teams", got, want)
}
}
func TestAlertRows_ShowIncidentLink(t *testing.T) {
id := int64(7)
rows := alertRows([]api.Alert{
{Name: "DiskFull", Status: "firing", IncidentID: &id},
{Name: "Orphan", Status: "resolved"},
}, false)
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, theme.GruvboxDark)
m.width, m.height = 120, 40
m.users = []api.User{
{ID: 1, Username: "niklas", NtfyTopic: &topic},
{ID: 2, Username: "alex"},
// The server stores a blank topic as NULL, but a stale client or an older
// server can still hand one back; it means the same thing.
{ID: 3, Username: "sam", NtfyTopic: &empty},
}
m.rebuildUserManageTable()
rows := m.userManageTable.Rows()
if rows[0][2] != "terdut-niklas" {
t.Errorf("expected the topic in the row, got %q", rows[0][2])
}
if rows[1][2] != "—" || rows[2][2] != "—" {
t.Errorf("expected an em dash for nil and empty topics, got %q and %q",
rows[1][2], rows[2][2])
}
}
// A previous release overflowed the terminal by two columns because the padding
// budget was wrong. Columns plus bubbles' per-cell padding must land exactly on
// the window width.
func TestColumnWidthsFitTheTerminal(t *testing.T) {
for _, width := range []int{100, 110, 140, 200} {
// Five cells each, or six once a Team column is added. userManageColumns
// is five cells as well: username, email, topic, flags, created.
for name, tc := range map[string]struct {
cols []table.Column
cells int
}{
"incident": {incidentColumns(width, false), 5},
"incident with team": {incidentColumns(width, true), 6},
"alert": {alertColumns(width, false), 5},
"alert with team": {alertColumns(width, true), 6},
"user": {userManageColumns(width), 5},
} {
sum := 0
for _, w := range widths(tc.cols) {
sum += w
}
padding := 2 * tc.cells // bubbles applies Padding(0, 1) to each cell
if len(tc.cols) != tc.cells {
t.Errorf("%s has %d columns, expected %d", name, len(tc.cols), tc.cells)
}
if sum+padding != width {
t.Errorf("%s columns at width %d sum to %d+%d = %d",
name, width, sum, padding, sum+padding)
}
}
}
}
// 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, true)), widths(alertColumns(width, true))...)
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, theme.GruvboxDark)
m.width, m.height = 120, 40
m.connected = true
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleOwner}}
m.activeSection = sectionSchedule
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
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)
}
}
}
+378
View File
@@ -0,0 +1,378 @@
package tui
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/session"
tea "github.com/charmbracelet/bubbletea"
)
// fakeServer is the device-login half of terdut-server: it starts a login,
// answers polls with whatever poll says, and records what it was asked.
type fakeServer struct {
*httptest.Server
mu sync.Mutex
polls int
// poll is called for each poll and writes the response.
poll func(w http.ResponseWriter, n int)
}
func newFakeServer(t *testing.T) *fakeServer {
t.Helper()
f := &fakeServer{}
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/oidc/device":
io.WriteString(w, `{"device_code":"dev-1","user_code":"BCDF-GHJK",
"verification_url":"https://terdut.example.com/device?code=BCDF-GHJK","interval":5,"expires_in":600}`)
case "/api/oidc/device/token":
f.mu.Lock()
f.polls++
n := f.polls
f.mu.Unlock()
var body struct {
DeviceCode string `json:"device_code"`
}
json.NewDecoder(r.Body).Decode(&body)
if body.DeviceCode != "dev-1" {
t.Errorf("polled with %q", body.DeviceCode)
}
f.poll(w, n)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(f.Close)
return f
}
func pending(w http.ResponseWriter, _ int) {
w.WriteHeader(http.StatusAccepted)
io.WriteString(w, `{"status":"pending"}`)
}
// offering is a signed-out model that has been told what the server offers.
func offering(t *testing.T, url string, cfg api.AuthConfig, pref string) (Model, tea.Cmd) {
t.Helper()
m := signedOut(url).WithAuth(pref)
next, cmd := m.Update(authConfigMsg{cfg})
return next.(Model), cmd
}
func both() api.AuthConfig {
c := api.AuthConfig{PasswordLogin: true, DeviceLogin: true}
c.OIDC.Enabled, c.OIDC.Name = true, "Authentik"
return c
}
func ssoOnly() api.AuthConfig {
c := both()
c.PasswordLogin = false
return c
}
func update(t *testing.T, m Model, msg tea.Msg) (Model, tea.Cmd) {
t.Helper()
next, cmd := m.Update(msg)
return next.(Model), cmd
}
func ctrlO() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyCtrlO} }
func TestSSO_OfferedAlongsidePasswordsIsNotStartedByItself(t *testing.T) {
m, cmd := offering(t, "http://test", both(), "")
if cmd != nil || m.sso.active {
t.Fatal("with passwords on offer nothing should start until asked")
}
view := m.View()
for _, want := range []string{"Username:", "Password:", "ctrl+o to sign in with Authentik"} {
if !strings.Contains(view, want) {
t.Errorf("expected %q on the form:\n%s", want, view)
}
}
}
func TestSSO_NotOfferedShowsNoSuchHint(t *testing.T) {
m, _ := offering(t, "http://test", api.AuthConfig{PasswordLogin: true}, "")
if strings.Contains(m.View(), "ctrl+o") {
t.Error("a server with no SSO must not advertise it")
}
if m, cmd := update(t, m, ctrlO()); cmd != nil || m.sso.active {
t.Error("ctrl+o must do nothing when the server has no SSO")
}
}
func TestSSO_FullFlow(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
f := newFakeServer(t)
f.poll = func(w http.ResponseWriter, n int) {
if n < 3 {
pending(w, n)
return
}
http.SetCookie(w, &http.Cookie{Name: api.SessionCookie, Value: "tok-sso", Path: "/"})
io.WriteString(w, `{"user":{}}`)
}
m, _ := offering(t, f.URL, both(), "")
// ctrl+o asks the server for a login.
m, cmd := update(t, m, ctrlO())
if !m.sso.active || cmd == nil {
t.Fatal("ctrl+o should start a single sign-on login")
}
if !strings.Contains(m.View(), "Contacting the server") {
t.Errorf("before the server answers:\n%s", m.View())
}
started, ok := cmd().(deviceStartedMsg)
if !ok {
t.Fatalf("expected deviceStartedMsg, got %#v", cmd())
}
// The link and the code are shown, and a poll is scheduled.
m, cmd = update(t, m, started)
if cmd == nil || m.sso.interval != 5*time.Second {
t.Fatalf("expected a poll to be scheduled every 5s, got cmd %v interval %v", cmd != nil, m.sso.interval)
}
view := m.View()
for _, want := range []string{"Sign in with Authentik", "https://terdut.example.com/device?code=BCDF-GHJK",
"BCDF-GHJK", "Waiting for approval", "10 minutes", "esc·cancel"} {
if !strings.Contains(view, want) {
t.Errorf("expected %q while waiting:\n%s", want, view)
}
}
if strings.Contains(view, "Username:") {
t.Error("the password form must be out of the way while waiting")
}
// Two polls that find nothing, each scheduling the next.
for i := 1; i <= 2; i++ {
m, cmd = update(t, m, devicePollMsg{m.sso.attempt})
if cmd == nil {
t.Fatalf("poll %d: expected a request", i)
}
pend, ok := cmd().(devicePendingMsg)
if !ok {
t.Fatalf("poll %d: expected devicePendingMsg", i)
}
if m, cmd = update(t, m, pend); cmd == nil || !m.sso.active {
t.Fatalf("poll %d: expected to keep waiting", i)
}
}
// The third is approved: the session is saved and it moves on and connects.
m, cmd = update(t, m, devicePollMsg{m.sso.attempt})
done := cmd()
if _, ok := done.(loginDoneMsg); !ok {
t.Fatalf("expected loginDoneMsg, got %#v", done)
}
if got := session.Load(f.URL); got != "tok-sso" {
t.Errorf("session saved for next time: %q", got)
}
m, connect := update(t, m, done)
if m.mode != modeDashboard || m.sso.active || connect == nil {
t.Errorf("expected to move on and connect: mode %v active %v", m.mode, m.sso.active)
}
if f.polls != 3 {
t.Errorf("%d polls, want 3", f.polls)
}
}
func TestSSO_EscCancelsBeforeItQuits(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "AAAA-BBBB", VerificationURL: "u", Interval: 5, ExpiresIn: 600}})
m, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEsc})
if cmd != nil {
t.Fatal("the first esc backs out of the wait; it must not quit")
}
if m.sso.active || m.mode != modeLogin || !strings.Contains(m.View(), "Username:") {
t.Errorf("expected the password form back, active %v", m.sso.active)
}
if _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEsc}); cmd == nil {
t.Error("esc on the form quits, as it always did")
} else if _, ok := cmd().(tea.QuitMsg); !ok {
t.Errorf("expected a quit, got %#v", cmd())
}
}
// The answers of an attempt that was cancelled arrive late and must change nothing.
func TestSSO_StaleMessagesAreIgnored(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
old := m.sso.attempt
m = m.cancelSSO()
for name, msg := range map[string]tea.Msg{
"started": deviceStartedMsg{old, api.DeviceLogin{DeviceCode: "d", UserCode: "X", Interval: 5}},
"poll": devicePollMsg{old},
"pending": devicePendingMsg{attempt: old},
"failed": deviceFailedMsg{old, api.ErrDeviceExpired},
} {
next, cmd := update(t, m, msg)
if cmd != nil || next.sso.active || next.sso.login != nil || next.loginErr != "" {
t.Errorf("%s from a cancelled attempt was acted on: %+v err %q", name, next.sso, next.loginErr)
}
}
// A new attempt is not confused by the old one's messages either.
m, _ = update(t, m, ctrlO())
if m.sso.attempt == old {
t.Fatal("a new attempt must have a new number")
}
if _, cmd := update(t, m, devicePollMsg{old}); cmd != nil {
t.Error("the old attempt's poll must not run in the new one")
}
}
func TestSSO_NoPasswordsStartsByItselfAndEnterRestarts(t *testing.T) {
m, cmd := offering(t, "http://test", ssoOnly(), "")
if !m.sso.active || cmd == nil {
t.Fatal("a server with no passwords should start signing in with SSO straight away")
}
m = m.cancelSSO()
view := m.View()
if strings.Contains(view, "Username:") || !strings.Contains(view, "This server signs in with Authentik") {
t.Errorf("no password form on an SSO-only server:\n%s", view)
}
if !strings.Contains(view, "enter·sign in with Authentik") {
t.Errorf("the footer should say what enter does:\n%s", view)
}
if m, cmd = update(t, m, tea.KeyMsg{Type: tea.KeyEnter}); !m.sso.active || cmd == nil {
t.Error("enter should start it again")
}
}
func TestSSO_ConfigPrefersItWhenOffered(t *testing.T) {
if m, cmd := offering(t, "http://test", both(), "sso"); !m.sso.active || cmd == nil {
t.Error("auth: sso should start by itself when the server offers it")
}
// ...and shows the password form when it does not, rather than a dead end.
m, cmd := offering(t, "http://test", api.AuthConfig{PasswordLogin: true}, "sso")
if m.sso.active || cmd != nil || !strings.Contains(m.View(), "Username:") {
t.Error("auth: sso against a server without SSO must fall back to the form")
}
if m, cmd := offering(t, "http://test", both(), "password"); m.sso.active || cmd != nil {
t.Error("auth: password must not start SSO")
}
}
func TestSSO_ExpiredAndRefusedReturnToTheFormWithAReason(t *testing.T) {
for name, tc := range map[string]struct {
err error
want string
}{
"expired": {api.ErrDeviceExpired, "expired"},
"refused": {api.ErrDeviceDenied, "refused"},
"no sso": {&api.StatusError{Code: 404}, "does not offer"},
"limited": {&api.StatusError{Code: 429}, "too many"},
"other": {errors.New("dial tcp: refused"), "Authentik failed"},
} {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m, cmd := update(t, m, deviceFailedMsg{m.sso.attempt, tc.err})
if cmd != nil || m.sso.active || !strings.Contains(m.loginErr, tc.want) {
t.Errorf("%s: active %v err %q, want it to contain %q", name, m.sso.active, m.loginErr, tc.want)
}
if !strings.Contains(m.View(), tc.want) {
t.Errorf("%s: the reason is not shown:\n%s", name, m.View())
}
}
}
func TestSSO_SlowDownLengthensTheInterval(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "A", VerificationURL: "u", Interval: 5, ExpiresIn: 60}})
m, cmd := update(t, m, devicePendingMsg{attempt: m.sso.attempt, slower: true})
if m.sso.interval != 10*time.Second || cmd == nil {
t.Errorf("interval %v, cmd %v; want 10s and another poll", m.sso.interval, cmd != nil)
}
}
func TestSSO_ADeadConnectionEndsTheWaitButABlipDoesNot(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m, _ = update(t, m, deviceStartedMsg{m.sso.attempt, api.DeviceLogin{DeviceCode: "d", UserCode: "A", VerificationURL: "u", Interval: 5, ExpiresIn: 60}})
blip := errors.New("connection reset")
// Two failures, then a good answer: the count starts over.
for range 2 {
m, _ = update(t, m, devicePendingMsg{attempt: m.sso.attempt, err: blip})
}
m, _ = update(t, m, devicePendingMsg{attempt: m.sso.attempt})
if !m.sso.active || m.sso.failures != 0 {
t.Fatalf("a good answer should reset the failures: %+v", m.sso)
}
// Three in a row is a dead connection.
var cmd tea.Cmd
for range maxPollFailures {
m, cmd = update(t, m, devicePendingMsg{attempt: m.sso.attempt, err: blip})
}
if m.sso.active || cmd != nil || !strings.Contains(m.loginErr, "connection reset") {
t.Errorf("expected to give up with the reason: active %v err %q", m.sso.active, m.loginErr)
}
}
func TestSSO_TypingGoesNowhereWhileWaiting(t *testing.T) {
m, _ := offering(t, "http://test", both(), "")
m, _ = update(t, m, ctrlO())
m = typeInto(t, m, "hunter2")
if got := m.loginInputs[m.loginFocus].Value(); got != "" {
t.Errorf("keys typed during the wait ended up in a field: %q", got)
}
if _, cmd := update(t, m, tea.KeyMsg{Type: tea.KeyEnter}); cmd != nil {
t.Error("enter during the wait must not start anything")
}
}
// After the session ends the form comes back; it must still know what the
// server offers, and follow the config's preference without a second question.
func TestSSO_SessionEndingReturnsToSSOWhenPreferred(t *testing.T) {
m, _ := offering(t, "http://test", both(), "sso")
m = m.cancelSSO()
m.mode = modeDashboard // signed in, as loginDoneMsg leaves it
m, _ = update(t, m, connectedMsg{})
m, cmd := update(t, m, fetchDataErrMsg{&api.StatusError{Code: 401}})
if m.mode != modeLogin || m.authInfo == nil {
t.Fatalf("expected the form with what the server offers kept: mode %v info %v", m.mode, m.authInfo)
}
if !m.sso.active || cmd == nil {
t.Error("with auth: sso an ended session should go straight to SSO")
}
}
func TestSSO_SigningOutDoesNotSignStraightBackIn(t *testing.T) {
m, _ := offering(t, "http://test", both(), "sso")
m = m.cancelSSO()
m.mode = modeDashboard
m, _ = update(t, m, connectedMsg{})
m, cmd := update(t, m, logoutDoneMsg{})
if m.mode != modeLogin || m.sso.active || cmd != nil {
t.Errorf("a deliberate sign-out must wait: mode %v active %v", m.mode, m.sso.active)
}
}
// A session that ends before the server was ever asked (the TUI started on a
// saved session) still has to learn what to offer.
func TestSSO_LearnsWhatIsOfferedWhenTheSessionEndsFirst(t *testing.T) {
c := api.NewClient("http://test")
c.SetSession("saved")
m := NewModel(c, "http://test", time.Minute, signedOut("x").theme)
m.width, m.height = 120, 40
m, cmd := update(t, m, fetchDataErrMsg{&api.StatusError{Code: 401}})
if m.mode != modeLogin || m.authInfo != nil || cmd == nil {
t.Errorf("expected the form and a question to the server: mode %v info %v cmd %v", m.mode, m.authInfo, cmd != nil)
}
}
+171 -41
View File
@@ -1,45 +1,175 @@
package tui package tui
import "github.com/charmbracelet/lipgloss" import (
"strings"
var ( "git.ryuvia.com/niklas/terdut-tui/internal/api"
colorPrimary = lipgloss.Color("69") // blue "git.ryuvia.com/niklas/terdut-tui/internal/theme"
colorMuted = lipgloss.Color("240") // gray "github.com/charmbracelet/bubbles/help"
colorFiring = lipgloss.Color("196") // red "github.com/charmbracelet/bubbles/table"
colorResolved = lipgloss.Color("70") // green "github.com/charmbracelet/bubbles/textinput"
colorAccent = lipgloss.Color("214") // orange "github.com/charmbracelet/lipgloss"
styleHeader = lipgloss.NewStyle().
Bold(true).
Foreground(colorPrimary).
Padding(0, 1)
styleTabActive = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("0")).
Background(colorPrimary).
Padding(0, 2)
styleTabInactive = lipgloss.NewStyle().
Foreground(colorMuted).
Padding(0, 2)
styleFooter = lipgloss.NewStyle().
Foreground(colorMuted)
styleStatus = lipgloss.NewStyle().
Foreground(colorAccent).
Bold(true)
styleError = lipgloss.NewStyle().
Foreground(colorFiring).
Bold(true)
styleFiring = lipgloss.NewStyle().Foreground(colorFiring).Bold(true)
styleResolved = lipgloss.NewStyle().Foreground(colorResolved)
styleMuted = lipgloss.NewStyle().Foreground(colorMuted)
styleAlertName = lipgloss.NewStyle().Bold(true)
styleBold = lipgloss.NewStyle().Bold(true)
styleSelected = lipgloss.NewStyle().Foreground(colorPrimary).Bold(true)
styleAccent = lipgloss.NewStyle().Foreground(colorAccent)
) )
// Styles is every style the views draw with, built once from a theme and held
// on the Model. Nothing here reads a colour literal: the theme is the only
// place a colour is named.
type Styles struct {
Header lipgloss.Style
TabActive lipgloss.Style
TabInactive lipgloss.Style
Footer lipgloss.Style
Status lipgloss.Style
Error lipgloss.Style
Firing lipgloss.Style
Resolved lipgloss.Style
Muted lipgloss.Style
Accent lipgloss.Style
AlertName lipgloss.Style
Bold lipgloss.Style
Selected lipgloss.Style
// Incident status. Triggered is unclaimed work and reads as loudly as a
// firing alert; acknowledged means somebody has it.
Triggered lipgloss.Style
Acknowledged lipgloss.Style
Snoozed lipgloss.Style
// Severity, over the conventional Alertmanager label values.
SevCritical lipgloss.Style
SevError lipgloss.Style
SevWarning lipgloss.Style
SevInfo lipgloss.Style
theme theme.Theme
}
func newStyles(t theme.Theme) Styles {
return Styles{
Header: lipgloss.NewStyle().
Bold(true).
Foreground(t.Primary).
Padding(0, 1),
TabActive: lipgloss.NewStyle().
Bold(true).
Foreground(t.OnPrimary).
Background(t.Primary).
Padding(0, 2),
TabInactive: lipgloss.NewStyle().
Foreground(t.Muted).
Padding(0, 2),
Footer: lipgloss.NewStyle().Foreground(t.Muted),
Status: lipgloss.NewStyle().
Foreground(t.Accent).
Bold(true),
Error: lipgloss.NewStyle().
Foreground(t.Error).
Bold(true),
Firing: lipgloss.NewStyle().Foreground(t.Firing).Bold(true),
Resolved: lipgloss.NewStyle().Foreground(t.Resolved),
Muted: lipgloss.NewStyle().Foreground(t.Muted),
Accent: lipgloss.NewStyle().Foreground(t.Accent),
AlertName: lipgloss.NewStyle().Foreground(t.Text).Bold(true),
Bold: lipgloss.NewStyle().Foreground(t.Text).Bold(true),
Selected: lipgloss.NewStyle().Foreground(t.Primary).Bold(true),
Triggered: lipgloss.NewStyle().Foreground(t.Firing).Bold(true),
Acknowledged: lipgloss.NewStyle().Foreground(t.Accent).Bold(true),
Snoozed: lipgloss.NewStyle().Foreground(t.Muted).Italic(true),
SevCritical: lipgloss.NewStyle().Foreground(t.SevCritical).Bold(true),
SevError: lipgloss.NewStyle().Foreground(t.SevError).Bold(true),
SevWarning: lipgloss.NewStyle().Foreground(t.SevWarning),
SevInfo: lipgloss.NewStyle().Foreground(t.SevInfo),
theme: t,
}
}
// Severity picks the style for a severity label, falling back to muted for
// values this client does not recognise rather than dropping them.
func (s Styles) Severity(severity string) lipgloss.Style {
switch strings.ToLower(severity) {
case "critical":
return s.SevCritical
case "error":
return s.SevError
case "warning":
return s.SevWarning
case "info":
return s.SevInfo
default:
return s.Muted
}
}
// IncidentStatus picks the style for an incident status, falling back to muted
// for statuses added after this client was built.
func (s Styles) IncidentStatus(status string) lipgloss.Style {
switch status {
case api.StatusTriggered:
return s.Triggered
case api.StatusAcknowledged:
return s.Acknowledged
case api.StatusResolved:
return s.Resolved
default:
return s.Muted
}
}
// ── Embedded bubbles components ────────────────────────────────────────────
//
// Each ships its own hardcoded palette, so a theme that stopped at this
// package's own styles would leave a pink selected row and grey help text
// behind. These three restyle them from the same tokens.
// Table styles the six tables. Padding comes from the bubbles defaults; only
// the colours are ours.
func (s Styles) Table() table.Styles {
ts := table.DefaultStyles()
ts.Header = ts.Header.Foreground(s.theme.Muted).Bold(true)
// Cell deliberately keeps no foreground: bubbles renders each cell before
// wrapping the whole row in Selected, so a colour here would emit a reset
// mid-row and cut the selection highlight short.
ts.Selected = ts.Selected.
Foreground(s.theme.OnPrimary).
Background(s.theme.Primary).
Bold(true)
return ts
}
// Help styles the key hints in the footer.
func (s Styles) Help() help.Styles {
key := lipgloss.NewStyle().Foreground(s.theme.Text)
desc := lipgloss.NewStyle().Foreground(s.theme.Muted)
sep := lipgloss.NewStyle().Foreground(s.theme.Muted)
return help.Styles{
Ellipsis: sep,
ShortKey: key,
ShortDesc: desc,
ShortSeparator: sep,
FullKey: key,
FullDesc: desc,
FullSeparator: sep,
}
}
// Input styles a text input and returns it, so NewModel can wrap each one as
// it is built.
func (s Styles) Input(ti textinput.Model) textinput.Model {
ti.PromptStyle = lipgloss.NewStyle().Foreground(s.theme.Primary)
ti.TextStyle = lipgloss.NewStyle().Foreground(s.theme.Text)
ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(s.theme.Muted)
ti.CompletionStyle = lipgloss.NewStyle().Foreground(s.theme.Muted)
ti.Cursor.Style = lipgloss.NewStyle().Foreground(s.theme.Primary)
return ti
}
+131
View File
@@ -0,0 +1,131 @@
package tui
import (
"testing"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss"
)
// Assertions here read the colours off the styles rather than off rendered
// output: under `go test` stdout is not a TTY, so lipgloss strips every escape
// sequence and rendered strings would all compare equal.
func wantFg(t *testing.T, s lipgloss.Style, want lipgloss.Color, what string) {
t.Helper()
if got := s.GetForeground(); got != want {
t.Errorf("%s foreground = %v, want %v", what, got, want)
}
}
func TestStyles_TokensReachTheStyles(t *testing.T) {
th := theme.GruvboxDark
s := newStyles(th)
wantFg(t, s.Header, th.Primary, "header")
wantFg(t, s.Firing, th.Firing, "firing")
wantFg(t, s.Resolved, th.Resolved, "resolved")
wantFg(t, s.Muted, th.Muted, "muted")
wantFg(t, s.Status, th.Accent, "status")
wantFg(t, s.Error, th.Error, "error")
wantFg(t, s.SevWarning, th.SevWarning, "sev warning")
// The two inverted spots need the pair, not just a foreground.
wantFg(t, s.TabActive, th.OnPrimary, "active tab")
if got := s.TabActive.GetBackground(); got != th.Primary {
t.Errorf("active tab background = %v, want %v", got, th.Primary)
}
// Attributes the old package-level styles carried must survive.
if !s.Firing.GetBold() {
t.Error("firing lost its bold")
}
if !s.Snoozed.GetItalic() {
t.Error("snoozed lost its italic")
}
if top, right, bottom, left := s.TabActive.GetPadding(); top != 0 || right != 2 || bottom != 0 || left != 2 {
t.Errorf("active tab padding = %d %d %d %d, want 0 2 0 2", top, right, bottom, left)
}
}
func TestStyles_EachBuiltinIsFullyPopulated(t *testing.T) {
for _, th := range []theme.Theme{theme.GruvboxDark, theme.GruvboxLight} {
s := newStyles(th)
for what, style := range map[string]lipgloss.Style{
"header": s.Header, "tab inactive": s.TabInactive, "footer": s.Footer,
"status": s.Status, "error": s.Error, "firing": s.Firing,
"resolved": s.Resolved, "muted": s.Muted, "accent": s.Accent,
"alert name": s.AlertName, "bold": s.Bold, "selected": s.Selected,
"triggered": s.Triggered, "acknowledged": s.Acknowledged, "snoozed": s.Snoozed,
"sev critical": s.SevCritical, "sev error": s.SevError,
"sev warning": s.SevWarning, "sev info": s.SevInfo,
} {
if style.GetForeground() == (lipgloss.NoColor{}) {
t.Errorf("%s: %s has no foreground", th.Name, what)
}
}
}
}
func TestStyles_SeverityFallsBackToMuted(t *testing.T) {
s := newStyles(theme.GruvboxDark)
for _, sev := range []string{"critical", "CRITICAL", "error", "warning", "info"} {
if s.Severity(sev).GetForeground() == s.Muted.GetForeground() {
t.Errorf("severity %q should have its own colour", sev)
}
}
for _, sev := range []string{"", "page", "unknown"} {
if s.Severity(sev).GetForeground() != s.Muted.GetForeground() {
t.Errorf("severity %q should fall back to muted", sev)
}
}
}
func TestStyles_IncidentStatusFallsBackToMuted(t *testing.T) {
s := newStyles(theme.GruvboxDark)
for _, status := range []string{api.StatusTriggered, api.StatusAcknowledged, api.StatusResolved} {
if s.IncidentStatus(status).GetForeground() == s.Muted.GetForeground() {
t.Errorf("status %q should have its own colour", status)
}
}
if s.IncidentStatus("invented-later").GetForeground() != s.Muted.GetForeground() {
t.Error("an unknown status should fall back to muted")
}
}
// The bubbles components ship their own palettes — a pink selected row, grey
// help text, a 240 placeholder. These check we replaced them.
func TestStyles_BubblesComponentsFollowTheTheme(t *testing.T) {
th := theme.GruvboxDark
s := newStyles(th)
ts := s.Table()
wantFg(t, ts.Selected, th.OnPrimary, "table selection")
if got := ts.Selected.GetBackground(); got != th.Primary {
t.Errorf("table selection background = %v, want %v", got, th.Primary)
}
wantFg(t, ts.Header, th.Muted, "table header")
if _, right, _, left := ts.Cell.GetPadding(); right != 1 || left != 1 {
t.Error("table cell padding was lost")
}
// A foreground on Cell would emit a reset mid-row and truncate the
// selection highlight, so it must stay unset.
if ts.Cell.GetForeground() != (lipgloss.NoColor{}) {
t.Error("table cells must not carry a foreground")
}
h := s.Help()
wantFg(t, h.ShortKey, th.Text, "help key")
wantFg(t, h.ShortDesc, th.Muted, "help description")
wantFg(t, h.FullSeparator, th.Muted, "help separator")
in := s.Input(textinput.New())
wantFg(t, in.PlaceholderStyle, th.Muted, "input placeholder")
wantFg(t, in.TextStyle, th.Text, "input text")
wantFg(t, in.PromptStyle, th.Primary, "input prompt")
wantFg(t, in.Cursor.Style, th.Primary, "input cursor")
}
+1174 -207
View File
File diff suppressed because it is too large Load Diff
+975
View File
@@ -0,0 +1,975 @@
package tui
import (
"strings"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
tea "github.com/charmbracelet/bubbletea"
)
// 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, theme.GruvboxDark)
m.width, m.height = 120, 40
m.connected = true
m.isAdmin = true // most handlers are being tested for what they do, not who may
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.pickerTarget = pickerSchedule
m.pickerMembers = map[int64]bool{1: true, 2: true}
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
}
// The bug: assigning an on-call week panicked with "index out of range [-1]"
// on a perfectly normal schedule, as long as nobody had moved the cursor first.
//
// The cause is not in this package. bubbles' SetRows clamps the cursor down but
// never up, so the empty rebuild every table gets from the first WindowSizeMsg
// -- which arrives before any fetch returns -- pins the cursor at -1, and
// loading real rows afterwards leaves it there. Pressing up or down hid it,
// which is why every existing test missed it: they all call SetCursor, and
// SetCursor clamps.
//
// So this test must NOT touch the cursor. It reproduces the real order of
// events: size first, data second, keys third.
func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.connected = true
m.isAdmin = true
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleOwner}}
m.activeSection = sectionSchedule
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
// 1. Terminal size arrives while every table is still empty.
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = next.(Model)
// 2. The schedule and the user list land.
next, _ = m.Update(scheduleFetchedMsg{entries: []api.ScheduleEntry{}})
m = next.(Model)
next, _ = m.Update(usersFetchedMsg{users: []api.User{
{ID: 1, Username: "niklas", Email: "n@example.com"},
}})
m = next.(Model)
if got := m.scheduleTable.Cursor(); got < 0 {
t.Fatalf("schedule cursor is %d after loading %d days; a populated table must have a usable cursor",
got, len(m.scheduleDays))
}
// 3. Assign the week to the first user, without ever moving a cursor.
m, _ = press(t, m, "W")
if m.mode != modeUserPicker {
t.Fatalf("W did not open the user picker, got mode %v", m.mode)
}
// The picker waits for the team's members before offering anybody.
next, _ = m.Update(pickerReadyMsg{
users: []api.User{{ID: 1, Username: "niklas", Email: "n@example.com"}},
members: map[int64]bool{1: true},
})
m = next.(Model)
m, _ = press(t, m, "enter") // panicked here
if m.mode == modeUserPicker {
t.Fatal("enter left the picker open; the assignment never went anywhere")
}
}
// ── Teams ─────────────────────────────────────────────────────────────────
func twoTeams() []api.Team {
return []api.Team{
{ID: 1, Name: "Ops", Role: api.RoleOwner},
{ID: 2, Name: "Dev", Role: api.RoleMember},
}
}
func TestConnected_LoadsTeamsAndWhoIAm(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40
next, _ := m.Update(connectedMsg{
teams: twoTeams(),
me: api.Me{User: api.User{ID: 7, IsAdmin: true}},
})
m = next.(Model)
if len(m.teams) != 2 || m.meID != 7 || !m.isAdmin {
t.Errorf("expected teams, id and admin flag to be kept, got %+v %d %v", m.teams, m.meID, m.isAdmin)
}
if m.activeTeamID != 0 {
t.Errorf("with no default team every team shows, got active %d", m.activeTeamID)
}
}
func TestConnected_DefaultTeamFromConfig(t *testing.T) {
for _, want := range []string{"dev", "2"} { // by name, any case, or by id
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark).WithDefaultTeam(want)
next, _ := m.Update(connectedMsg{teams: twoTeams()})
if got := next.(Model).activeTeamID; got != 2 {
t.Errorf("default team %q: expected team 2, got %d", want, got)
}
}
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark).WithDefaultTeam("nope")
next, cmd := m.Update(connectedMsg{teams: twoTeams()})
m = next.(Model)
if m.activeTeamID != 0 || !strings.Contains(m.statusMsg, "nope") {
t.Errorf("an unknown default should fall back to all teams and say so, got %d %q",
m.activeTeamID, m.statusMsg)
}
if cmd == nil {
t.Error("expected the initial fetches to still be issued")
}
}
func TestSwitchTeam_CyclesAllThenEachTeam(t *testing.T) {
m := sized()
m.teams = twoTeams()
var seen []int64
for i := 0; i < 4; i++ {
var cmd tea.Cmd
m, cmd = press(t, m, "T")
if cmd == nil {
t.Fatal("switching team should reload")
}
seen = append(seen, m.activeTeamID)
}
want := []int64{1, 2, 0, 1}
for i := range want {
if seen[i] != want[i] {
t.Fatalf("expected the cycle %v, got %v", want, seen)
}
}
}
func TestSwitchTeam_ClearsRowsFromTheOtherTeam(t *testing.T) {
m := sized()
m.teams = twoTeams()
m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}}
m.rebuildIncidentTable()
m, _ = press(t, m, "T")
if len(m.incidents) != 0 {
t.Errorf("the previous team's incidents must not linger, got %d", len(m.incidents))
}
if !strings.Contains(m.statusMsg, "Ops") {
t.Errorf("expected the status bar to name the team, got %q", m.statusMsg)
}
}
func TestSwitchTeam_NoTeamsDoesNothing(t *testing.T) {
m, cmd := press(t, sized(), "T")
if cmd != nil || m.activeTeamID != 0 {
t.Errorf("without teams T has nothing to switch to")
}
}
func TestScheduleTeam(t *testing.T) {
m := sized()
if _, ok := m.scheduleTeam(); ok {
t.Error("no teams means no schedule")
}
m.teams = []api.Team{{ID: 2, Name: "Dev", Role: api.RoleMember}, {ID: 1, Name: "Ops", Role: api.RoleOwner}}
if tm, _ := m.scheduleTeam(); tm.ID != 1 {
t.Errorf("with all teams showing the one the caller owns is used, got %d", tm.ID)
}
m.activeTeamID = 2
if tm, _ := m.scheduleTeam(); tm.ID != 2 {
t.Errorf("the active team wins, got %d", tm.ID)
}
}
func TestSchedule_OnlyOwnersAndAdminsEdit(t *testing.T) {
m := scheduledWeek(nil)
m.isAdmin = false
m.teams = []api.Team{{ID: 1, Name: "Ops", Role: api.RoleMember}}
m, cmd := press(t, m, "+")
if m.mode == modeUserPicker {
t.Fatal("a plain member must not get as far as the picker")
}
if !strings.Contains(m.statusMsg, "owners of Ops") {
t.Errorf("expected the reason in the status bar, got %q", m.statusMsg)
}
if cmd == nil {
t.Error("the message should clear itself")
}
m.isAdmin = true // administrators may edit any team's rota
m.statusMsg = ""
m, _ = press(t, m, "+")
if m.mode != modeUserPicker {
t.Errorf("an administrator should reach the picker, got mode %v", m.mode)
}
}
// The picker fetches the team's members, and offers nobody until they arrive:
// the server answers 404 for anyone else.
func TestSchedulePicker_OffersOnlyTeamMembers(t *testing.T) {
m := scheduledWeek(nil)
m, cmd := press(t, m, "+")
if cmd == nil || !m.usersLoading {
t.Fatal("opening the picker should start loading the members")
}
if got := len(m.pickerUsers()); got != 0 {
t.Errorf("nobody should be offered before the members are known, got %d", got)
}
disabled := time.Now()
next, _ := m.Update(pickerReadyMsg{
users: []api.User{
{ID: 1, Username: "niklas"},
{ID: 2, Username: "outsider"},
{ID: 3, Username: "gone", DisabledAt: &disabled},
},
members: map[int64]bool{1: true, 3: true},
})
m = next.(Model)
got := m.pickerUsers()
if len(got) != 1 || got[0].Username != "niklas" {
t.Errorf("expected only the enabled member, got %+v", got)
}
if rows := m.userPickerTable.Rows(); len(rows) != 1 {
t.Errorf("the table should match, got %d rows", len(rows))
}
}
func TestUsers_NonAdminsCannotCreateOrDelete(t *testing.T) {
m := threeUsers()
m.isAdmin = false
m.meID = 1
m, _ = press(t, m, "n")
if m.mode != modeDashboard || !strings.Contains(m.statusMsg, "administrators") {
t.Errorf("n should be refused with a reason, got mode %v %q", m.mode, m.statusMsg)
}
m, _ = press(t, m, "d")
if m.mode != modeDashboard {
t.Errorf("d should not ask to delete for a non-admin, got mode %v", m.mode)
}
}
func TestUsers_NonAdminManagesOnlyThemselves(t *testing.T) {
m := threeUsers() // cursor on erik, id 3
m.isAdmin = false
m.meID = 1
for _, key := range []string{"t", "k", "p"} {
next, _ := press(t, m, key)
if next.mode != modeDashboard {
t.Errorf("%s on somebody else's row should be refused, got mode %v", key, next.mode)
}
if !strings.Contains(next.statusMsg, "another user's") {
t.Errorf("%s: expected the reason, got %q", key, next.statusMsg)
}
}
m.userManageTable.SetCursor(0) // niklas, id 1: themselves
if next, _ := press(t, m, "k"); next.mode != modeAPIKeyMenu {
t.Errorf("a user may manage their own keys, got mode %v", next.mode)
}
}
func TestUserFlags(t *testing.T) {
now := time.Now()
cases := []struct {
u api.User
want string
}{
{api.User{}, "—"},
{api.User{IsAdmin: true}, "admin"},
{api.User{DisabledAt: &now}, "disabled"},
{api.User{IsAdmin: true, DisabledAt: &now}, "admin,disabled"},
}
for _, c := range cases {
if got := userFlags(c.u); got != c.want {
t.Errorf("userFlags(%+v) = %q, want %q", c.u, got, c.want)
}
}
}
// Rebuilding a list on every refresh must not send the cursor back to the top.
func TestRefresh_KeepsTheCursor(t *testing.T) {
m := sized()
m.incidents = []api.Incident{{ID: 1}, {ID: 2}, {ID: 3}}
m.rebuildIncidentTable()
m.incidentTable.SetCursor(2)
next, _ := m.Update(incidentsFetchedMsg{incidents: m.incidents})
if got := next.(Model).incidentTable.Cursor(); got != 2 {
t.Errorf("expected the cursor to stay on row 2, got %d", got)
}
}
// The Team column comes and goes as the team switches, and the table must
// survive its column count changing under rows that are already loaded.
func TestTeamColumn_AppearsWithoutPanicking(t *testing.T) {
m := sized()
m.incidents = []api.Incident{{ID: 1, Title: "a", TeamName: "Ops"}}
m.rebuildIncidentTable()
m.teams = twoTeams()
m.rebuildIncidentTable() // five columns become six over a five-cell row
if got := len(m.incidentTable.Columns()); got != 6 {
t.Errorf("expected a Team column across two teams, got %d columns", got)
}
m.activeTeamID = 1
m.rebuildIncidentTable()
if got := len(m.incidentTable.Columns()); got != 5 {
t.Errorf("expected the Team column to go when one team is chosen, got %d", got)
}
}
+204
View File
@@ -0,0 +1,204 @@
package tui
import (
"strings"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
tea "github.com/charmbracelet/bubbletea"
)
// threeUsers is the Users section with the cursor on the last of three users.
func threeUsers() Model {
m := onUsers([]api.User{
{ID: 1, Username: "niklas"},
{ID: 2, Username: "anna"},
{ID: 3, Username: "erik"},
})
m.userManageTable.SetCursor(2)
return m
}
// The table used to see k before the section did, take it as "up", and the
// handler then opened API keys for the user above the one selected.
func TestUsers_APIKeysOpenForTheSelectedUser(t *testing.T) {
m, _ := press(t, threeUsers(), "k")
if m.mode != modeAPIKeyMenu {
t.Fatalf("expected the API key menu, got mode %v", m.mode)
}
if m.selectedUser.Username != "erik" {
t.Errorf("API keys opened for %s, want erik", m.selectedUser.Username)
}
}
// Same collision with d, which the table read as half a page down: the delete
// confirmation named a different user than the one under the cursor.
func TestUsers_DeleteTargetsTheSelectedUser(t *testing.T) {
m := threeUsers()
m.userManageTable.SetCursor(0)
m, _ = press(t, m, "d")
if m.mode != modeConfirm || m.selectedUser.Username != "niklas" {
t.Errorf("delete asked about %q in mode %v, want niklas", m.selectedUser.Username, m.mode)
}
}
func TestSchedule_DeleteTargetsTheSelectedDay(t *testing.T) {
m := sized()
m.activeSection = sectionSchedule
m.scheduleEntries = []api.ScheduleEntry{
{ID: 10, UserID: 1, Username: "niklas", Date: m.scheduleWindow.Format("2006-01-02")},
{ID: 11, UserID: 2, Username: "anna", Date: m.scheduleWindow.AddDate(0, 0, 1).Format("2006-01-02")},
}
m.scheduleDays = buildScheduleDays(m.scheduleWindow, m.scheduleEntries)
m.rebuildScheduleTable()
m.scheduleTable.SetCursor(0)
m, _ = press(t, m, "d")
if m.pendingDeleteEntry == nil || m.pendingDeleteEntry.ID != 10 {
t.Errorf("schedule delete targeted %+v, want entry 10", m.pendingDeleteEntry)
}
}
// f cycles the filter; it must not also page the cursor down.
func TestFilter_DoesNotMoveTheCursor(t *testing.T) {
m := sized()
m.incidents = make([]api.Incident, 40)
for i := range m.incidents {
m.incidents[i] = api.Incident{ID: int64(i + 1), Title: "x", Status: api.StatusTriggered, TriggeredAt: time.Now()}
}
m.rebuildIncidentTable()
m, _ = press(t, m, "f")
if c := m.incidentTable.Cursor(); c != 0 {
t.Errorf("f moved the cursor to %d", c)
}
}
// The arrow keys still move the users table, now that k is an action there.
func TestUsers_ArrowKeysStillNavigate(t *testing.T) {
m := threeUsers()
next, _ := m.Update(keyUp())
if c := next.(Model).userManageTable.Cursor(); c != 1 {
t.Errorf("up arrow left the cursor on %d, want 1", c)
}
}
func TestPassword_OpensForTheSelectedUserAndLooksUpWhoIAm(t *testing.T) {
m, cmd := press(t, threeUsers(), "p")
if m.mode != modePasswordSet || m.selectedUser.Username != "erik" {
t.Fatalf("expected the password form for erik, got mode %v for %q", m.mode, m.selectedUser.Username)
}
if !m.pwLoading || cmd == nil {
t.Error("the form should look up /api/me before it is usable")
}
if !strings.Contains(m.View(), "Checking who this key belongs to") {
t.Error("the form should say it is waiting")
}
}
func TestPassword_SomeoneElseNeedsNoCurrentPassword(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: true}})
m = next.(Model)
if m.pwNeedCurrent || m.pwFocus != pwNew {
t.Errorf("setting erik's password as niklas should not ask for a current one")
}
if strings.Contains(m.View(), "Current password") {
t.Error("the current-password field should be hidden")
}
}
func TestPassword_OwnExistingPasswordNeedsCurrent(t *testing.T) {
m := threeUsers()
m.userManageTable.SetCursor(0)
m, _ = press(t, m, "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: true}})
m = next.(Model)
if !m.pwNeedCurrent || m.pwFocus != pwCurrent {
t.Fatal("changing your own existing password should ask for the current one first")
}
m = typeInto(t, m, "correct horse")
m, _ = press(t, m, "tab")
m = typeInto(t, m, "a brand new secret")
m, _ = press(t, m, "tab")
m = typeInto(t, m, "a brand new secret")
m, cmd := press(t, m, "enter")
if cmd == nil || m.mode != modeDashboard {
t.Errorf("a complete form should submit (mode %v)", m.mode)
}
}
func TestPassword_OwnFirstPasswordNeedsNoCurrent(t *testing.T) {
m := threeUsers()
m.userManageTable.SetCursor(0)
m, _ = press(t, m, "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}, HasPassword: false}})
if next.(Model).pwNeedCurrent {
t.Error("there is no current password to ask for yet")
}
}
func TestPassword_RejectsBeforeSending(t *testing.T) {
cases := []struct{ name, pw, repeat, want string }{
{"too short", "short", "short", "at least 10"},
{"mismatch", "a brand new secret", "a different secret", "do not match"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 1}}})
m = typeInto(t, next.(Model), tc.pw)
m, _ = press(t, m, "tab")
m = typeInto(t, m, tc.repeat)
m, cmd := press(t, m, "enter")
if m.mode != modePasswordSet {
t.Error("the form should stay open")
}
if !strings.Contains(m.statusMsg, tc.want) {
t.Errorf("status %q should mention %q", m.statusMsg, tc.want)
}
if cmd == nil {
return
}
// Only the clear-status timer may be scheduled, never a request.
if _, ok := cmd().(clearStatusMsg); !ok {
t.Error("nothing should be sent to the server")
}
})
}
}
func TestPassword_EscapeCancels(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
m, _ = press(t, m, "esc")
if m.mode != modeDashboard {
t.Errorf("esc should close the form, got mode %v", m.mode)
}
// A lookup arriving after the form closed must not reopen anything.
next, _ := m.Update(meFetchedMsg{me: api.Me{User: api.User{ID: 3}, HasPassword: true}})
if next.(Model).mode != modeDashboard {
t.Error("a late /api/me answer reopened the form")
}
}
func TestPassword_ErrorClosesTheFormWithAMessage(t *testing.T) {
m, _ := press(t, threeUsers(), "p")
next, _ := m.Update(userActionErrMsg{errTest("server returned 403: current password is incorrect")})
m = next.(Model)
if m.mode != modeDashboard || !strings.Contains(m.statusMsg, "current password is incorrect") {
t.Errorf("expected the server's message on the dashboard, got %q in mode %v", m.statusMsg, m.mode)
}
}
func typeInto(t *testing.T, m Model, s string) Model {
t.Helper()
next, _ := m.Update(runes(s))
return next.(Model)
}
func runes(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} }
func keyUp() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyUp} }
type errTest string
func (e errTest) Error() string { return string(e) }
+754 -281
View File
File diff suppressed because it is too large Load Diff
+368
View File
@@ -0,0 +1,368 @@
package tui
import (
"regexp"
"strings"
"testing"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
tea "github.com/charmbracelet/bubbletea"
)
// ansi matches the escape sequences lipgloss emits when it decides the output
// 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, "") }
// testStyles is the default theme, so assertions here run against what a user
// with no 'theme:' key actually sees.
func testStyles() Styles { return newStyles(theme.GruvboxDark) }
func mustContain(t *testing.T, got string, wants ...string) {
t.Helper()
got = plain(got)
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(testStyles(), inc, timeline, nil, -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(testStyles(), inc, nil, 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(testStyles(), inc, nil, 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(testStyles(), inc, nil, 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(testStyles(), inc, nil, 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(testStyles(), inc, nil, 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(testStyles(), inc, timeline, nil, 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(testStyles(), inc, timeline, nil, -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(testStyles(), 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(testStyles(), alert, 110), "Incident:", "none")
}
func TestAlertDetail_ShowsResolutionSource(t *testing.T) {
source := "expiry"
alert := api.Alert{Name: "Gone", Status: "resolved", ReceivedAt: time.Now(),
ResolutionSource: &source}
mustContain(t, buildAlertDetailContent(testStyles(), alert, 110), "RESOLVED", "expiry")
}
// Null MTTA means nothing has been acknowledged, which is a different claim
// from an instant response.
func TestStats_RendersDashForMissingAverages(t *testing.T) {
stats := &api.IncidentStats{Total: 2, Triggered: 2}
out := buildStatsContent(testStyles(), 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(testStyles(), stats, []api.TopAlert{{Name: "DiskFull", Count: 4}}, nil, nil, 110)
mustContain(t, out, "2m", "1h", "Top Alerts", "DiskFull")
}
func TestStats_HandlesNoIncidentData(t *testing.T) {
mustContain(t, buildStatsContent(testStyles(), 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, theme.GruvboxDark)
m.connected = true
m.incidentStats = &api.IncidentStats{Total: 3, Triggered: 1}
m.topAlerts = []api.TopAlert{{Name: "DiskFull", Count: 4}}
m.statsLoaded = true
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = next.(Model)
m.activeSection = sectionStats
out := m.View()
mustContain(t, out, "Stats", "Incident Response", "Top Alerts", "DiskFull",
"tab·section")
if strings.Contains(plain(out), "Loading statistics") {
t.Error("loaded stats should not show the loading placeholder")
}
}
func TestView_ConnectionError(t *testing.T) {
m := sized()
m.connected = false
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, theme.GruvboxDark)
if m.View() != "" {
t.Error("expected no output before the first window size message")
}
}
type errFixture struct{}
func (errFixture) Error() string { return "connection refused" }
func TestIncidentDetail_ShowsSimilarWithResolutionNotes(t *testing.T) {
now := time.Now()
inc := api.Incident{Title: "DiskFull", Status: api.StatusTriggered, TriggeredAt: now}
similar := []api.SimilarIncident{
{ID: 4, Title: "DiskFull (job=node)", ResolvedAt: now.Add(-48 * time.Hour),
ResolutionNotes: []api.IncidentEvent{{Type: api.EventResolutionNote, Detail: "rotated the logs"}}},
{ID: 2, Title: "DiskFull (job=node)", ResolvedAt: now.Add(-96 * time.Hour), NoteCount: 3},
}
out := plain(buildIncidentDetailContent(testStyles(), inc, nil, similar, -1, 110))
mustContain(t, out, "Seen before", "#4", "fixed: rotated the logs", "3 note(s), no resolution note")
// Nothing similar, no section.
if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, nil, -1, 110)), "Seen before") {
t.Error("expected no Seen before section without similar incidents")
}
}
+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
+24 -6
View File
@@ -5,11 +5,13 @@ import (
"fmt" "fmt"
"os" "os"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/config"
"git.ryuvia.com/niklas/terdut-tui/internal/session"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"git.ryuvia.com/niklas/terdut-tui/internal/tui"
"git.ryuvia.com/niklas/terdut-tui/internal/updater"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
"github.com/yeniklas/terdut-tui/internal/config"
"github.com/yeniklas/terdut-tui/internal/tui"
"github.com/yeniklas/terdut-tui/internal/updater"
) )
var version = "dev" var version = "dev"
@@ -38,8 +40,24 @@ func main() {
os.Exit(1) os.Exit(1)
} }
client := api.NewClient(cfg.ServerURL, cfg.APIKey) th, err := theme.Load(cfg.Theme)
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval) if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
client := api.NewClient(cfg.ServerURL)
if token := session.Load(cfg.ServerURL); token != "" {
client.SetSession(token)
}
note := ""
if cfg.LegacyAPIKey {
note = "api_key in config.yaml is no longer used: sign in with your username and password"
}
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th).
WithDefaultTeam(cfg.Team).
WithAuth(cfg.Auth).
WithLogin(cfg.Username, note)
p := tea.NewProgram(model, tea.WithAltScreen()) p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {