From 3c8d15b97de1adff5948b8db683087db59b24f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:53:36 -0400 Subject: [PATCH] wip-devtools --- dashboard_filter_bar.go | 95 ++++++++++++++++++ dashboard_filter_bar_test.go | 73 ++++++++++++++ messages_devtools.go | 12 +++ refresh_interval_selector.go | 4 +- .../darwin/dashboard_filter_bar-dark.png | Bin 0 -> 6339 bytes .../darwin/dashboard_filter_bar-light.png | Bin 0 -> 6241 bytes 6 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 dashboard_filter_bar.go create mode 100644 dashboard_filter_bar_test.go create mode 100644 testdata/snapshots/darwin/dashboard_filter_bar-dark.png create mode 100644 testdata/snapshots/darwin/dashboard_filter_bar-light.png 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 0000000000000000000000000000000000000000..8e96c8813a748e7fbe5ac16c2006f6cfc0ea0418 GIT binary patch literal 6339 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..be559045800d15662b63a63a11f25fe0ed6875dd GIT binary patch literal 6241 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 Ku~NZ0=sy6+%WpgY literal 0 HcmV?d00001