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.
5.9 KiB
terdut-tui
TUI client for 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.
Tech stack
- Go 1.25+
- Bubbletea — TUI framework (strict Elm architecture)
- Lipgloss — styles (all in
internal/tui/styles.go, never inline) - Bubbles — table, textinput, help components
Project layout
main.go CLI entry point: flags, config load, start TUI
internal/api/client.go REST API client — one method per endpoint
internal/config/config.go Config loader (~/.config/terdut-tui/config.yaml)
internal/theme/ Colour themes: semantic tokens, built-ins, user file loader
internal/tui/ Bubbletea UI
model.go Model struct, mode/section constants, Init(), tea.Cmd constructors
update.go Update() — dispatch only, no API calls inline
view.go View() — pure rendering
keys.go keyMap (bubbles/key pattern)
styles.go Styles struct — every lipgloss style, built from a theme
internal/updater/updater.go Self-update via Gitea releases
Architecture rules
- Never call API inside
Update()— returntea.Cmdinstead; the runtime runs it async. View()is pure — no side effects, no state mutations.- All state in
Model— no globals. - All styles in
styles.go— never use lipgloss inline inview.go. Styles live onModel.styles, built once bynewStyles(theme.Theme); the handful of free functions inview.gotake aStylesas their first argument. No colour literal appears outsideinternal/theme.
Config
Location: ~/.config/terdut-tui/config.yaml
server_url: https://terdut.example.com
username: niklas # optional, prefills the sign-in form
refresh_interval: 30 # seconds, optional, default 30
theme: gruvbox-dark # optional, default gruvbox-dark
team: Ops # optional, team name or id to start on, default all
Built-in themes are gruvbox-dark and gruvbox-light; user themes are YAML
files in ~/.config/terdut-tui/themes/, optionally extends:-ing a built-in.
See the README for the token list.
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
go run .
go run . --version
go run . --self-update
Building
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
| Stage | Feature |
|---|---|
| 1 | Scaffold, config, health check, placeholder TUI |
| 2 | Alert dashboard with auto-refresh and stats |
| 3 | Alert detail: acknowledge, comment, statistics charts |
| 4 | On-call schedule calendar view |
| 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) |
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. ⚠ takesagent, notagent_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.