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.
This commit is contained in:
Niklas Ye
2026-08-20 11:06:36 +02:00
parent dc53d49c3e
commit 4a579bdbc6
15 changed files with 996 additions and 226 deletions
+10 -1
View File
@@ -28,12 +28,13 @@ non-destructive "not now" alternative.
main.go CLI entry point: flags, config load, health check, start TUI main.go CLI entry point: flags, config load, health check, 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 GitHub Releases
``` ```
@@ -43,6 +44,9 @@ internal/updater/updater.go Self-update via GitHub Releases
2. **`View()` is pure** — no side effects, no state mutations. 2. **`View()` is pure** — no side effects, no state mutations.
3. **All state in `Model`** — no globals. 3. **All state in `Model`** — no globals.
4. **All styles in `styles.go`** — never use lipgloss inline in `view.go`. 4. **All styles in `styles.go`** — never use lipgloss inline in `view.go`.
Styles live on `Model.styles`, built once by `newStyles(theme.Theme)`; the
handful of free functions in `view.go` take a `Styles` as their first
argument. No colour literal appears outside `internal/theme`.
## Config ## Config
@@ -52,8 +56,13 @@ Location: `~/.config/terdut-tui/config.yaml`
server_url: https://terdut.example.com server_url: https://terdut.example.com
api_key: <64-char hex key> api_key: <64-char hex key>
refresh_interval: 30 # seconds, optional, default 30 refresh_interval: 30 # seconds, optional, default 30
theme: gruvbox-dark # optional, default gruvbox-dark
``` ```
Built-in themes are `gruvbox-dark` and `gruvbox-light`; user themes are YAML
files in `~/.config/terdut-tui/themes/`, optionally `extends:`-ing a built-in.
See the README for the token list.
The API key is a one-time secret generated by terdut-server (`POST /api/users/{id}/api-keys`). The API key is a one-time secret generated by terdut-server (`POST /api/users/{id}/api-keys`).
## Running ## Running
+39
View File
@@ -69,10 +69,49 @@ Create `~/.config/terdut-tui/config.yaml`:
server_url: https://terdut.example.com server_url: https://terdut.example.com
api_key: <your-api-key> api_key: <your-api-key>
refresh_interval: 30 # seconds, optional refresh_interval: 30 # seconds, optional
theme: gruvbox-dark # optional, this is the default
``` ```
The API key is generated in terdut-server. See the server documentation for how to bootstrap a user and issue an API key. The API key is generated in terdut-server. See the server documentation for how to bootstrap a user and issue an API key.
## Themes
Two themes ship with the client: `gruvbox-dark` (the default) and
`gruvbox-light`. Both colour foregrounds only — the terminal supplies the
background — so pick the one that matches the background you already run.
To make your own, drop a file in `~/.config/terdut-tui/themes/` and name it in
`theme:`. `extends` inherits a built-in, so a file only has to list what it
changes:
```yaml
# ~/.config/terdut-tui/themes/mine.yaml
extends: gruvbox-dark
primary: "#d3869b"
accent: "#fabd2f"
```
A file may shadow a built-in name — `themes/gruvbox-dark.yaml` is how you tweak
the default without renaming it.
Without `extends`, every token must be set. The twelve are:
| Token | Where it shows |
|---|---|
| `primary` | header, active tab, selected row, cursors |
| `on_primary` | text drawn *on* `primary` — the active tab and selected row |
| `text` | incident titles and other emphasis |
| `muted` | secondary text, dividers, footer, table headers |
| `accent` | status line, acknowledged incidents, the by-day chart |
| `firing` | firing alerts, triggered incidents, the by-hour chart |
| `resolved` | resolved alerts and incidents, the top-alerts chart |
| `error` | error banners |
| `sev_critical`, `sev_error`, `sev_warning`, `sev_info` | the `severity` label |
Values are hex (`#83a598` or `#abc`) or an ANSI palette index (`0`–`255`) if you
would rather follow your terminal's own colours. Colours are downsampled
automatically on 256- and 16-colour terminals, and `NO_COLOR` is honoured.
## Usage ## Usage
``` ```
+4 -1
View File
@@ -15,12 +15,14 @@ type Config struct {
ServerURL string ServerURL string
APIKey string APIKey string
RefreshInterval time.Duration RefreshInterval time.Duration
Theme string
} }
type rawConfig struct { type rawConfig struct {
ServerURL string `yaml:"server_url"` ServerURL string `yaml:"server_url"`
APIKey string `yaml:"api_key"` APIKey string `yaml:"api_key"`
RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds
Theme string `yaml:"theme,omitempty"`
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -33,7 +35,7 @@ func Load() (*Config, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n api_key: <your-api-key>", path) return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n api_key: <your-api-key>\n theme: gruvbox-dark # optional", path)
} }
return nil, fmt.Errorf("cannot read config file: %w", err) return nil, fmt.Errorf("cannot read config file: %w", err)
} }
@@ -59,5 +61,6 @@ func Load() (*Config, error) {
ServerURL: raw.ServerURL, ServerURL: raw.ServerURL,
APIKey: raw.APIKey, APIKey: raw.APIKey,
RefreshInterval: interval, RefreshInterval: interval,
Theme: raw.Theme,
}, nil }, nil
} }
+88
View File
@@ -0,0 +1,88 @@
package theme
import "sort"
// The gruvbox palettes, in the author's original names. Dark uses the bright
// variants and light the faded ones, which is what keeps each readable against
// its own background.
const (
darkBg0 = "#282828"
darkFg1 = "#ebdbb2"
darkGray = "#928374"
darkRed = "#fb4934"
darkGrn = "#b8bb26"
darkYel = "#fabd2f"
darkBlu = "#83a598"
darkAqua = "#8ec07c"
darkOrng = "#fe8019"
lightBg0 = "#fbf1c7"
lightFg1 = "#3c3836"
lightFg4 = "#7c6f64"
lightRed = "#9d0006"
lightGrn = "#79740e"
lightYel = "#b57614"
lightBlu = "#076678"
lightAqua = "#427b58"
lightOrng = "#af3a03"
)
// GruvboxDark is the default scheme. It assumes a dark terminal background:
// themes colour foregrounds only, so the terminal supplies the canvas.
var GruvboxDark = Theme{
Name: "gruvbox-dark",
Primary: darkBlu,
OnPrimary: darkBg0,
Text: darkFg1,
Muted: darkGray,
Accent: darkOrng,
Firing: darkRed,
Resolved: darkGrn,
Error: darkRed,
SevCritical: darkRed,
SevError: darkOrng,
SevWarning: darkYel,
SevInfo: darkAqua,
}
// GruvboxLight is the same scheme against a light terminal background.
var GruvboxLight = Theme{
Name: "gruvbox-light",
Primary: lightBlu,
OnPrimary: lightBg0,
Text: lightFg1,
Muted: lightFg4,
Accent: lightOrng,
Firing: lightRed,
Resolved: lightGrn,
Error: lightRed,
SevCritical: lightRed,
SevError: lightOrng,
SevWarning: lightYel,
SevInfo: lightAqua,
}
// Default is the theme used when the config names none.
var Default = GruvboxDark
var builtins = map[string]Theme{
GruvboxDark.Name: GruvboxDark,
GruvboxLight.Name: GruvboxLight,
}
// BuiltinNames lists the compiled-in themes, sorted, for error messages and
// documentation.
func BuiltinNames() []string {
names := make([]string, 0, len(builtins))
for name := range builtins {
names = append(names, name)
}
sort.Strings(names)
return names
}
+196
View File
@@ -0,0 +1,196 @@
package theme
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/charmbracelet/lipgloss"
"gopkg.in/yaml.v3"
)
// rawTheme is the on-disk form. Every token is a pointer so an absent key is
// distinguishable from an empty one, which is what lets 'extends' overwrite
// only what the file actually mentions.
type rawTheme struct {
Extends *string `yaml:"extends"`
Primary *string `yaml:"primary"`
OnPrimary *string `yaml:"on_primary"`
Text *string `yaml:"text"`
Muted *string `yaml:"muted"`
Accent *string `yaml:"accent"`
Firing *string `yaml:"firing"`
Resolved *string `yaml:"resolved"`
Error *string `yaml:"error"`
SevCritical *string `yaml:"sev_critical"`
SevError *string `yaml:"sev_error"`
SevWarning *string `yaml:"sev_warning"`
SevInfo *string `yaml:"sev_info"`
}
// binding ties a YAML key to its raw value and the field it fills, so parsing,
// merging and the missing-token report all walk the same list.
type binding struct {
key string
src *string
dst *lipgloss.Color
}
func bindings(r *rawTheme, t *Theme) []binding {
return []binding{
{"primary", r.Primary, &t.Primary},
{"on_primary", r.OnPrimary, &t.OnPrimary},
{"text", r.Text, &t.Text},
{"muted", r.Muted, &t.Muted},
{"accent", r.Accent, &t.Accent},
{"firing", r.Firing, &t.Firing},
{"resolved", r.Resolved, &t.Resolved},
{"error", r.Error, &t.Error},
{"sev_critical", r.SevCritical, &t.SevCritical},
{"sev_error", r.SevError, &t.SevError},
{"sev_warning", r.SevWarning, &t.SevWarning},
{"sev_info", r.SevInfo, &t.SevInfo},
}
}
// tokenKeys lists the colour keys a theme file may set, in the order they are
// documented.
func tokenKeys() []string {
bs := bindings(&rawTheme{}, &Theme{})
keys := make([]string, len(bs))
for i, b := range bs {
keys[i] = b.key
}
return keys
}
func isTokenKey(k string) bool {
if k == "extends" {
return true
}
for _, want := range tokenKeys() {
if k == want {
return true
}
}
return false
}
// Load resolves a theme by name. An empty name is the default; otherwise a file
// in the user's themes directory wins over a built-in of the same name, so the
// documented way to tweak a built-in is to shadow it rather than rename it.
func Load(name string) (Theme, error) {
if name == "" {
return Default, nil
}
dir, err := os.UserConfigDir()
if err != nil {
// Built-ins do not need the disk, so a missing config directory only
// matters for user themes.
if t, ok := builtins[name]; ok {
return t, nil
}
return Theme{}, fmt.Errorf("cannot determine config directory: %w", err)
}
return loadFrom(filepath.Join(dir, "terdut-tui", "themes"), name)
}
func loadFrom(dir, name string) (Theme, error) {
if strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
return Theme{}, fmt.Errorf("invalid theme name %q: a theme is a bare name, not a path", name)
}
path := filepath.Join(dir, name+".yaml")
data, err := os.ReadFile(path)
switch {
case err == nil:
return parse(name, data)
case !os.IsNotExist(err):
return Theme{}, fmt.Errorf("cannot read theme file %s: %w", path, err)
}
if t, ok := builtins[name]; ok {
return t, nil
}
return Theme{}, fmt.Errorf("unknown theme %q\n\nBuilt-in themes: %s\nOr define your own at %s",
name, strings.Join(BuiltinNames(), ", "), path)
}
func parse(name string, data []byte) (Theme, error) {
// Check the keys before decoding, so a typo'd token reports itself by name
// alongside the ones that would have worked rather than surfacing yaml's
// message about an internal Go type.
var keys map[string]yaml.Node
if err := yaml.Unmarshal(data, &keys); err != nil {
return Theme{}, fmt.Errorf("invalid theme %q: %w", name, err)
}
for k := range keys {
if !isTokenKey(k) {
return Theme{}, fmt.Errorf("theme %q: unknown key %q\n\nValid keys: extends, %s",
name, k, strings.Join(tokenKeys(), ", "))
}
}
var raw rawTheme
if err := yaml.Unmarshal(data, &raw); err != nil {
return Theme{}, fmt.Errorf("invalid theme %q: %w", name, err)
}
t := Theme{Name: name}
if raw.Extends != nil {
base, ok := builtins[*raw.Extends]
if !ok {
return Theme{}, fmt.Errorf("theme %q: 'extends' names unknown theme %q (built-ins: %s)",
name, *raw.Extends, strings.Join(BuiltinNames(), ", "))
}
t = base
t.Name = name
}
var missing []string
for _, b := range bindings(&raw, &t) {
if b.src == nil {
if raw.Extends == nil {
missing = append(missing, b.key)
}
continue
}
c, err := parseColor(*b.src)
if err != nil {
return Theme{}, fmt.Errorf("theme %q: %s: %w", name, b.key, err)
}
*b.dst = c
}
if len(missing) > 0 {
return Theme{}, fmt.Errorf("theme %q is missing %s\n\nEither set every token or add 'extends: %s' to inherit the rest",
name, strings.Join(missing, ", "), Default.Name)
}
return t, nil
}
var hexColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// parseColor accepts what lipgloss can actually render: a hex value, or an ANSI
// palette index for people who would rather follow their terminal's colours.
func parseColor(s string) (lipgloss.Color, error) {
if hexColor.MatchString(s) {
return lipgloss.Color(s), nil
}
// strconv.Itoa round-trips to reject "+7" and "007", which lipgloss would
// pass to the terminal verbatim.
if n, err := strconv.Atoi(s); err == nil && n >= 0 && n <= 255 && strconv.Itoa(n) == s {
return lipgloss.Color(s), nil
}
return "", fmt.Errorf("invalid colour %q, want a hex value like \"#83a598\" or an ANSI index 0-255", s)
}
+180
View File
@@ -0,0 +1,180 @@
package theme
import (
"os"
"path/filepath"
"strings"
"testing"
)
// write drops a theme file into dir and returns the directory, so each test
// works against its own themes directory rather than the user's.
func write(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name+".yaml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoad_EmptyNameIsTheDefault(t *testing.T) {
got, err := Load("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Name != Default.Name {
t.Errorf("got theme %q, want %q", got.Name, Default.Name)
}
}
func TestLoadFrom_BuiltinsResolveWithoutAFile(t *testing.T) {
dir := t.TempDir()
for _, name := range BuiltinNames() {
got, err := loadFrom(dir, name)
if err != nil {
t.Fatalf("%s: unexpected error: %v", name, err)
}
if got.Name != name {
t.Errorf("got theme %q, want %q", got.Name, name)
}
if got.Primary == "" {
t.Errorf("%s: primary is unset", name)
}
}
}
func TestLoadFrom_ExtendsOverridesOnlyWhatIsNamed(t *testing.T) {
dir := t.TempDir()
write(t, dir, "mine", "extends: gruvbox-dark\nprimary: \"#d3869b\"\n")
got, err := loadFrom(dir, "mine")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Name != "mine" {
t.Errorf("got name %q, want %q", got.Name, "mine")
}
if got.Primary != "#d3869b" {
t.Errorf("got primary %q, want the override", got.Primary)
}
if got.Muted != GruvboxDark.Muted {
t.Errorf("got muted %q, want inherited %q", got.Muted, GruvboxDark.Muted)
}
if got.SevInfo != GruvboxDark.SevInfo {
t.Errorf("got sev_info %q, want inherited %q", got.SevInfo, GruvboxDark.SevInfo)
}
}
func TestLoadFrom_UserFileShadowsABuiltin(t *testing.T) {
dir := t.TempDir()
write(t, dir, "gruvbox-dark", "extends: gruvbox-dark\naccent: \"#fabd2f\"\n")
got, err := loadFrom(dir, "gruvbox-dark")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Accent != "#fabd2f" {
t.Errorf("got accent %q, want the shadowing file's value", got.Accent)
}
}
func TestLoadFrom_WithoutExtendsEveryTokenIsRequired(t *testing.T) {
dir := t.TempDir()
write(t, dir, "partial", "primary: \"#83a598\"\n")
_, err := loadFrom(dir, "partial")
if err == nil {
t.Fatal("expected an error for a theme missing tokens")
}
for _, want := range []string{"muted", "sev_info", "extends"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error should mention %q, got: %v", want, err)
}
}
}
func TestLoadFrom_CompleteThemeNeedsNoExtends(t *testing.T) {
dir := t.TempDir()
write(t, dir, "full", `primary: "#000001"
on_primary: "#000002"
text: "#000003"
muted: "#000004"
accent: "#000005"
firing: "#000006"
resolved: "#000007"
error: "#000008"
sev_critical: "#000009"
sev_error: "#00000a"
sev_warning: "#00000b"
sev_info: "#00000c"
`)
got, err := loadFrom(dir, "full")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Primary != "#000001" || got.SevInfo != "#00000c" {
t.Errorf("tokens not applied: %+v", got)
}
}
func TestLoadFrom_Errors(t *testing.T) {
tests := []struct {
name string
file string // empty means: write no file at all
body string
want string
}{
{"unknown name", "", "", "unknown theme"},
{"unknown key", "typo", "extends: gruvbox-dark\nprimry: \"#83a598\"\n", `unknown key "primry"`},
{"unknown key lists the valid ones", "typo2", "primry: \"#83a598\"\n", "Valid keys: extends, primary,"},
{"bad colour", "bad", "extends: gruvbox-dark\nprimary: notacolour\n", "invalid colour"},
{"bad colour names the token", "badkey", "extends: gruvbox-dark\nsev_warning: \"#gggggg\"\n", "sev_warning"},
{"unknown base", "orphan", "extends: solarized\nprimary: \"#83a598\"\n", "unknown theme \"solarized\""},
{"malformed yaml", "broken", "extends: [\n", "invalid theme"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
name := tt.file
if name == "" {
name = "missing"
} else {
write(t, dir, name, tt.body)
}
_, err := loadFrom(dir, name)
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), tt.want) {
t.Errorf("error should mention %q, got: %v", tt.want, err)
}
})
}
}
func TestLoadFrom_RejectsPathsAsNames(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"../secrets", "sub/theme", ".."} {
if _, err := loadFrom(dir, name); err == nil {
t.Errorf("%q: expected an error", name)
}
}
}
func TestParseColor(t *testing.T) {
ok := []string{"#83a598", "#FFF", "#abc", "0", "15", "255"}
for _, s := range ok {
if _, err := parseColor(s); err != nil {
t.Errorf("parseColor(%q) = %v, want no error", s, err)
}
}
bad := []string{"", "83a598", "#ab", "#abcd", "#gggggg", "256", "-1", "+7", "007", "red"}
for _, s := range bad {
if _, err := parseColor(s); err == nil {
t.Errorf("parseColor(%q) = nil, want an error", s)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
// Package theme resolves the named colour scheme the TUI renders with. A theme
// is a flat set of semantic tokens — roles like "muted" or "firing", never hues
// — so a new scheme is a table of colours rather than a change to the views.
package theme
import "github.com/charmbracelet/lipgloss"
// Theme is the palette the UI draws from. Every token is a foreground except
// OnPrimary, which is the text colour for the two places that invert: the
// active tab and the selected table row.
//
// Colours are truecolor hex; lipgloss downsamples them for 256- and 16-colour
// terminals and drops them entirely under NO_COLOR, so themes do not carry
// fallbacks of their own.
type Theme struct {
Name string
Primary lipgloss.Color // header, tab highlight, selection
OnPrimary lipgloss.Color // text drawn on a Primary background
Text lipgloss.Color // default emphasis foreground
Muted lipgloss.Color // secondary text, dividers, borders
Accent lipgloss.Color // status line, acknowledged, by-day chart
Firing lipgloss.Color
Resolved lipgloss.Color
Error lipgloss.Color
SevCritical lipgloss.Color
SevError lipgloss.Color
SevWarning lipgloss.Color
SevInfo lipgloss.Color
}
+19 -17
View File
@@ -5,12 +5,12 @@ import (
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
) )
// ── Enums ────────────────────────────────────────────────────────────────── // ── Enums ──────────────────────────────────────────────────────────────────
@@ -246,10 +246,12 @@ type Model struct {
help help.Model help help.Model
keys keyMap keys keyMap
styles Styles
} }
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model { func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration, th theme.Theme) Model {
ts := defaultTableStyles() st := newStyles(th)
ts := st.Table()
incidentT := table.New(table.WithFocused(true)) incidentT := table.New(table.WithFocused(true))
incidentT.SetStyles(ts) incidentT.SetStyles(ts)
@@ -301,6 +303,15 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
revokeIn.Placeholder = "integer key ID" revokeIn.Placeholder = "integer key ID"
revokeIn.CharLimit = 20 revokeIn.CharLimit = 20
for _, in := range []*textinput.Model{
&noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn,
} {
*in = st.Input(*in)
}
helpModel := help.New()
helpModel.Styles = st.Help()
now := time.Now().UTC() now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
weekday := int(today.Weekday()) weekday := int(today.Weekday())
@@ -333,8 +344,9 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
ntfyTopicInput: topicIn, ntfyTopicInput: topicIn,
apiKeyNameInput: keyNameIn, apiKeyNameInput: keyNameIn,
apiKeyRevokeInput: revokeIn, apiKeyRevokeInput: revokeIn,
help: help.New(), help: helpModel,
keys: keys, keys: keys,
styles: st,
} }
} }
@@ -344,16 +356,6 @@ func (m Model) Init() tea.Cmd {
// ── Table rebuilders ─────────────────────────────────────────────────────── // ── Table rebuilders ───────────────────────────────────────────────────────
func defaultTableStyles() table.Styles {
s := table.DefaultStyles()
s.Header = s.Header.Bold(true)
s.Selected = s.Selected.
Foreground(lipgloss.Color("0")).
Background(colorPrimary).
Bold(true)
return s
}
// setRows replaces a table's rows and keeps its cursor in a state the rest of // setRows replaces a table's rows and keeps its cursor in a state the rest of
// this package can rely on: valid whenever the table has any rows at all. // this package can rely on: valid whenever the table has any rows at all.
// //
@@ -433,16 +435,16 @@ func (m *Model) refreshDetailContent() {
return return
} }
if m.mode == modeAlertDetail { if m.mode == modeAlertDetail {
m.detailViewport.SetContent(buildAlertDetailContent(m.selectedAlert, m.width)) m.detailViewport.SetContent(buildAlertDetailContent(m.styles, m.selectedAlert, m.width))
return return
} }
m.detailViewport.SetContent( m.detailViewport.SetContent(
buildIncidentDetailContent(m.selectedIncident, m.timeline, m.noteCursor, m.width)) buildIncidentDetailContent(m.styles, m.selectedIncident, m.timeline, m.noteCursor, m.width))
} }
func (m *Model) refreshStatsContent() { func (m *Model) refreshStatsContent() {
m.statsViewport.SetContent( m.statsViewport.SetContent(
buildStatsContent(m.incidentStats, m.topAlerts, m.hourStats, m.dayStats, m.width)) buildStatsContent(m.styles, m.incidentStats, m.topAlerts, m.hourStats, m.dayStats, m.width))
} }
func (m Model) statsViewportHeight() int { func (m Model) statsViewportHeight() int {
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/table"
) )
@@ -177,7 +178,7 @@ func TestAlertRows_ShowIncidentLink(t *testing.T) {
func TestUserManageRows_ShowMissingTopic(t *testing.T) { func TestUserManageRows_ShowMissingTopic(t *testing.T) {
topic := "terdut-niklas" topic := "terdut-niklas"
empty := "" empty := ""
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40 m.width, m.height = 120, 40
m.users = []api.User{ m.users = []api.User{
{ID: 1, Username: "niklas", NtfyTopic: &topic}, {ID: 1, Username: "niklas", NtfyTopic: &topic},
@@ -283,7 +284,7 @@ func TestBuildScheduleDays(t *testing.T) {
// scheduledWeek builds a model showing the week of 2026-07-27 with the given // scheduledWeek builds a model showing the week of 2026-07-27 with the given
// entries already on the rota. // entries already on the rota.
func scheduledWeek(entries []api.ScheduleEntry) Model { func scheduledWeek(entries []api.ScheduleEntry) Model {
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40 m.width, m.height = 120, 40
m.connected = true m.connected = true
m.activeSection = sectionSchedule m.activeSection = sectionSchedule
+142 -66
View File
@@ -4,96 +4,172 @@ import (
"strings" "strings"
"git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
var ( // Styles is every style the views draw with, built once from a theme and held
colorPrimary = lipgloss.Color("69") // blue // on the Model. Nothing here reads a colour literal: the theme is the only
colorMuted = lipgloss.Color("240") // gray // place a colour is named.
colorFiring = lipgloss.Color("196") // red type Styles struct {
colorResolved = lipgloss.Color("70") // green Header lipgloss.Style
colorAccent = lipgloss.Color("214") // orange TabActive lipgloss.Style
TabInactive lipgloss.Style
Footer lipgloss.Style
Status lipgloss.Style
colorSevCritical = lipgloss.Color("196") // red Error lipgloss.Style
colorSevError = lipgloss.Color("202") // dark orange Firing lipgloss.Style
colorSevWarning = lipgloss.Color("214") // orange Resolved lipgloss.Style
colorSevInfo = lipgloss.Color("39") // cyan Muted lipgloss.Style
Accent lipgloss.Style
styleHeader = lipgloss.NewStyle(). AlertName lipgloss.Style
Bold(true). Bold lipgloss.Style
Foreground(colorPrimary). Selected lipgloss.Style
Padding(0, 1)
styleTabActive = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("0")).
Background(colorPrimary).
Padding(0, 2)
styleTabInactive = lipgloss.NewStyle().
Foreground(colorMuted).
Padding(0, 2)
styleFooter = lipgloss.NewStyle().
Foreground(colorMuted)
styleStatus = lipgloss.NewStyle().
Foreground(colorAccent).
Bold(true)
styleError = lipgloss.NewStyle().
Foreground(colorFiring).
Bold(true)
styleFiring = lipgloss.NewStyle().Foreground(colorFiring).Bold(true)
styleResolved = lipgloss.NewStyle().Foreground(colorResolved)
styleMuted = lipgloss.NewStyle().Foreground(colorMuted)
styleAlertName = lipgloss.NewStyle().Bold(true)
styleBold = lipgloss.NewStyle().Bold(true)
styleSelected = lipgloss.NewStyle().Foreground(colorPrimary).Bold(true)
styleAccent = lipgloss.NewStyle().Foreground(colorAccent)
// Incident status. Triggered is unclaimed work and reads as loudly as a // Incident status. Triggered is unclaimed work and reads as loudly as a
// firing alert; acknowledged means somebody has it. // firing alert; acknowledged means somebody has it.
styleTriggered = lipgloss.NewStyle().Foreground(colorFiring).Bold(true) Triggered lipgloss.Style
styleAcknowledged = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) Acknowledged lipgloss.Style
styleSnoozed = lipgloss.NewStyle().Foreground(colorMuted).Italic(true) Snoozed lipgloss.Style
// Severity, over the conventional Alertmanager label values. // Severity, over the conventional Alertmanager label values.
styleSevCritical = lipgloss.NewStyle().Foreground(colorSevCritical).Bold(true) SevCritical lipgloss.Style
styleSevError = lipgloss.NewStyle().Foreground(colorSevError).Bold(true) SevError lipgloss.Style
styleSevWarning = lipgloss.NewStyle().Foreground(colorSevWarning) SevWarning lipgloss.Style
styleSevInfo = lipgloss.NewStyle().Foreground(colorSevInfo) SevInfo lipgloss.Style
)
// severityStyle picks the style for a severity label, falling back to muted for theme theme.Theme
}
func newStyles(t theme.Theme) Styles {
return Styles{
Header: lipgloss.NewStyle().
Bold(true).
Foreground(t.Primary).
Padding(0, 1),
TabActive: lipgloss.NewStyle().
Bold(true).
Foreground(t.OnPrimary).
Background(t.Primary).
Padding(0, 2),
TabInactive: lipgloss.NewStyle().
Foreground(t.Muted).
Padding(0, 2),
Footer: lipgloss.NewStyle().Foreground(t.Muted),
Status: lipgloss.NewStyle().
Foreground(t.Accent).
Bold(true),
Error: lipgloss.NewStyle().
Foreground(t.Error).
Bold(true),
Firing: lipgloss.NewStyle().Foreground(t.Firing).Bold(true),
Resolved: lipgloss.NewStyle().Foreground(t.Resolved),
Muted: lipgloss.NewStyle().Foreground(t.Muted),
Accent: lipgloss.NewStyle().Foreground(t.Accent),
AlertName: lipgloss.NewStyle().Foreground(t.Text).Bold(true),
Bold: lipgloss.NewStyle().Foreground(t.Text).Bold(true),
Selected: lipgloss.NewStyle().Foreground(t.Primary).Bold(true),
Triggered: lipgloss.NewStyle().Foreground(t.Firing).Bold(true),
Acknowledged: lipgloss.NewStyle().Foreground(t.Accent).Bold(true),
Snoozed: lipgloss.NewStyle().Foreground(t.Muted).Italic(true),
SevCritical: lipgloss.NewStyle().Foreground(t.SevCritical).Bold(true),
SevError: lipgloss.NewStyle().Foreground(t.SevError).Bold(true),
SevWarning: lipgloss.NewStyle().Foreground(t.SevWarning),
SevInfo: lipgloss.NewStyle().Foreground(t.SevInfo),
theme: t,
}
}
// Severity picks the style for a severity label, falling back to muted for
// values this client does not recognise rather than dropping them. // values this client does not recognise rather than dropping them.
func severityStyle(severity string) lipgloss.Style { func (s Styles) Severity(severity string) lipgloss.Style {
switch strings.ToLower(severity) { switch strings.ToLower(severity) {
case "critical": case "critical":
return styleSevCritical return s.SevCritical
case "error": case "error":
return styleSevError return s.SevError
case "warning": case "warning":
return styleSevWarning return s.SevWarning
case "info": case "info":
return styleSevInfo return s.SevInfo
default: default:
return styleMuted return s.Muted
} }
} }
// incidentStatusStyle picks the style for an incident status, falling back to // IncidentStatus picks the style for an incident status, falling back to muted
// muted for statuses added after this client was built. // for statuses added after this client was built.
func incidentStatusStyle(status string) lipgloss.Style { func (s Styles) IncidentStatus(status string) lipgloss.Style {
switch status { switch status {
case api.StatusTriggered: case api.StatusTriggered:
return styleTriggered return s.Triggered
case api.StatusAcknowledged: case api.StatusAcknowledged:
return styleAcknowledged return s.Acknowledged
case api.StatusResolved: case api.StatusResolved:
return styleResolved return s.Resolved
default: default:
return styleMuted return s.Muted
} }
} }
// ── Embedded bubbles components ────────────────────────────────────────────
//
// Each ships its own hardcoded palette, so a theme that stopped at this
// package's own styles would leave a pink selected row and grey help text
// behind. These three restyle them from the same tokens.
// Table styles the six tables. Padding comes from the bubbles defaults; only
// the colours are ours.
func (s Styles) Table() table.Styles {
ts := table.DefaultStyles()
ts.Header = ts.Header.Foreground(s.theme.Muted).Bold(true)
// Cell deliberately keeps no foreground: bubbles renders each cell before
// wrapping the whole row in Selected, so a colour here would emit a reset
// mid-row and cut the selection highlight short.
ts.Selected = ts.Selected.
Foreground(s.theme.OnPrimary).
Background(s.theme.Primary).
Bold(true)
return ts
}
// Help styles the key hints in the footer.
func (s Styles) Help() help.Styles {
key := lipgloss.NewStyle().Foreground(s.theme.Text)
desc := lipgloss.NewStyle().Foreground(s.theme.Muted)
sep := lipgloss.NewStyle().Foreground(s.theme.Muted)
return help.Styles{
Ellipsis: sep,
ShortKey: key,
ShortDesc: desc,
ShortSeparator: sep,
FullKey: key,
FullDesc: desc,
FullSeparator: sep,
}
}
// Input styles a text input and returns it, so NewModel can wrap each one as
// it is built.
func (s Styles) Input(ti textinput.Model) textinput.Model {
ti.PromptStyle = lipgloss.NewStyle().Foreground(s.theme.Primary)
ti.TextStyle = lipgloss.NewStyle().Foreground(s.theme.Text)
ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(s.theme.Muted)
ti.CompletionStyle = lipgloss.NewStyle().Foreground(s.theme.Muted)
ti.Cursor.Style = lipgloss.NewStyle().Foreground(s.theme.Primary)
return ti
}
+131
View File
@@ -0,0 +1,131 @@
package tui
import (
"testing"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss"
)
// Assertions here read the colours off the styles rather than off rendered
// output: under `go test` stdout is not a TTY, so lipgloss strips every escape
// sequence and rendered strings would all compare equal.
func wantFg(t *testing.T, s lipgloss.Style, want lipgloss.Color, what string) {
t.Helper()
if got := s.GetForeground(); got != want {
t.Errorf("%s foreground = %v, want %v", what, got, want)
}
}
func TestStyles_TokensReachTheStyles(t *testing.T) {
th := theme.GruvboxDark
s := newStyles(th)
wantFg(t, s.Header, th.Primary, "header")
wantFg(t, s.Firing, th.Firing, "firing")
wantFg(t, s.Resolved, th.Resolved, "resolved")
wantFg(t, s.Muted, th.Muted, "muted")
wantFg(t, s.Status, th.Accent, "status")
wantFg(t, s.Error, th.Error, "error")
wantFg(t, s.SevWarning, th.SevWarning, "sev warning")
// The two inverted spots need the pair, not just a foreground.
wantFg(t, s.TabActive, th.OnPrimary, "active tab")
if got := s.TabActive.GetBackground(); got != th.Primary {
t.Errorf("active tab background = %v, want %v", got, th.Primary)
}
// Attributes the old package-level styles carried must survive.
if !s.Firing.GetBold() {
t.Error("firing lost its bold")
}
if !s.Snoozed.GetItalic() {
t.Error("snoozed lost its italic")
}
if top, right, bottom, left := s.TabActive.GetPadding(); top != 0 || right != 2 || bottom != 0 || left != 2 {
t.Errorf("active tab padding = %d %d %d %d, want 0 2 0 2", top, right, bottom, left)
}
}
func TestStyles_EachBuiltinIsFullyPopulated(t *testing.T) {
for _, th := range []theme.Theme{theme.GruvboxDark, theme.GruvboxLight} {
s := newStyles(th)
for what, style := range map[string]lipgloss.Style{
"header": s.Header, "tab inactive": s.TabInactive, "footer": s.Footer,
"status": s.Status, "error": s.Error, "firing": s.Firing,
"resolved": s.Resolved, "muted": s.Muted, "accent": s.Accent,
"alert name": s.AlertName, "bold": s.Bold, "selected": s.Selected,
"triggered": s.Triggered, "acknowledged": s.Acknowledged, "snoozed": s.Snoozed,
"sev critical": s.SevCritical, "sev error": s.SevError,
"sev warning": s.SevWarning, "sev info": s.SevInfo,
} {
if style.GetForeground() == (lipgloss.NoColor{}) {
t.Errorf("%s: %s has no foreground", th.Name, what)
}
}
}
}
func TestStyles_SeverityFallsBackToMuted(t *testing.T) {
s := newStyles(theme.GruvboxDark)
for _, sev := range []string{"critical", "CRITICAL", "error", "warning", "info"} {
if s.Severity(sev).GetForeground() == s.Muted.GetForeground() {
t.Errorf("severity %q should have its own colour", sev)
}
}
for _, sev := range []string{"", "page", "unknown"} {
if s.Severity(sev).GetForeground() != s.Muted.GetForeground() {
t.Errorf("severity %q should fall back to muted", sev)
}
}
}
func TestStyles_IncidentStatusFallsBackToMuted(t *testing.T) {
s := newStyles(theme.GruvboxDark)
for _, status := range []string{api.StatusTriggered, api.StatusAcknowledged, api.StatusResolved} {
if s.IncidentStatus(status).GetForeground() == s.Muted.GetForeground() {
t.Errorf("status %q should have its own colour", status)
}
}
if s.IncidentStatus("invented-later").GetForeground() != s.Muted.GetForeground() {
t.Error("an unknown status should fall back to muted")
}
}
// The bubbles components ship their own palettes — a pink selected row, grey
// help text, a 240 placeholder. These check we replaced them.
func TestStyles_BubblesComponentsFollowTheTheme(t *testing.T) {
th := theme.GruvboxDark
s := newStyles(th)
ts := s.Table()
wantFg(t, ts.Selected, th.OnPrimary, "table selection")
if got := ts.Selected.GetBackground(); got != th.Primary {
t.Errorf("table selection background = %v, want %v", got, th.Primary)
}
wantFg(t, ts.Header, th.Muted, "table header")
if _, right, _, left := ts.Cell.GetPadding(); right != 1 || left != 1 {
t.Error("table cell padding was lost")
}
// A foreground on Cell would emit a reset mid-row and truncate the
// selection highlight, so it must stay unset.
if ts.Cell.GetForeground() != (lipgloss.NoColor{}) {
t.Error("table cells must not carry a foreground")
}
h := s.Help()
wantFg(t, h.ShortKey, th.Text, "help key")
wantFg(t, h.ShortDesc, th.Muted, "help description")
wantFg(t, h.FullSeparator, th.Muted, "help separator")
in := s.Input(textinput.New())
wantFg(t, in.PlaceholderStyle, th.Muted, "input placeholder")
wantFg(t, in.TextStyle, th.Text, "input text")
wantFg(t, in.PromptStyle, th.Primary, "input prompt")
wantFg(t, in.Cursor.Style, th.Primary, "input cursor")
}
+3 -2
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@@ -31,7 +32,7 @@ func press(t *testing.T, m Model, key string) (Model, tea.Cmd) {
// sized returns a connected model with a usable window, which most handlers need. // sized returns a connected model with a usable window, which most handlers need.
func sized() Model { func sized() Model {
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40 m.width, m.height = 120, 40
m.connected = true m.connected = true
return m return m
@@ -690,7 +691,7 @@ func containsAll(s string, subs ...string) bool {
// So this test must NOT touch the cursor. It reproduces the real order of // So this test must NOT touch the cursor. It reproduces the real order of
// events: size first, data second, keys third. // events: size first, data second, keys third.
func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) { func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.connected = true m.connected = true
m.activeSection = sectionSchedule m.activeSection = sectionSchedule
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC) m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
+118 -118
View File
@@ -26,8 +26,8 @@ func (m Model) View() string {
} }
func (m Model) renderHeader() string { func (m Model) renderHeader() string {
title := styleHeader.Render("terdut-tui") title := m.styles.Header.Render("terdut-tui")
right := styleMuted.Render(m.serverURL) right := m.styles.Muted.Render(m.serverURL)
return spread(title, right, m.width) return spread(title, right, m.width)
} }
@@ -35,31 +35,31 @@ func (m Model) renderTabs() string {
var tabs []string var tabs []string
for i, name := range sectionNames { for i, name := range sectionNames {
if section(i) == m.activeSection { if section(i) == m.activeSection {
tabs = append(tabs, styleTabActive.Render(name)) tabs = append(tabs, m.styles.TabActive.Render(name))
} else { } else {
tabs = append(tabs, styleTabInactive.Render(name)) tabs = append(tabs, m.styles.TabInactive.Render(name))
} }
} }
sep := styleMuted.Render(strings.Repeat("─", m.width)) sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
return strings.Join(tabs, "") + "\n" + sep return strings.Join(tabs, "") + "\n" + sep
} }
func (m Model) renderBody() string { func (m Model) renderBody() string {
if m.err != nil { if m.err != nil {
return "\n" + styleError.Render(fmt.Sprintf(" Error: %v", m.err)) + return "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) +
"\n" + styleMuted.Render(" Press r to retry.") "\n" + m.styles.Muted.Render(" Press r to retry.")
} }
if !m.connected { if !m.connected {
return "\n" + styleMuted.Render(" Connecting…") return "\n" + m.styles.Muted.Render(" Connecting…")
} }
switch m.mode { switch m.mode {
case modeIncidentDetail, modeAlertDetail: case modeIncidentDetail, modeAlertDetail:
return m.renderDetail() return m.renderDetail()
case modeNote: case modeNote:
return m.renderPrompt(styleHeader.Render("Note: ") + m.noteInput.View()) return m.renderPrompt(m.styles.Header.Render("Note: ") + m.noteInput.View())
case modeSnooze: case modeSnooze:
return m.renderPrompt(styleHeader.Render("Snooze for: ") + m.snoozeInput.View()) return m.renderPrompt(m.styles.Header.Render("Snooze for: ") + m.snoozeInput.View())
case modeConfirm: case modeConfirm:
switch m.confirmTarget { switch m.confirmTarget {
case confirmDeleteNote, confirmResolveIncident: case confirmDeleteNote, confirmResolveIncident:
@@ -90,9 +90,9 @@ func (m Model) renderBody() string {
func (m Model) renderFooter() string { func (m Model) renderFooter() string {
withStatus := func(actions string) string { withStatus := func(actions string) string {
rendered := styleFooter.Render(actions) rendered := m.styles.Footer.Render(actions)
if m.statusMsg != "" { if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + rendered return m.styles.Status.Render(" "+m.statusMsg) + "\n" + rendered
} }
return "\n" + rendered return "\n" + rendered
} }
@@ -108,13 +108,13 @@ func (m Model) renderFooter() string {
return withStatus(" i·open incident esc·back") return withStatus(" i·open incident esc·back")
case modeNote: case modeNote:
return "\n" + styleFooter.Render(" enter·submit esc·cancel") return "\n" + m.styles.Footer.Render(" enter·submit esc·cancel")
case modeSnooze: case modeSnooze:
return "\n" + styleFooter.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)") return "\n" + m.styles.Footer.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)")
case modeConfirm: case modeConfirm:
return "\n" + styleError.Render(" "+m.confirmPrompt()) return "\n" + m.styles.Error.Render(" "+m.confirmPrompt())
case modeUserPicker: case modeUserPicker:
if m.pickerTarget == pickerIncidentAssignee { if m.pickerTarget == pickerIncidentAssignee {
@@ -159,7 +159,7 @@ func (m Model) renderFooter() string {
case sectionUsers: case sectionUsers:
return withStatus(" n·new user t·topic d·delete k·API keys r·refresh tab·section q·quit") return withStatus(" n·new user t·topic d·delete k·API keys r·refresh tab·section q·quit")
} }
return "\n" + styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp())) return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
} }
} }
@@ -241,9 +241,9 @@ func (m Model) renderIncidents() string {
var content string var content string
switch { switch {
case m.loading && len(m.incidents) == 0: case m.loading && len(m.incidents) == 0:
content = styleMuted.Render(" Loading incidents…") content = m.styles.Muted.Render(" Loading incidents…")
case len(m.incidents) == 0: case len(m.incidents) == 0:
content = styleMuted.Render( content = m.styles.Muted.Render(
fmt.Sprintf(" No %s incidents.", filterLabel(m.incidentFilter))) fmt.Sprintf(" No %s incidents.", filterLabel(m.incidentFilter)))
default: default:
content = m.incidentTable.View() content = m.incidentTable.View()
@@ -256,9 +256,9 @@ func (m Model) renderAlerts() string {
var content string var content string
switch { switch {
case m.loading && len(m.alerts) == 0: case m.loading && len(m.alerts) == 0:
content = styleMuted.Render(" Loading alerts…") content = m.styles.Muted.Render(" Loading alerts…")
case len(m.alerts) == 0: case len(m.alerts) == 0:
content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", filterLabel(m.alertFilter))) content = m.styles.Muted.Render(fmt.Sprintf(" No %s alerts.", filterLabel(m.alertFilter)))
default: default:
content = m.alertTable.View() content = m.alertTable.View()
} }
@@ -267,10 +267,10 @@ func (m Model) renderAlerts() string {
func (m Model) renderArchived() string { func (m Model) renderArchived() string {
if m.archivedLoading { if m.archivedLoading {
return "\n" + styleMuted.Render(" Loading archived incidents…") return "\n" + m.styles.Muted.Render(" Loading archived incidents…")
} }
if len(m.archivedIncidents) == 0 { if len(m.archivedIncidents) == 0 {
return "\n" + styleMuted.Render(" No archived incidents.") return "\n" + m.styles.Muted.Render(" No archived incidents.")
} }
return "\n" + m.archivedTable.View() return "\n" + m.archivedTable.View()
} }
@@ -286,12 +286,12 @@ func (m Model) renderIncidentStatsBar() string {
mttr = humanSeconds(m.incidentStats.MTTRSeconds) mttr = humanSeconds(m.incidentStats.MTTRSeconds)
} }
left := fmt.Sprintf(" %s %s %s %s", left := fmt.Sprintf(" %s %s %s %s",
styleTriggered.Render(fmt.Sprintf("Triggered: %d", triggered)), m.styles.Triggered.Render(fmt.Sprintf("Triggered: %d", triggered)),
styleAcknowledged.Render(fmt.Sprintf("Acked: %d", acked)), m.styles.Acknowledged.Render(fmt.Sprintf("Acked: %d", acked)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)), m.styles.Resolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
styleMuted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)), m.styles.Muted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)),
) )
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.incidentFilter))) right := m.styles.Muted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.incidentFilter)))
return spread(left, right, m.width) return spread(left, right, m.width)
} }
@@ -304,10 +304,10 @@ func (m Model) renderAlertStatsBar() string {
} }
left := fmt.Sprintf(" Total: %d %s %s", left := fmt.Sprintf(" Total: %d %s %s",
total, total,
styleFiring.Render(fmt.Sprintf("Firing: %d", firing)), m.styles.Firing.Render(fmt.Sprintf("Firing: %d", firing)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)), m.styles.Resolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
) )
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.alertFilter))) right := m.styles.Muted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.alertFilter)))
return spread(left, right, m.width) return spread(left, right, m.width)
} }
@@ -324,20 +324,20 @@ func spread(left, right string, width int) string {
func (m Model) renderSchedule() string { func (m Model) renderSchedule() string {
if m.scheduleLoading { if m.scheduleLoading {
return "\n" + styleMuted.Render(" Loading schedule…") return "\n" + m.styles.Muted.Render(" Loading schedule…")
} }
var onCallLine string var onCallLine string
if m.currentOnCall != nil { if m.currentOnCall != nil {
onCallLine = fmt.Sprintf(" On-call today: %s", onCallLine = fmt.Sprintf(" On-call today: %s",
styleAlertName.Render(m.currentOnCall.Username)) m.styles.AlertName.Render(m.currentOnCall.Username))
} else { } else {
onCallLine = styleMuted.Render(" On-call today: nobody scheduled") onCallLine = m.styles.Muted.Render(" On-call today: nobody scheduled")
} }
from := m.scheduleWindow from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 6) to := m.scheduleWindow.AddDate(0, 0, 6)
windowLabel := styleMuted.Render(fmt.Sprintf(" %s — %s", windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s — %s",
from.Format("Jan 02"), to.Format("Jan 02, 2006"))) from.Format("Jan 02"), to.Format("Jan 02, 2006")))
header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n" header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n"
@@ -346,12 +346,12 @@ func (m Model) renderSchedule() string {
func (m Model) renderUserPicker() string { func (m Model) renderUserPicker() string {
if m.usersLoading { if m.usersLoading {
return "\n" + styleMuted.Render(" Loading users…") return "\n" + m.styles.Muted.Render(" Loading users…")
} }
if m.pickerTarget == pickerIncidentAssignee { if m.pickerTarget == pickerIncidentAssignee {
header := fmt.Sprintf("\n Assign %s to:\n\n", header := fmt.Sprintf("\n Assign %s to:\n\n",
styleBold.Render(m.selectedIncident.Title)) m.styles.Bold.Render(m.selectedIncident.Title))
return header + m.userPickerTable.View() return header + m.userPickerTable.View()
} }
@@ -376,7 +376,7 @@ func (m Model) renderUserPicker() string {
} }
} }
header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", styleBold.Render(scope)) header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", m.styles.Bold.Render(scope))
return header + m.userPickerTable.View() return header + m.userPickerTable.View()
} }
@@ -384,14 +384,14 @@ func (m Model) renderUserPicker() string {
func (m Model) renderDetail() string { func (m Model) renderDetail() string {
if m.detailLoading { if m.detailLoading {
return "\n" + styleMuted.Render(" Loading…") return "\n" + m.styles.Muted.Render(" Loading…")
} }
return m.detailViewport.View() return m.detailViewport.View()
} }
// renderPrompt puts an input line under the detail pane. // renderPrompt puts an input line under the detail pane.
func (m Model) renderPrompt(prompt string) string { func (m Model) renderPrompt(prompt string) string {
sep := styleMuted.Render(strings.Repeat("─", m.width)) sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
return m.detailViewport.View() + "\n" + sep + "\n" + prompt return m.detailViewport.View() + "\n" + sep + "\n" + prompt
} }
@@ -401,7 +401,7 @@ func (m Model) renderStats() string {
// Only announce loading before the first result: a background refresh must not // Only announce loading before the first result: a background refresh must not
// blank the page out from under whoever is reading it. // blank the page out from under whoever is reading it.
if m.statsLoading && !m.statsLoaded { if m.statsLoading && !m.statsLoaded {
return "\n" + styleMuted.Render(" Loading statistics…") return "\n" + m.styles.Muted.Render(" Loading statistics…")
} }
return m.statsViewport.View() return m.statsViewport.View()
} }
@@ -418,16 +418,16 @@ func line(style lipgloss.Style, s string) string {
// ── Content builders ─────────────────────────────────────────────────────── // ── Content builders ───────────────────────────────────────────────────────
func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string { func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string {
now := time.Now() now := time.Now()
var b strings.Builder var b strings.Builder
contentW := width - 4 contentW := width - 4
// Title + status header // Title + status header
title := styleAlertName.Render(inc.Title) title := s.AlertName.Render(inc.Title)
status := incidentStatusStyle(inc.Status).Render(incidentStatusLabel(inc)) status := s.IncidentStatus(inc.Status).Render(incidentStatusLabel(inc))
if inc.Severity != "" { if inc.Severity != "" {
status += " " + severityStyle(inc.Severity).Render(strings.ToUpper(inc.Severity)) status += " " + s.Severity(inc.Severity).Render(strings.ToUpper(inc.Severity))
} }
gap := contentW - lipgloss.Width(title) - lipgloss.Width(status) gap := contentW - lipgloss.Width(title) - lipgloss.Width(status)
if gap < 1 { if gap < 1 {
@@ -440,9 +440,9 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt))) inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt)))
if inc.AssignedTo != "" { if inc.AssignedTo != "" {
b.WriteString(fmt.Sprintf(" Assigned: %s\n", styleBold.Render(inc.AssignedTo))) b.WriteString(fmt.Sprintf(" Assigned: %s\n", s.Bold.Render(inc.AssignedTo)))
} else { } else {
b.WriteString(line(styleMuted, " Assigned: nobody")) b.WriteString(line(s.Muted, " Assigned: nobody"))
} }
if inc.AcknowledgedByID != nil { if inc.AcknowledgedByID != nil {
@@ -450,14 +450,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
if inc.AcknowledgedAt != nil { if inc.AcknowledgedAt != nil {
ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC") ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC")
} }
b.WriteString(line(styleResolved, b.WriteString(line(s.Resolved,
fmt.Sprintf(" Acked: %s%s", inc.AcknowledgedBy, ackAt))) fmt.Sprintf(" Acked: %s%s", inc.AcknowledgedBy, ackAt)))
} else { } else {
b.WriteString(line(styleMuted, " Acked: not acknowledged")) b.WriteString(line(s.Muted, " Acked: not acknowledged"))
} }
if inc.IsSnoozed() { if inc.IsSnoozed() {
b.WriteString(line(styleSnoozed, fmt.Sprintf(" Snoozed: until %s (%s)", b.WriteString(line(s.Snoozed, fmt.Sprintf(" Snoozed: until %s (%s)",
inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil)))) inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil))))
} }
@@ -470,14 +470,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source)) inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source))
} }
if inc.ArchivedAt != nil { if inc.ArchivedAt != nil {
b.WriteString(line(styleMuted, " Archived: "+ b.WriteString(line(s.Muted, " Archived: "+
inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC"))) inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC")))
} }
b.WriteString("\n") b.WriteString("\n")
// Group labels — the correlation Alertmanager applied. // Group labels — the correlation Alertmanager applied.
if len(inc.GroupLabels) > 0 { if len(inc.GroupLabels) > 0 {
b.WriteString(divider("Grouped By", width)) b.WriteString(divider(s, "Grouped By", width))
for _, k := range sortedKeys(inc.GroupLabels) { for _, k := range sortedKeys(inc.GroupLabels) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(inc.GroupLabels[k], contentW-24))) b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(inc.GroupLabels[k], contentW-24)))
} }
@@ -485,14 +485,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
} }
// Member alerts // Member alerts
b.WriteString(divider(fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width)) b.WriteString(divider(s, fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width))
if len(inc.Alerts) == 0 { if len(inc.Alerts) == 0 {
b.WriteString(line(styleMuted, " No alerts.")) b.WriteString(line(s.Muted, " No alerts."))
} else { } else {
for _, a := range inc.Alerts { for _, a := range inc.Alerts {
marker := styleFiring.Render("●") marker := s.Firing.Render("●")
if a.Status != "firing" { if a.Status != "firing" {
marker = styleResolved.Render("✓") marker = s.Resolved.Render("✓")
} }
instance := a.Labels["instance"] instance := a.Labels["instance"]
if instance == "" { if instance == "" {
@@ -500,29 +500,29 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
} }
b.WriteString(fmt.Sprintf(" %s %-28s %-26s %s\n", b.WriteString(fmt.Sprintf(" %s %-28s %-26s %s\n",
marker, truncate(a.Name, 28), truncate(instance, 26), marker, truncate(a.Name, 28), truncate(instance, 26),
styleMuted.Render("last seen "+humanAgo(now, a.ReceivedAt)))) s.Muted.Render("last seen "+humanAgo(now, a.ReceivedAt))))
} }
} }
b.WriteString("\n") b.WriteString("\n")
// Timeline — the only history the server keeps. // Timeline — the only history the server keeps.
notes := noteEvents(timeline) notes := noteEvents(timeline)
b.WriteString(divider(fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width)) b.WriteString(divider(s, fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width))
if len(timeline) == 0 { if len(timeline) == 0 {
b.WriteString(line(styleMuted, " Nothing recorded yet.")) b.WriteString(line(s.Muted, " Nothing recorded yet."))
} else { } else {
noteIndex := 0 noteIndex := 0
for _, e := range timeline { for _, e := range timeline {
when := styleMuted.Render(humanAgo(now, e.CreatedAt)) when := s.Muted.Render(humanAgo(now, e.CreatedAt))
if e.Type != api.EventNote { if e.Type != api.EventNote {
b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when)) b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when))
continue continue
} }
marker := " " marker := " "
author := styleBold.Render(e.Username) author := s.Bold.Render(e.Username)
if noteIndex == cursor { if noteIndex == cursor {
marker = styleSelected.Render("> ") marker = s.Selected.Render("> ")
author = styleSelected.Render(e.Username) author = s.Selected.Render(e.Username)
} }
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when)) b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when))
b.WriteString(" " + e.Detail + "\n") b.WriteString(" " + e.Detail + "\n")
@@ -626,21 +626,21 @@ func notifyKind(detail string) string {
return " (" + detail + ")" return " (" + detail + ")"
} }
func buildAlertDetailContent(alert api.Alert, width int) string { func buildAlertDetailContent(s Styles, alert api.Alert, width int) string {
now := time.Now() now := time.Now()
var b strings.Builder var b strings.Builder
contentW := width - 4 contentW := width - 4
name := styleAlertName.Render(alert.Name) name := s.AlertName.Render(alert.Name)
var statusStr string var statusStr string
if alert.Status == "firing" { if alert.Status == "firing" {
statusStr = styleFiring.Render("● FIRING") statusStr = s.Firing.Render("● FIRING")
} else { } else {
label := "✓ RESOLVED" label := "✓ RESOLVED"
if alert.ResolutionSource != nil { if alert.ResolutionSource != nil {
label += " · " + *alert.ResolutionSource label += " · " + *alert.ResolutionSource
} }
statusStr = styleResolved.Render(label) statusStr = s.Resolved.Render(label)
} }
gap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr) gap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
if gap < 1 { if gap < 1 {
@@ -660,15 +660,15 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
} }
if alert.IncidentID != nil { if alert.IncidentID != nil {
b.WriteString(fmt.Sprintf(" Incident: %s %s\n", b.WriteString(fmt.Sprintf(" Incident: %s %s\n",
styleBold.Render(fmt.Sprintf("#%d", *alert.IncidentID)), s.Bold.Render(fmt.Sprintf("#%d", *alert.IncidentID)),
styleMuted.Render("press i to open it"))) s.Muted.Render("press i to open it")))
} else { } else {
b.WriteString(line(styleMuted, " Incident: none")) b.WriteString(line(s.Muted, " Incident: none"))
} }
b.WriteString("\n") b.WriteString("\n")
if len(alert.Labels) > 0 { if len(alert.Labels) > 0 {
b.WriteString(divider("Labels", width)) b.WriteString(divider(s, "Labels", width))
for _, k := range sortedKeys(alert.Labels) { for _, k := range sortedKeys(alert.Labels) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Labels[k], contentW-24))) b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Labels[k], contentW-24)))
} }
@@ -676,7 +676,7 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
} }
if len(alert.Annotations) > 0 { if len(alert.Annotations) > 0 {
b.WriteString(divider("Annotations", width)) b.WriteString(divider(s, "Annotations", width))
for _, k := range sortedKeys(alert.Annotations) { for _, k := range sortedKeys(alert.Annotations) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Annotations[k], contentW-24))) b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Annotations[k], contentW-24)))
} }
@@ -684,14 +684,14 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
} }
// Alerts carry no workflow state: it all lives on the incident. // Alerts carry no workflow state: it all lives on the incident.
b.WriteString(divider("", width)) b.WriteString(divider(s, "", width))
b.WriteString(line(styleMuted, b.WriteString(line(s.Muted,
" Alerts are read-only — acknowledge, assign, note and resolve on the incident.")) " Alerts are read-only — acknowledge, assign, note and resolve on the incident."))
return b.String() return b.String()
} }
func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string { func buildStatsContent(s Styles, incidents *api.IncidentStats, top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string {
barWidth := width/2 - 10 barWidth := width/2 - 10
if barWidth < 8 { if barWidth < 8 {
barWidth = 8 barWidth = 8
@@ -704,41 +704,41 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
b.WriteString("\n") b.WriteString("\n")
// Response times first: they are what a rota is actually judged on. // Response times first: they are what a rota is actually judged on.
b.WriteString(divider("Incident Response", width)) b.WriteString(divider(s, "Incident Response", width))
if incidents == nil { if incidents == nil {
b.WriteString(line(styleMuted, " No data.")) b.WriteString(line(s.Muted, " No data."))
} else { } else {
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total",
styleBold.Render(fmt.Sprintf("%d", incidents.Total)))) s.Bold.Render(fmt.Sprintf("%d", incidents.Total))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Triggered", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Triggered",
styleTriggered.Render(fmt.Sprintf("%d", incidents.Triggered)))) s.Triggered.Render(fmt.Sprintf("%d", incidents.Triggered))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Acknowledged", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Acknowledged",
styleAcknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged)))) s.Acknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Resolved", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Resolved",
styleResolved.Render(fmt.Sprintf("%d", incidents.Resolved)))) s.Resolved.Render(fmt.Sprintf("%d", incidents.Resolved))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to acknowledge", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to acknowledge",
styleBold.Render(humanSeconds(incidents.MTTASeconds)))) s.Bold.Render(humanSeconds(incidents.MTTASeconds))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve",
styleBold.Render(humanSeconds(incidents.MTTRSeconds)))) s.Bold.Render(humanSeconds(incidents.MTTRSeconds))))
if incidents.MTTASeconds == nil || incidents.MTTRSeconds == nil { if incidents.MTTASeconds == nil || incidents.MTTRSeconds == nil {
b.WriteString(line(styleMuted, " (— means nothing has been acknowledged or resolved yet)")) b.WriteString(line(s.Muted, " (— means nothing has been acknowledged or resolved yet)"))
} }
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(divider("Top Alerts", width)) b.WriteString(divider(s, "Top Alerts", width))
if len(top) == 0 { if len(top) == 0 {
b.WriteString(line(styleMuted, " No data.")) b.WriteString(line(s.Muted, " No data."))
} else { } else {
maxCount := top[0].Count maxCount := top[0].Count
for i, a := range top { for i, a := range top {
bar := styleResolved.Render(strings.Repeat("█", renderBarWidth(a.Count, maxCount, barWidth))) bar := s.Resolved.Render(strings.Repeat("█", renderBarWidth(a.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %2d. %-30s %s %d\n", i+1, truncate(a.Name, 30), bar, a.Count)) b.WriteString(fmt.Sprintf(" %2d. %-30s %s %d\n", i+1, truncate(a.Name, 30), bar, a.Count))
} }
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(divider("Alerts by Hour (UTC)", width)) b.WriteString(divider(s, "Alerts by Hour (UTC)", width))
if len(byHour) > 0 { if len(byHour) > 0 {
maxCount := 0 maxCount := 0
for _, h := range byHour { for _, h := range byHour {
@@ -747,15 +747,15 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
} }
} }
for _, h := range byHour { for _, h := range byHour {
bar := styleFiring.Render(strings.Repeat("█", renderBarWidth(h.Count, maxCount, barWidth))) bar := s.Firing.Render(strings.Repeat("█", renderBarWidth(h.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %2dh %-*s %d\n", h.Hour, barWidth, bar, h.Count)) b.WriteString(fmt.Sprintf(" %2dh %-*s %d\n", h.Hour, barWidth, bar, h.Count))
} }
} else { } else {
b.WriteString(line(styleMuted, " No data.")) b.WriteString(line(s.Muted, " No data."))
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(divider("Alerts by Day", width)) b.WriteString(divider(s, "Alerts by Day", width))
if len(byDay) > 0 { if len(byDay) > 0 {
maxCount := 0 maxCount := 0
for _, d := range byDay { for _, d := range byDay {
@@ -764,11 +764,11 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
} }
} }
for _, d := range byDay { for _, d := range byDay {
bar := styleAccent.Render(strings.Repeat("█", renderBarWidth(d.Count, maxCount, barWidth))) bar := s.Accent.Render(strings.Repeat("█", renderBarWidth(d.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %-4s %-*s %d\n", d.DayName[:3], barWidth, bar, d.Count)) b.WriteString(fmt.Sprintf(" %-4s %-*s %d\n", d.DayName[:3], barWidth, bar, d.Count))
} }
} else { } else {
b.WriteString(line(styleMuted, " No data.")) b.WriteString(line(s.Muted, " No data."))
} }
return b.String() return b.String()
@@ -778,22 +778,22 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
func (m Model) renderUsers() string { func (m Model) renderUsers() string {
if m.usersLoading { if m.usersLoading {
return "\n" + styleMuted.Render(" Loading users…") return "\n" + m.styles.Muted.Render(" Loading users…")
} }
if len(m.users) == 0 { if len(m.users) == 0 {
return "\n" + styleMuted.Render(" No users found. Press n to create one.") return "\n" + m.styles.Muted.Render(" No users found. Press n to create one.")
} }
return "\n" + m.userManageTable.View() return "\n" + m.userManageTable.View()
} }
func (m Model) renderUserCreate() string { func (m Model) renderUserCreate() string {
header := "\n " + styleBold.Render("Create new user") + "\n\n" header := "\n " + m.styles.Bold.Render("Create new user") + "\n\n"
usernameLabel := " Username: " usernameLabel := " Username: "
emailLabel := " Email: " emailLabel := " Email: "
if m.userFormFocus == 0 { if m.userFormFocus == 0 {
usernameLabel = styleSelected.Render(" Username: ") usernameLabel = m.styles.Selected.Render(" Username: ")
} else { } else {
emailLabel = styleSelected.Render(" Email: ") emailLabel = m.styles.Selected.Render(" Email: ")
} }
return header + return header +
usernameLabel + m.userFormInputs[0].View() + "\n" + usernameLabel + m.userFormInputs[0].View() + "\n" +
@@ -801,59 +801,59 @@ func (m Model) renderUserCreate() string {
} }
func (m Model) renderUserNotifyEdit() string { func (m Model) renderUserNotifyEdit() string {
header := fmt.Sprintf("\n Push notifications for %s\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n Push notifications for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
hint := line(styleMuted, hint := line(m.styles.Muted,
" The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+ " The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+
" their incidents then page the server's shared fallback topic, which\n"+ " their incidents then page the server's shared fallback topic, which\n"+
" carries no Acknowledge button.") " carries no Acknowledge button.")
label := styleSelected.Render(" Topic: ") label := m.styles.Selected.Render(" Topic: ")
return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n" return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n"
} }
func (m Model) renderAPIKeyMenu() string { func (m Model) renderAPIKeyMenu() string {
header := fmt.Sprintf("\n API keys for %s\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n API keys for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
warning := line(styleMuted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.") warning := line(m.styles.Muted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.")
options := "\n" + options := "\n" +
styleAccent.Render(" n") + " · create a new API key\n" + m.styles.Accent.Render(" n") + " · create a new API key\n" +
styleAccent.Render(" r") + " · revoke a key by ID\n" m.styles.Accent.Render(" r") + " · revoke a key by ID\n"
return header + "\n" + warning + options return header + "\n" + warning + options
} }
func (m Model) renderAPIKeyCreate() string { func (m Model) renderAPIKeyCreate() string {
header := fmt.Sprintf("\n New API key for %s\n\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n New API key for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
label := styleSelected.Render(" Key name: ") label := m.styles.Selected.Render(" Key name: ")
return header + label + m.apiKeyNameInput.View() + "\n" return header + label + m.apiKeyNameInput.View() + "\n"
} }
func (m Model) renderAPIKeyReveal() string { func (m Model) renderAPIKeyReveal() string {
sep := styleMuted.Render(strings.Repeat("─", m.width)) sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
warn := styleError.Render(" !! COPY NOW — this key will NEVER be shown again !!") warn := m.styles.Error.Render(" !! COPY NOW — this key will NEVER be shown again !!")
nameLine := fmt.Sprintf(" Key name: %s", styleBold.Render(m.revealedAPIKey.Name)) nameLine := fmt.Sprintf(" Key name: %s", m.styles.Bold.Render(m.revealedAPIKey.Name))
idLine := fmt.Sprintf(" Key ID: %s %s", idLine := fmt.Sprintf(" Key ID: %s %s",
styleBold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)), m.styles.Bold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)),
styleMuted.Render("(save this — needed for future revocation)")) m.styles.Muted.Render("(save this — needed for future revocation)"))
keyLine := styleResolved.Render(" " + m.revealedAPIKey.Key) keyLine := m.styles.Resolved.Render(" " + m.revealedAPIKey.Key)
return "\n" + sep + "\n\n" + return "\n" + sep + "\n\n" +
warn + "\n\n" + warn + "\n\n" +
nameLine + "\n" + nameLine + "\n" +
idLine + "\n\n" + idLine + "\n\n" +
styleMuted.Render(" Key value:") + "\n" + m.styles.Muted.Render(" Key value:") + "\n" +
keyLine + "\n\n" + keyLine + "\n\n" +
sep + "\n" sep + "\n"
} }
func (m Model) renderAPIKeyRevokeByID() string { func (m Model) renderAPIKeyRevokeByID() string {
header := fmt.Sprintf("\n Revoke API key for %s\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n Revoke API key for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
hint := line(styleMuted, " Enter the integer key ID (shown when the key was created).") hint := line(m.styles.Muted, " Enter the integer key ID (shown when the key was created).")
label := styleSelected.Render(" Key ID: ") label := m.styles.Selected.Render(" Key ID: ")
return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n" return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n"
} }
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
func divider(title string, width int) string { func divider(s Styles, title string, width int) string {
prefix := "── " prefix := "── "
if title != "" { if title != "" {
prefix += title + " " prefix += title + " "
@@ -862,7 +862,7 @@ func divider(title string, width int) string {
if remaining > 0 { if remaining > 0 {
prefix += strings.Repeat("─", remaining) prefix += strings.Repeat("─", remaining)
} }
return styleMuted.Render(prefix) + "\n" return s.Muted.Render(prefix) + "\n"
} }
func sortedKeys(m map[string]string) []string { func sortedKeys(m map[string]string) []string {
+21 -16
View File
@@ -7,6 +7,7 @@ import (
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@@ -16,6 +17,10 @@ var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func plain(s string) string { return ansi.ReplaceAllString(s, "") } func plain(s string) string { return ansi.ReplaceAllString(s, "") }
// testStyles is the default theme, so assertions here run against what a user
// with no 'theme:' key actually sees.
func testStyles() Styles { return newStyles(theme.GruvboxDark) }
func mustContain(t *testing.T, got string, wants ...string) { func mustContain(t *testing.T, got string, wants ...string) {
t.Helper() t.Helper()
got = plain(got) got = plain(got)
@@ -52,7 +57,7 @@ func TestIncidentDetail_RendersTheWholeStory(t *testing.T) {
{Type: api.EventNote, Username: "admin", Detail: "draining node-2", CreatedAt: now}, {Type: api.EventNote, Username: "admin", Detail: "draining node-2", CreatedAt: now},
} }
out := buildIncidentDetailContent(inc, timeline, -1, 110) out := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 110)
mustContain(t, out, mustContain(t, out,
"DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL", "DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL",
"Assigned:", "admin", "Assigned:", "admin",
@@ -72,7 +77,7 @@ func TestIncidentDetail_ShowsSnooze(t *testing.T) {
} }
// The exact remaining time is humanUntil's business, not this test's — a few // The exact remaining time is humanUntil's business, not this test's — a few
// microseconds of elapsed clock turn "in 2h" into "in 1h 59m". // microseconds of elapsed clock turn "in 2h" into "in 1h 59m".
mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110), mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110),
"TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h") "TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h")
} }
@@ -83,7 +88,7 @@ func TestIncidentDetail_HidesExpiredSnooze(t *testing.T) {
Title: "Noisy", Status: api.StatusTriggered, Title: "Noisy", Status: api.StatusTriggered,
TriggeredAt: time.Now(), SnoozedUntil: &past, TriggeredAt: time.Now(), SnoozedUntil: &past,
} }
if strings.Contains(plain(buildIncidentDetailContent(inc, nil, -1, 110)), "Snoozed:") { if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, -1, 110)), "Snoozed:") {
t.Error("an expired snooze should not be rendered") t.Error("an expired snooze should not be rendered")
} }
} }
@@ -95,18 +100,18 @@ func TestIncidentDetail_ShowsResolutionSource(t *testing.T) {
Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour), Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour),
ResolvedAt: &now, ResolutionSource: &source, ResolvedAt: &now, ResolutionSource: &source,
} }
mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110), mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110),
"RESOLVED", "Resolved:", "manual") "RESOLVED", "Resolved:", "manual")
} }
func TestIncidentDetail_UnassignedAndUnacknowledged(t *testing.T) { func TestIncidentDetail_UnassignedAndUnacknowledged(t *testing.T) {
inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()} inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()}
mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110), "nobody", "not acknowledged") mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "nobody", "not acknowledged")
} }
func TestIncidentDetail_EmptyTimeline(t *testing.T) { func TestIncidentDetail_EmptyTimeline(t *testing.T) {
inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()} inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()}
mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110), "Nothing recorded yet") mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "Nothing recorded yet")
} }
func TestIncidentDetail_MarksSelectedNote(t *testing.T) { func TestIncidentDetail_MarksSelectedNote(t *testing.T) {
@@ -117,7 +122,7 @@ func TestIncidentDetail_MarksSelectedNote(t *testing.T) {
} }
inc := api.Incident{Title: "X", Status: api.StatusTriggered, TriggeredAt: now} inc := api.Incident{Title: "X", Status: api.StatusTriggered, TriggeredAt: now}
out := plain(buildIncidentDetailContent(inc, timeline, 1, 110)) out := plain(buildIncidentDetailContent(testStyles(), inc, timeline, 1, 110))
for _, line := range strings.Split(out, "\n") { for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "alice") && !strings.HasPrefix(line, "> ") { if strings.Contains(line, "alice") && !strings.HasPrefix(line, "> ") {
t.Errorf("expected the selected note marked, got %q", line) t.Errorf("expected the selected note marked, got %q", line)
@@ -207,7 +212,7 @@ func TestIncidentDetail_RendersNotifications(t *testing.T) {
Detail: "reminder: ntfy returned 502", CreatedAt: now}, Detail: "reminder: ntfy returned 502", CreatedAt: now},
} }
got := buildIncidentDetailContent(inc, timeline, -1, 120) got := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 120)
mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed") mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed")
} }
@@ -230,7 +235,7 @@ func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {
Labels: map[string]string{"instance": "node-1", "severity": "critical"}, Labels: map[string]string{"instance": "node-1", "severity": "critical"},
Annotations: map[string]string{"summary": "disk 90%"}, Annotations: map[string]string{"summary": "disk 90%"},
} }
mustContain(t, buildAlertDetailContent(alert, 110), mustContain(t, buildAlertDetailContent(testStyles(), alert, 110),
"DiskFull", "FIRING", "Incident:", "#7", "press i to open it", "DiskFull", "FIRING", "Incident:", "#7", "press i to open it",
"instance", "node-1", "summary", "disk 90%", "instance", "node-1", "summary", "disk 90%",
"Alerts are read-only") "Alerts are read-only")
@@ -238,21 +243,21 @@ func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {
func TestAlertDetail_NoIncident(t *testing.T) { func TestAlertDetail_NoIncident(t *testing.T) {
alert := api.Alert{ID: 3, Name: "Orphan", Status: "resolved", ReceivedAt: time.Now()} alert := api.Alert{ID: 3, Name: "Orphan", Status: "resolved", ReceivedAt: time.Now()}
mustContain(t, buildAlertDetailContent(alert, 110), "Incident:", "none") mustContain(t, buildAlertDetailContent(testStyles(), alert, 110), "Incident:", "none")
} }
func TestAlertDetail_ShowsResolutionSource(t *testing.T) { func TestAlertDetail_ShowsResolutionSource(t *testing.T) {
source := "expiry" source := "expiry"
alert := api.Alert{Name: "Gone", Status: "resolved", ReceivedAt: time.Now(), alert := api.Alert{Name: "Gone", Status: "resolved", ReceivedAt: time.Now(),
ResolutionSource: &source} ResolutionSource: &source}
mustContain(t, buildAlertDetailContent(alert, 110), "RESOLVED", "expiry") mustContain(t, buildAlertDetailContent(testStyles(), alert, 110), "RESOLVED", "expiry")
} }
// Null MTTA means nothing has been acknowledged, which is a different claim // Null MTTA means nothing has been acknowledged, which is a different claim
// from an instant response. // from an instant response.
func TestStats_RendersDashForMissingAverages(t *testing.T) { func TestStats_RendersDashForMissingAverages(t *testing.T) {
stats := &api.IncidentStats{Total: 2, Triggered: 2} stats := &api.IncidentStats{Total: 2, Triggered: 2}
out := buildStatsContent(stats, nil, nil, nil, 110) out := buildStatsContent(testStyles(), stats, nil, nil, nil, 110)
mustContain(t, out, "Incident Response", "Mean time to acknowledge", "—", mustContain(t, out, "Incident Response", "Mean time to acknowledge", "—",
"nothing has been acknowledged or resolved yet") "nothing has been acknowledged or resolved yet")
} }
@@ -260,12 +265,12 @@ func TestStats_RendersDashForMissingAverages(t *testing.T) {
func TestStats_RendersAverages(t *testing.T) { func TestStats_RendersAverages(t *testing.T) {
mtta, mttr := 150.0, 3600.0 mtta, mttr := 150.0, 3600.0
stats := &api.IncidentStats{Total: 3, Resolved: 1, MTTASeconds: &mtta, MTTRSeconds: &mttr} stats := &api.IncidentStats{Total: 3, Resolved: 1, MTTASeconds: &mtta, MTTRSeconds: &mttr}
out := buildStatsContent(stats, []api.TopAlert{{Name: "DiskFull", Count: 4}}, nil, nil, 110) out := buildStatsContent(testStyles(), stats, []api.TopAlert{{Name: "DiskFull", Count: 4}}, nil, nil, 110)
mustContain(t, out, "2m", "1h", "Top Alerts", "DiskFull") mustContain(t, out, "2m", "1h", "Top Alerts", "DiskFull")
} }
func TestStats_HandlesNoIncidentData(t *testing.T) { func TestStats_HandlesNoIncidentData(t *testing.T) {
mustContain(t, buildStatsContent(nil, nil, nil, nil, 110), "Incident Response", "No data") mustContain(t, buildStatsContent(testStyles(), nil, nil, nil, nil, 110), "Incident Response", "No data")
} }
func TestView_TabsAndDashboardRender(t *testing.T) { func TestView_TabsAndDashboardRender(t *testing.T) {
@@ -296,7 +301,7 @@ func TestView_EmptyStates(t *testing.T) {
// The stats page renders inside the normal section chrome now, so it has to // The stats page renders inside the normal section chrome now, so it has to
// survive the real path: a window size message sizes the viewport and fills it. // survive the real path: a window size message sizes the viewport and fills it.
func TestView_StatsSectionRendersInPlace(t *testing.T) { func TestView_StatsSectionRendersInPlace(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.connected = true m.connected = true
m.incidentStats = &api.IncidentStats{Total: 3, Triggered: 1} m.incidentStats = &api.IncidentStats{Total: 3, Triggered: 1}
m.topAlerts = []api.TopAlert{{Name: "DiskFull", Count: 4}} m.topAlerts = []api.TopAlert{{Name: "DiskFull", Count: 4}}
@@ -335,7 +340,7 @@ func TestFooter_IncidentDetailOffersResolveOnlyWhileOpen(t *testing.T) {
} }
func TestView_ZeroWidthRendersNothing(t *testing.T) { func TestView_ZeroWidthRendersNothing(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
if m.View() != "" { if m.View() != "" {
t.Error("expected no output before the first window size message") t.Error("expected no output before the first window size message")
} }
+8 -1
View File
@@ -7,6 +7,7 @@ import (
"git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/config" "git.ryuvia.com/niklas/terdut-tui/internal/config"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"git.ryuvia.com/niklas/terdut-tui/internal/tui" "git.ryuvia.com/niklas/terdut-tui/internal/tui"
"git.ryuvia.com/niklas/terdut-tui/internal/updater" "git.ryuvia.com/niklas/terdut-tui/internal/updater"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -38,8 +39,14 @@ func main() {
os.Exit(1) os.Exit(1)
} }
th, err := theme.Load(cfg.Theme)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
client := api.NewClient(cfg.ServerURL, cfg.APIKey) client := api.NewClient(cfg.ServerURL, cfg.APIKey)
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval) model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th)
p := tea.NewProgram(model, tea.WithAltScreen()) p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {