diff --git a/alert_list.go b/alert_list.go new file mode 100644 index 0000000..97c92d6 --- /dev/null +++ b/alert_list.go @@ -0,0 +1,128 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// AlertLevel is an alert's level. The zero value is AlertInfo. +type AlertLevel int + +const ( + AlertInfo AlertLevel = iota + AlertWarning + AlertCritical +) + +// AlertItem is one firing alert; the caller owns it. +type AlertItem struct { + ID string + Title string + Level AlertLevel + Source string + At time.Time + Acked bool + Silence time.Time // silenced until; zero is not silenced +} + +// AlertListOptions configure an AlertList. +type AlertListOptions struct { + // MaxHeight caps the scrolling list; zero means 360. + MaxHeight float32 + Disabled bool +} + +// AlertListResult reports this frame's request. +type AlertListResult struct { + Element *ui.Element + // Ack and Silence are the IDs to acknowledge or silence for an hour, else "". + Ack, Silence string +} + +// AlertList lists alerts by level, source and time with acknowledge and silence. +func AlertList(c *ui.Context, alerts []AlertItem, opts AlertListOptions) AlertListResult { + if opts.MaxHeight < 0 { + panic("mujica: AlertList MaxHeight is negative") + } + seen := map[string]bool{} + for i, a := range alerts { + if a.ID == "" || a.Title == "" || seen[a.ID] || a.Level < AlertInfo || a.Level > AlertCritical { + panic(fmt.Sprintf("mujica: AlertList alert %d needs a unique ID, a Title and a known Level", i)) + } + seen[a.ID] = true + } + maxH := opts.MaxHeight + if maxH == 0 { + maxH = 360 + } + k := Tokens(c) + var r AlertListResult + r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled) + locked := r.Element.IsDisabled() + r.Element.Children(func() { + if len(alerts) == 0 { + agentCaption(c, msg(c, "alert_list.empty")) + return + } + agentRows(c, len(alerts), func(i int) { + ack, silence := alertRow(c, alerts[i], locked) + if ack && !locked { + r.Ack = alerts[i].ID + } + if silence && !locked { + r.Silence = alerts[i].ID + } + }).Shrink(1).MaxHeight(maxH).Background(k.Surface).Border(theme.BorderWidth, k.Border). + Radius(theme.CardRadius).Dividers(theme.BorderWidth, k.Border) + }) + if r.Ack != "" || r.Silence != "" { + slog.Debug("mujica: alert_list action", "ack", r.Ack, "silence", r.Silence) + } + return r +} + +// alertRow shows one alert and reports its acknowledge and silence presses. +func alertRow(c *ui.Context, a AlertItem, locked bool) (ack, silence bool) { + k := Tokens(c) + d := Density(c) + u := d.Unit() + sev := [...]Severity{SeverityInfo, SeverityWarning, SeverityError}[a.Level] + word := msg(c, [...]string{"alert_list.info", "alert_list.warning", "alert_list.critical"}[a.Level]) + silenced := a.Silence.After(c.Now()) + row := ui.Row(c).Key(a.ID).Gap(u * 3).AlignItems(ui.Center).MinHeight(d.ControlHeight() + u*2).Padding(u*2, u*3). + Label(word + ", " + a.Title) + if tone, _ := severityLook(k, sev); a.Level == AlertCritical && !a.Acked { + row.DrawOver(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y, W: 3, H: r.H}, tone, 0) }) + } + row.Children(func() { + ui.Box(c).Width(96).Shrink(0).Children(func() { devtoolsSeverity(c, sev, word) }) + ui.Column(c).Grow(1).Shrink(1).Gap(u / 2).Children(func() { + t := ui.Text(c, a.Title).FontWeight(600) + if a.Acked || silenced { + t.TextColor(k.TextMuted) + } + line := a.Source + if !a.At.IsZero() { + line += " · " + devtoolsAgo(c, a.At) + } + agentCaption(c, line) + }) + switch { + case silenced: + agentCaption(c, fmt.Sprintf(msg(c, "alert_list.silenced"), a.Silence.Format("15:04"))) + case a.Acked: + agentCaption(c, msg(c, "alert_list.acked")) + default: + ack = Button(c, msg(c, "alert_list.ack"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked() + } + if !silenced { + silence = agentToggle(c, msg(c, "alert_list.silence"), "bell", locked) + } + }) + return ack, silence +} diff --git a/alert_list_test.go b/alert_list_test.go new file mode 100644 index 0000000..156f7e0 --- /dev/null +++ b/alert_list_test.go @@ -0,0 +1,83 @@ +package mujica + +import ( + "fmt" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func alertSample() []AlertItem { + now := time.Now() + return []AlertItem{ + {ID: "a", Title: "Disk 92% full", Level: AlertCritical, Source: "db-1", At: now.Add(-5 * time.Minute)}, + {ID: "b", Title: "Latency above 500 ms", Level: AlertWarning, Source: "api", At: now.Add(-time.Hour), Acked: true}, + {ID: "c", Title: "Deploy finished", Level: AlertInfo, Source: "ci", Silence: now.Add(time.Hour)}, + } +} + +func alertTester(alerts []AlertItem, opts AlertListOptions, got *[]AlertListResult) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := AlertList(c, alerts, opts) + if r.Ack != "" || r.Silence != "" { + *got = append(*got, r) + } + }, 640, 400) +} + +func TestAlertListLevelsAndActions(t *testing.T) { + var got []AlertListResult + tt := alertTester(alertSample(), AlertListOptions{}, &got) + for _, s := range []string{"Critical", "Warning", "Info", "db-1 · 5 m 00 s ago", "Acknowledged"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + _ = tt.Click("Acknowledge") + silences, _ := tt.Find("Silence 1 h") + tt.ClickAt(silences.X+4, silences.Y+4) + if len(got) != 2 || got[0].Ack != "a" || got[1].Silence != "a" { + t.Fatalf("actions %+v", got) + } + tt = alertTester(alertSample(), AlertListOptions{}, &got) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if len(got) != 3 || got[2].Ack != "a" { + t.Fatalf("keyboard ack: %+v", got) + } +} + +func TestAlertListDisabledEmptyMany(t *testing.T) { + var got []AlertListResult + tt := alertTester(alertSample(), AlertListOptions{Disabled: true}, &got) + _ = tt.Click("Acknowledge") + _ = tt.Click("Silence 1 h") + if len(got) != 0 { + t.Fatalf("disabled list acted: %+v", got) + } + tt = alertTester(nil, AlertListOptions{}, &got) + if !tt.HasText("No alerts") { + t.Fatal("empty text missing") + } + many := make([]AlertItem, 100000) + for i := range many { + many[i] = AlertItem{ID: fmt.Sprint(i), Title: fmt.Sprintf("alert %d", i)} + } + tt = alertTester(many, AlertListOptions{}, &got) + if n := len(tt.Texts()); n > 300 { + t.Fatalf("%d texts for 100000 alerts", n) + } +} + +func TestAlertListSnapshot(t *testing.T) { + snapshot(t, "alert_list", 640, 260, func(c *ui.Context) { + alerts := alertSample() + for i := range alerts { + alerts[i].At = time.Time{} + } + alerts[2].Silence = time.Time{} + ui.Column(c).Padding(16).Children(func() { AlertList(c, alerts, AlertListOptions{}) }) + }) +} diff --git a/devtools_parts.go b/devtools_parts.go new file mode 100644 index 0000000..845590d --- /dev/null +++ b/devtools_parts.go @@ -0,0 +1,30 @@ +package mujica + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// devtoolsAgo formats how long before now t was, as "5 m ago". +func devtoolsAgo(c *ui.Context, t time.Time) string { + d := max(c.Now().Sub(t), 0).Truncate(time.Second) + return fmt.Sprintf(msg(c, "devtools.ago"), agentDuration(d)) +} + +// devtoolsSeverity shows a severity by icon and word, never color alone. +func devtoolsSeverity(c *ui.Context, s Severity, word string) { + tone, icon := severityLook(Tokens(c), s) + ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() { + ui.Icon(c, icon).Size(14, 14).TextColor(tone) + ui.Text(c, word).TextColor(tone).FontSize(FontSize(c, theme.CaptionSize+1)).FontWeight(600).SingleLine() + }) +} + +// devtoolsTitle is a serif panel title. +func devtoolsTitle(c *ui.Context, s string) *ui.Element { + return ui.Text(c, s).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600) +} diff --git a/messages_devtools.go b/messages_devtools.go index 9276a76..5d9d5a6 100644 --- a/messages_devtools.go +++ b/messages_devtools.go @@ -7,5 +7,17 @@ func init() { "key_value_input.row": {"Row %d", "第 %d 行"}, "key_value_input.remove": {"Remove row %d", "删除第 %d 行"}, "key_value_input.add": {"Add row", "添加一行"}, + + "stat_card.change": {"Change %s", "变化 %s"}, + "devtools.ago": {"%s ago", "%s 前"}, + + "alert_list.info": {"Info", "提示"}, + "alert_list.warning": {"Warning", "警告"}, + "alert_list.critical": {"Critical", "严重"}, + "alert_list.ack": {"Acknowledge", "确认"}, + "alert_list.acked": {"Acknowledged", "已确认"}, + "alert_list.silence": {"Silence 1 h", "静默 1 小时"}, + "alert_list.silenced": {"Silenced until %s", "静默至 %s"}, + "alert_list.empty": {"No alerts", "没有告警"}, }) } diff --git a/stat_card.go b/stat_card.go new file mode 100644 index 0000000..1a5bc7c --- /dev/null +++ b/stat_card.go @@ -0,0 +1,106 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// StatCardOptions configure a StatCard. Label is required. +type StatCardOptions struct { + Label string + // Change is the relative change, 0.12 for +12%; HasChange shows it. + Change float64 + HasChange bool + // LowerIsBetter colors a fall as good, as for latency. + LowerIsBetter bool + // Trend draws a sparkline; nil hides it. + Trend []float64 + Status DataStatus + Error string +} + +// StatCardResult reports this frame's events. +type StatCardResult struct { + Element *ui.Element + Retry bool +} + +// StatCard shows a statistic, its change and trend, or loading and error states at the same size. +func StatCard(c *ui.Context, value string, opts StatCardOptions) StatCardResult { + if opts.Label == "" { + panic("mujica: StatCard needs a Label") + } + if math.IsNaN(opts.Change) || math.IsInf(opts.Change, 0) { + panic("mujica: StatCard Change must be finite") + } + if opts.Status != DataReady && opts.Status != DataLoading && opts.Status != DataFailed { + panic(fmt.Sprintf("mujica: StatCard Status %d is not ready, loading or failed", int(opts.Status))) + } + k := Tokens(c) + u := Density(c).Unit() + var r StatCardResult + r.Element = agentCard(c).Height(116).MinWidth(180).Label(opts.Label) + r.Element.Children(func() { + agentCaption(c, opts.Label) + switch opts.Status { + case DataLoading: + ui.Column(c).Grow(1).Justify(ui.Center).Children(func() { + Spinner(c, SpinnerOptions{Label: msg(c, "list.loading"), ShowLabel: true}) + }) + return + case DataFailed: + ui.Column(c).Grow(1).Justify(ui.Center).Gap(u).Children(func() { + ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).TextColor(k.Danger).Children(func() { + ui.Icon(c, icons.Must("circle-alert")).Size(16, 16) + ui.Text(c, dataError(c, opts.Error)).Role(ui.RoleStatus).FontSize(FontSize(c, theme.CaptionSize+1)) + }) + ui.Row(c).Children(func() { r.Retry = agentToggle(c, msg(c, "list.retry"), "refresh-cw", false) }) + }) + return + } + ui.Text(c, value).Font(theme.SerifFont).FontSize(FontSize(c, theme.H2Size)).FontWeight(600).FontFeatures("tnum").SingleLine() + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + if opts.HasChange { + statChange(c, opts.Change, opts.LowerIsBetter) + } + ui.Box(c).Grow(1) + if len(opts.Trend) > 0 { + Sparkline(c, opts.Trend, SparklineOptions{Frame: ChartFrame{Label: opts.Label}, Width: 88}) + } + }) + }) + if r.Retry { + slog.Debug("mujica: stat_card retry", "label", opts.Label) + } + return r +} + +// statChange shows a signed percentage with an arrow, toned good or bad. +func statChange(c *ui.Context, change float64, lowerIsBetter bool) { + k := Tokens(c) + icon, tone, sign := "minus", k.TextMuted, "" + switch { + case change > 0: + icon, tone, sign = "arrow-up", k.Success, "+" + case change < 0: + icon, tone = "arrow-down", k.Danger + } + if lowerIsBetter && change != 0 { + if tone == k.Success { + tone = k.Danger + } else { + tone = k.Success + } + } + text := fmt.Sprintf("%s%.1f%%", sign, change*100) + ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Label(fmt.Sprintf(msg(c, "stat_card.change"), text)).Children(func() { + ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(tone) + ui.Text(c, text).TextColor(tone).FontSize(FontSize(c, theme.CaptionSize+1)).FontFeatures("tnum") + }) +} diff --git a/stat_card_test.go b/stat_card_test.go new file mode 100644 index 0000000..d843db7 --- /dev/null +++ b/stat_card_test.go @@ -0,0 +1,73 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestStatCardValueChangeTrend(t *testing.T) { + opts := StatCardOptions{Label: "Requests", Change: 0.124, HasChange: true, Trend: []float64{3, 5, 4, 8, 9}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + StatCard(c, "12,480", opts) + }, 400, 200) + for _, s := range []string{"Requests", "12,480", "+12.4%"} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + opts.Change = -0.05 + tt.Frame() + if !tt.HasText("-5.0%") { + t.Fatalf("fall: %q", tt.Texts()) + } + opts.HasChange = false + tt.Frame() + if tt.HasText("-5.0%") { + t.Fatal("change shown without HasChange") + } +} + +func TestStatCardLoadingErrorSameSize(t *testing.T) { + status, retries := DataReady, 0 + var h []float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).AlignItems(ui.Start).Children(func() { + r := StatCard(c, "42 ms", StatCardOptions{Label: "Latency", Status: status, Error: "timeout"}) + h = append(h, r.Element.Bounds().H) + if r.Retry { + retries++ + } + }) + }, 400, 300) + tt.Frame() + ready := h[len(h)-1] + status = DataLoading + tt.Frame() + tt.Frame() + if !tt.HasText("Loading") || h[len(h)-1] != ready { + t.Fatalf("loading: %q height %v vs %v", tt.Texts(), h[len(h)-1], ready) + } + status = DataFailed + tt.Frame() + tt.Frame() + if !tt.HasText("timeout") || h[len(h)-1] != ready { + t.Fatalf("failed: %q height %v vs %v", tt.Texts(), h[len(h)-1], ready) + } + _ = tt.Click("Retry") + if retries != 1 { + t.Fatalf("retries %d", retries) + } +} + +func TestStatCardSnapshot(t *testing.T) { + snapshot(t, "stat_card", 640, 150, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(12).Children(func() { + StatCard(c, "12,480", StatCardOptions{Label: "Requests", Change: 0.124, HasChange: true, Trend: []float64{3, 5, 4, 8, 6, 9}}) + StatCard(c, "182 ms", StatCardOptions{Label: "p95 latency", Change: 0.08, HasChange: true, LowerIsBetter: true, Trend: []float64{120, 140, 130, 182}}) + StatCard(c, "", StatCardOptions{Label: "Errors", Status: DataFailed, Error: "Source unreachable"}) + }) + }) +} diff --git a/testdata/snapshots/darwin/alert_list-dark.png b/testdata/snapshots/darwin/alert_list-dark.png new file mode 100644 index 0000000..ee48696 Binary files /dev/null and b/testdata/snapshots/darwin/alert_list-dark.png differ diff --git a/testdata/snapshots/darwin/alert_list-light.png b/testdata/snapshots/darwin/alert_list-light.png new file mode 100644 index 0000000..ab19dc5 Binary files /dev/null and b/testdata/snapshots/darwin/alert_list-light.png differ diff --git a/testdata/snapshots/darwin/stat_card-dark.png b/testdata/snapshots/darwin/stat_card-dark.png new file mode 100644 index 0000000..197be7a Binary files /dev/null and b/testdata/snapshots/darwin/stat_card-dark.png differ diff --git a/testdata/snapshots/darwin/stat_card-light.png b/testdata/snapshots/darwin/stat_card-light.png new file mode 100644 index 0000000..3cc3a9c Binary files /dev/null and b/testdata/snapshots/darwin/stat_card-light.png differ