From 644f01b6bbbf17bae98946b0c4457f3564b51148 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:48:37 -0400 Subject: [PATCH] wip-devtools --- messages_devtools.go | 5 + testdata/snapshots/darwin/uptime_bar-dark.png | Bin 0 -> 5669 bytes .../snapshots/darwin/uptime_bar-light.png | Bin 0 -> 5438 bytes uptime_bar.go | 96 ++++++++++++++++++ uptime_bar_test.go | 87 ++++++++++++++++ 5 files changed, 188 insertions(+) create mode 100644 testdata/snapshots/darwin/uptime_bar-dark.png create mode 100644 testdata/snapshots/darwin/uptime_bar-light.png create mode 100644 uptime_bar.go create mode 100644 uptime_bar_test.go diff --git a/messages_devtools.go b/messages_devtools.go index a1373e8..bd12df1 100644 --- a/messages_devtools.go +++ b/messages_devtools.go @@ -38,6 +38,11 @@ func init() { "service_status.counts": {"%d up · %d degraded · %d down", "%d 正常 · %d 降级 · %d 中断"}, "service_status.empty": {"No services", "没有服务"}, + "uptime_bar.ok": {"Operational", "正常"}, + "uptime_bar.degraded": {"Degraded", "降级"}, + "uptime_bar.down": {"Outage", "中断"}, + "uptime_bar.nodata": {"No data", "无数据"}, + "alert_list.empty": {"No alerts", "没有告警"}, }) } diff --git a/testdata/snapshots/darwin/uptime_bar-dark.png b/testdata/snapshots/darwin/uptime_bar-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..03b3e10231577be93183a40fbfacafad8687113d GIT binary patch literal 5669 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 V@8TU?!ppw^RV6LO3b19^-vC>vBG~`{ literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/uptime_bar-light.png b/testdata/snapshots/darwin/uptime_bar-light.png new file mode 100644 index 0000000000000000000000000000000000000000..d01b9308a9416c0212d95864769b8fc210a3fc1c GIT binary patch literal 5438 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 literal 0 HcmV?d00001 diff --git a/uptime_bar.go b/uptime_bar.go new file mode 100644 index 0000000..9c1fe37 --- /dev/null +++ b/uptime_bar.go @@ -0,0 +1,96 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" +) + +// UptimeDay is one day's availability, 0 to 1; NaN means no data. +type UptimeDay struct { + Date Date + Uptime float64 + // Note says what happened, as "12 min outage". + Note string +} + +// UptimeBarOptions configure an UptimeBar; Frame.Height 0 means 36. +type UptimeBarOptions struct { + Frame ChartFrame + // Degraded and Down are the uptime limits below which a day is marked; zero means 0.999 and 0.95. + Degraded, Down float64 +} + +// UptimeBar draws one bar per day by availability; hover or arrows read a day's detail. +func UptimeBar(c *ui.Context, days []UptimeDay, opts UptimeBarOptions) ChartResult { + deg, down := opts.Degraded, opts.Down + if deg == 0 { + deg = 0.999 + } + if down == 0 { + down = 0.95 + } + if !(down < deg && deg <= 1 && down > 0) { + panic("mujica: UptimeBar needs 0 < Down < Degraded <= 1") + } + for i, d := range days { + if !math.IsNaN(d.Uptime) && (d.Uptime < 0 || d.Uptime > 1) { + panic(fmt.Sprintf("mujica: UptimeBar day %d uptime %v outside 0..1", i, d.Uptime)) + } + } + if opts.Frame.Height == 0 { + opts.Frame.Height = 36 + } + k := Tokens(c) + ch := newChart(c, opts.Frame) + n := len(days) + band := func(r ui.Rect) internal.Band { return internal.Band{N: n, R0: r.X, R1: r.X + r.W, Pad: 0.25} } + level := func(v float64) int { // 0 ok, 1 degraded, 2 down, 3 no data + switch { + case math.IsNaN(v): + return 3 + case v < down: + return 2 + case v < deg: + return 1 + } + return 0 + } + words := [...]string{msg(c, "uptime_bar.ok"), msg(c, "uptime_bar.degraded"), msg(c, "uptime_bar.down"), msg(c, "uptime_bar.nodata")} + res, _ := ch.build(chartSpec{ + n: n, + draw: func(p *ui.Painter, r ui.Rect, active int) { + b := band(r) + for i, d := range days { + x, w := internal.Snap(p, b.Start(i)), max(b.Width(), 1) + lv := level(d.Uptime) + col := [...]ui.Color{k.Success, k.Warning, k.Danger, k.Border}[lv] + // Shorter bars mark bad days, so color is not the only cue. + h := r.H * [...]float32{1, 0.75, 0.5, 1}[lv] + p.Fill(ui.Rect{X: x, Y: r.Y + r.H - h, W: w, H: h}, col, 1) + if i == active { + p.Stroke(ui.Rect{X: x - 2, Y: r.Y - 2, W: w + 4, H: r.H + 4}, k.Focus, 2, 1.5) + } + } + }, + pick: func(r ui.Rect, x, _ float32) int { + b := band(r) + return max(0, min(n-1, int((x-r.X)/b.Step()))) + }, + readout: func(i int) string { + d := days[i] + s := fmt.Sprintf("%s · %s", d.Date.String(), words[level(d.Uptime)]) + if !math.IsNaN(d.Uptime) { + s += fmt.Sprintf(" · %.2f%%", d.Uptime*100) + } + if d.Note != "" { + s += " · " + d.Note + } + return s + }, + }) + return res +} diff --git a/uptime_bar_test.go b/uptime_bar_test.go new file mode 100644 index 0000000..fdbff9f --- /dev/null +++ b/uptime_bar_test.go @@ -0,0 +1,87 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +func uptimeSample(n int) []UptimeDay { + days := make([]UptimeDay, n) + start := Date{Year: 2026, Month: 9, Day: 1} + for i := range days { + days[i] = UptimeDay{Date: start.AddDays(i), Uptime: 1} + } + days[3] = UptimeDay{Date: start.AddDays(3), Uptime: 0.9975, Note: "slow search"} + days[7] = UptimeDay{Date: start.AddDays(7), Uptime: 0.91, Note: "2 h outage"} + days[9].Uptime = math.NaN() + return days +} + +func uptimeTester(days []UptimeDay, frame ChartFrame, points *[]int) *ui.Tester { + if frame.Label == "" { + frame.Label = "API uptime" + } + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Padding(10).Children(func() { + r := UptimeBar(c, days, UptimeBarOptions{Frame: frame}) + *points = append(*points, r.Point) + }) + }, 520, 140) +} + +func TestUptimeBarKeyboardReadout(t *testing.T) { + var pts []int + tt := uptimeTester(uptimeSample(30), ChartFrame{}, &pts) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + for range 7 { + tt.Key(0, ui.KeyRight) + } + if !tt.HasText("2026-09-08 · Outage · 91.00% · 2 h outage") { + t.Fatalf("readout: %q", tt.Texts()) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if !tt.HasText("2026-09-10 · No data") { + t.Fatalf("no data readout: %q", tt.Texts()) + } +} + +func TestUptimeBarHover(t *testing.T) { + var pts []int + tt := uptimeTester(uptimeSample(30), ChartFrame{}, &pts) + // The 4th of 30 bars across 500 DIPs starting at x = 10. + tt.Move(10+500*3.5/30, 30) + if pts[len(pts)-1] != 3 || !tt.HasText("2026-09-04 · Degraded · 99.75% · slow search") { + t.Fatalf("hover point %d: %q", pts[len(pts)-1], tt.Texts()) + } +} + +func TestUptimeBarStatesAndRules(t *testing.T) { + var pts []int + tt := uptimeTester(nil, ChartFrame{}, &pts) + if !tt.HasText("No data") { + t.Fatalf("empty: %q", tt.Texts()) + } + tt = uptimeTester(uptimeSample(12), ChartFrame{Status: DataLoading}, &pts) + if !tt.HasText("Loading") { + t.Fatalf("loading: %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("uptime above 1 did not panic") + } + }() + uptimeTester([]UptimeDay{{Uptime: 1.2}}, ChartFrame{}, &pts) +} + +func TestUptimeBarSnapshot(t *testing.T) { + snapshot(t, "uptime_bar", 520, 110, func(c *ui.Context) { + ui.Column(c).Padding(10).Children(func() { + UptimeBar(c, uptimeSample(45), UptimeBarOptions{Frame: ChartFrame{Label: "API uptime"}}) + }) + }) +}