wip-devtools
This commit is contained in:
10 files changed
+432
No files matched your search
+128
@@ -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
|
||||
}
|
||||
@@ -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{}) })
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Reference in new issue
Block a user