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:
+21
-19
@@ -5,12 +5,12 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// ── Enums ──────────────────────────────────────────────────────────────────
|
||||
@@ -244,12 +244,14 @@ type Model struct {
|
||||
apiKeyRevokeInput textinput.Model
|
||||
revealedAPIKey api.APIKey
|
||||
|
||||
help help.Model
|
||||
keys keyMap
|
||||
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{
|
||||
¬eIn, &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,16 +356,6 @@ 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.
|
||||
//
|
||||
@@ -433,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 {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
+142
-66
@@ -4,96 +4,172 @@ import (
|
||||
"strings"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -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 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
|
||||
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.
|
||||
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
|
||||
@@ -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
|
||||
// events: size first, data second, keys third.
|
||||
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.activeSection = sectionSchedule
|
||||
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
+118
-118
@@ -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 {
|
||||
|
||||
+21
-16
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
|
||||
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, "") }
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user