4 Commits

Author SHA1 Message Date
niklas 9a510ecc77 Merge pull request 'Colour themes, defaulting to gruvbox dark' (#1) from color-themes into main
CI / test (push) Successful in 2s
Release / test (push) Successful in 2s
Release / binaries (push) Successful in 9s
Reviewed-on: #1
2026-08-20 09:11:13 +00:00
Niklas Ye 4a579bdbc6 Colour themes, defaulting to gruvbox dark
CI / test (pull_request) Successful in 4s
Every colour was a 256-colour ANSI index hardcoded in styles.go, so changing
the palette meant editing the styles themselves. This puts a semantic token set
between the two: styles name roles, a theme supplies the colours.

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

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

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

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

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

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

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

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

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

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