diff --git a/dashboard_filter_bar.go b/dashboard_filter_bar.go new file mode 100644 index 0000000..efd0686 --- /dev/null +++ b/dashboard_filter_bar.go @@ -0,0 +1,95 @@ +package mujica + +import ( + "fmt" + "log/slog" + "slices" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +// DashboardFilters is what a dashboard is filtered by; the zero value filters nothing. +type DashboardFilters struct { + // Range is how far back to look; zero means the caller's default. + Range time.Duration + Env string + Tags []string +} + +// DashboardFilterBarOptions configure a DashboardFilterBar. +type DashboardFilterBarOptions struct { + // Ranges are the offered look-backs; nil uses 15 min, 1 h, 24 h and 7 d. + Ranges []time.Duration + // Envs and Tags are the offered environments and tags. + Envs, Tags []string + Disabled bool +} + +var dashboardRanges = []time.Duration{15 * time.Minute, time.Hour, 24 * time.Hour, 7 * 24 * time.Hour} + +// DashboardFilterBar filters by time range, environment and tags, with a clear button. +func DashboardFilterBar(c *ui.Context, f *DashboardFilters, opts DashboardFilterBarOptions) ChoiceResult { + if f == nil { + panic("mujica: DashboardFilterBar needs filters") + } + ranges := opts.Ranges + if ranges == nil { + ranges = dashboardRanges + } + if f.Env != "" && !slices.Contains(opts.Envs, f.Env) { + panic("mujica: DashboardFilterBar Env is not offered") + } + u := Density(c).Unit() + var r ChoiceResult + r.Element = ui.Row(c).Shrink(0).Wrap().Gap(u * 3).AlignItems(ui.Center).Disabled(opts.Disabled). + Label(msg(c, "dashboard_filter_bar.label")) + locked := r.Element.IsDisabled() + r.Element.Children(func() { + rs := []SelectOption[time.Duration]{{Value: 0, Label: msg(c, "dashboard_filter_bar.default_range")}} + for _, d := range ranges { + rs = append(rs, SelectOption[time.Duration]{Value: d, Label: fmt.Sprintf(msg(c, "dashboard_filter_bar.last"), dashboardRangeLabel(c, d))}) + } + if f.Range != 0 && !slices.Contains(ranges, f.Range) { + panic("mujica: DashboardFilterBar Range is not offered") + } + r.changed = Select(c, &f.Range, rs, SelectOptions{Label: msg(c, "dashboard_filter_bar.range"), Disabled: locked}).Changed() + envs := []SelectOption[string]{{Value: "", Label: msg(c, "dashboard_filter_bar.all_envs")}} + for _, e := range opts.Envs { + envs = append(envs, SelectOption[string]{Value: e, Label: e}) + } + r.changed = Select(c, &f.Env, envs, SelectOptions{Label: msg(c, "dashboard_filter_bar.env"), Disabled: locked}).Changed() || r.changed + if len(opts.Tags) > 0 { + tags := make([]SelectOption[string], len(opts.Tags)) + for i, t := range opts.Tags { + tags[i] = SelectOption[string]{Value: t, Label: t} + } + r.changed = MultiSelect(c, &f.Tags, tags, MultiSelectOptions{Placeholder: msg(c, "dashboard_filter_bar.tags"), + Label: msg(c, "dashboard_filter_bar.tags"), Disabled: locked}).Changed() || r.changed + } + empty := f.Range == 0 && f.Env == "" && len(f.Tags) == 0 + if Button(c, msg(c, "dashboard_filter_bar.clear"), ButtonOptions{Variant: Ghost, Icon: icons.Must("x"), Disabled: locked || empty}).Clicked() && !locked { + *f, r.changed = DashboardFilters{}, true + } + }) + if r.changed { + slog.Debug("mujica: dashboard_filter_bar change", "range", f.Range, "env", f.Env, "tags", len(f.Tags)) + } + return r +} + +// dashboardRangeLabel names a look-back, as "15 min" or "7 d". +func dashboardRangeLabel(c *ui.Context, d time.Duration) string { + switch { + case d <= 0: + case d%(24*time.Hour) == 0: + return fmt.Sprintf(msg(c, "dashboard_filter_bar.days"), int(d/(24*time.Hour))) + case d%time.Hour == 0: + return fmt.Sprintf(msg(c, "dashboard_filter_bar.hours"), int(d/time.Hour)) + case d%time.Minute == 0: + return fmt.Sprintf(msg(c, "dashboard_filter_bar.minutes"), int(d/time.Minute)) + } + panic("mujica: DashboardFilterBar ranges must be positive whole minutes") +} diff --git a/dashboard_filter_bar_test.go b/dashboard_filter_bar_test.go new file mode 100644 index 0000000..db17674 --- /dev/null +++ b/dashboard_filter_bar_test.go @@ -0,0 +1,73 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func filterTester(f *DashboardFilters, opts DashboardFilterBarOptions, changes *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(8).Children(func() { + if DashboardFilterBar(c, f, opts).Changed() { + *changes++ + } + }) + }, 900, 420) +} + +var filterOpts = DashboardFilterBarOptions{Envs: []string{"production", "staging"}, Tags: []string{"api", "web", "db"}} + +func TestDashboardFilterBarChooseAndClear(t *testing.T) { + var f DashboardFilters + n := 0 + tt := filterTester(&f, filterOpts, &n) + _ = tt.Click("Time range") + _ = tt.Click("Last 1 d") + if f.Range != 24*time.Hour || n != 1 { + t.Fatalf("range %v changes %d", f.Range, n) + } + _ = tt.Click("Environment") + _ = tt.Click("staging") + if f.Env != "staging" { + t.Fatalf("env %q", f.Env) + } + _ = tt.Click("Tags") + _ = tt.Click("db") + if len(f.Tags) != 1 || f.Tags[0] != "db" { + t.Fatalf("tags %v", f.Tags) + } + tt.Key(0, ui.KeyEscape) + _ = tt.Click("Clear filters") + if f.Range != 0 || f.Env != "" || len(f.Tags) != 0 || !tt.HasText("All environments") { + t.Fatalf("clear: %+v", f) + } +} + +func TestDashboardFilterBarDisabledAndRules(t *testing.T) { + f := DashboardFilters{Env: "production"} + n := 0 + tt := filterTester(&f, DashboardFilterBarOptions{Envs: filterOpts.Envs, Disabled: true}, &n) + _ = tt.Click("Clear filters") + _ = tt.Click("Time range") + _ = tt.Click("Last 1 h") + if n != 0 || f.Env != "production" || f.Range != 0 { + t.Fatalf("disabled changed: %+v", f) + } + defer func() { + if recover() == nil { + t.Fatal("unoffered env did not panic") + } + }() + bad := DashboardFilters{Env: "moon"} + filterTester(&bad, filterOpts, &n) +} + +func TestDashboardFilterBarSnapshot(t *testing.T) { + snapshot(t, "dashboard_filter_bar", 860, 90, func(c *ui.Context) { + f := DashboardFilters{Range: time.Hour, Env: "production", Tags: []string{"api"}} + ui.Column(c).Padding(16).Children(func() { DashboardFilterBar(c, &f, filterOpts) }) + }) +} diff --git a/messages_devtools.go b/messages_devtools.go index 9e2a822..8829dd6 100644 --- a/messages_devtools.go +++ b/messages_devtools.go @@ -54,6 +54,18 @@ func init() { "refresh_interval_selector.minutes": {"Every %d min", "每 %d 分钟"}, "refresh_interval_selector.label": {"Refresh interval", "刷新间隔"}, + "dashboard_filter_bar.label": {"Dashboard filters", "仪表盘过滤"}, + "dashboard_filter_bar.range": {"Time range", "时间范围"}, + "dashboard_filter_bar.default_range": {"Default range", "默认范围"}, + "dashboard_filter_bar.last": {"Last %s", "最近 %s"}, + "dashboard_filter_bar.minutes": {"%d min", "%d 分钟"}, + "dashboard_filter_bar.hours": {"%d h", "%d 小时"}, + "dashboard_filter_bar.days": {"%d d", "%d 天"}, + "dashboard_filter_bar.env": {"Environment", "环境"}, + "dashboard_filter_bar.all_envs": {"All environments", "全部环境"}, + "dashboard_filter_bar.tags": {"Tags", "标签"}, + "dashboard_filter_bar.clear": {"Clear filters", "清除过滤"}, + "alert_list.empty": {"No alerts", "没有告警"}, }) } diff --git a/refresh_interval_selector.go b/refresh_interval_selector.go index f9a39e3..5839c59 100644 --- a/refresh_interval_selector.go +++ b/refresh_interval_selector.go @@ -44,8 +44,8 @@ func RefreshIntervalSelector(c *ui.Context, interval *time.Duration, opts Refres options := []SelectOption[time.Duration]{{Value: 0, Label: msg(c, "refresh_interval_selector.off")}} known := *interval == 0 for _, d := range choices { - if d < time.Second { - panic("mujica: RefreshIntervalSelector intervals must be at least a second") + if d < time.Second || d%time.Second != 0 { + panic("mujica: RefreshIntervalSelector intervals must be whole seconds") } known = known || d == *interval label := fmt.Sprintf(msg(c, "refresh_interval_selector.seconds"), int(d/time.Second)) diff --git a/testdata/snapshots/darwin/dashboard_filter_bar-dark.png b/testdata/snapshots/darwin/dashboard_filter_bar-dark.png new file mode 100644 index 0000000..8e96c88 Binary files /dev/null and b/testdata/snapshots/darwin/dashboard_filter_bar-dark.png differ diff --git a/testdata/snapshots/darwin/dashboard_filter_bar-light.png b/testdata/snapshots/darwin/dashboard_filter_bar-light.png new file mode 100644 index 0000000..be55904 Binary files /dev/null and b/testdata/snapshots/darwin/dashboard_filter_bar-light.png differ