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 0000000..03b3e10 Binary files /dev/null and b/testdata/snapshots/darwin/uptime_bar-dark.png differ diff --git a/testdata/snapshots/darwin/uptime_bar-light.png b/testdata/snapshots/darwin/uptime_bar-light.png new file mode 100644 index 0000000..d01b930 Binary files /dev/null and b/testdata/snapshots/darwin/uptime_bar-light.png differ 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"}}) + }) + }) +}