wip-devtools

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:45:49 -04:00
1 parent 412ed0605e
commit 92c42620f4
10 files changed
+432

No files matched your search

+128
View File
@@ -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
}
+83
View File
@@ -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{}) })
})
}
+30
View File
@@ -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)
}
+12
View File
@@ -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", "没有告警"},
})
}
+106
View File
@@ -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")
})
}
+73
View File
@@ -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"})
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB