wip-devtools
This commit is contained in:
5 files changed
+262
No files matched your search
@@ -72,6 +72,12 @@ func init() {
|
||||
"log_stream.waiting": {"%d new lines", "%d 条新日志"},
|
||||
"log_stream.empty": {"No log lines", "没有日志"},
|
||||
|
||||
"trace_waterfall.label": {"Trace", "调用链"},
|
||||
"trace_waterfall.empty": {"No spans", "没有跨度"},
|
||||
"trace_waterfall.summary": {"%d spans · %s", "%d 个跨度 · %s"},
|
||||
"trace_waterfall.detail": {"%s · starts at %s · takes %s", "%s · 起于 %s · 耗时 %s"},
|
||||
"trace_waterfall.error": {"Error", "错误"},
|
||||
|
||||
"alert_list.empty": {"No alerts", "没有告警"},
|
||||
})
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,178 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// TraceSpan is one timed operation of a request; Parent is "" for the root.
|
||||
type TraceSpan struct {
|
||||
ID, Parent string
|
||||
Name string
|
||||
Service string
|
||||
// Start is the offset from the trace start.
|
||||
Start, Duration time.Duration
|
||||
Error bool
|
||||
}
|
||||
|
||||
// TraceWaterfallOptions configure a TraceWaterfall.
|
||||
type TraceWaterfallOptions struct {
|
||||
// Height of the span list; zero means 320.
|
||||
Height float32
|
||||
}
|
||||
|
||||
// traceRow is a span in tree order with its depth.
|
||||
type traceRow struct {
|
||||
span int
|
||||
depth int
|
||||
}
|
||||
|
||||
type traceState struct {
|
||||
key string
|
||||
rows []traceRow
|
||||
total time.Duration
|
||||
selected string
|
||||
services map[string]int // palette slot of each service
|
||||
}
|
||||
|
||||
// TraceWaterfall lays spans out by parent and time; choosing a span shows its detail.
|
||||
func TraceWaterfall(c *ui.Context, spans []TraceSpan, opts TraceWaterfallOptions) ChoiceResult {
|
||||
if opts.Height < 0 {
|
||||
panic("mujica: TraceWaterfall Height is negative")
|
||||
}
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 320
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
var r ChoiceResult
|
||||
r.Element = codePanel(c).Label(msg(c, "trace_waterfall.label"))
|
||||
st := ui.Local(r.Element, "mujica.trace", func() traceState { return traceState{} })
|
||||
traceLayout(st, spans)
|
||||
r.Element.Children(func() {
|
||||
if len(st.rows) == 0 {
|
||||
codeCaption(c, msg(c, "trace_waterfall.empty")).Padding(u * 4)
|
||||
return
|
||||
}
|
||||
codeHeader(c, func() {
|
||||
codeCaption(c, fmt.Sprintf(msg(c, "trace_waterfall.summary"), len(spans), agentDuration(st.total))).Grow(1)
|
||||
})
|
||||
agentRows(c, len(st.rows), func(i int) {
|
||||
row := st.rows[i]
|
||||
s := spans[row.span]
|
||||
if traceSpanRow(c, s, row.depth, st.total, st.services[s.Service], s.ID == st.selected) {
|
||||
st.selected, r.changed = s.ID, true
|
||||
slog.Debug("mujica: trace_waterfall select", "span", s.ID)
|
||||
}
|
||||
}).Height(h).FocusGroup(ui.Vertical)
|
||||
for _, s := range spans {
|
||||
if s.ID == st.selected {
|
||||
ui.Column(c).Shrink(0).Gap(u).Padding(u*2, u*3).BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border).
|
||||
Role(ui.RoleStatus).Children(func() {
|
||||
ui.Text(c, s.Name).FontWeight(600)
|
||||
codeCaption(c, fmt.Sprintf(msg(c, "trace_waterfall.detail"), s.Service, agentDuration(s.Start), agentDuration(s.Duration)))
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// traceLayout orders spans depth-first by start, only when the spans change.
|
||||
func traceLayout(st *traceState, spans []TraceSpan) {
|
||||
key := fmt.Sprintf("%d", len(spans))
|
||||
if len(spans) > 0 {
|
||||
key += fmt.Sprintf("%p", &spans[0])
|
||||
}
|
||||
if key == st.key {
|
||||
return
|
||||
}
|
||||
st.key, st.rows, st.total, st.services = key, st.rows[:0], 0, map[string]int{}
|
||||
index := make(map[string]int, len(spans))
|
||||
kids := map[string][]int{}
|
||||
for i, s := range spans {
|
||||
if s.ID == "" || s.Name == "" || s.Start < 0 || s.Duration < 0 {
|
||||
panic(fmt.Sprintf("mujica: TraceWaterfall span %d needs an ID, a Name and non-negative times", i))
|
||||
}
|
||||
if _, dup := index[s.ID]; dup {
|
||||
panic("mujica: TraceWaterfall span IDs must be unique: " + s.ID)
|
||||
}
|
||||
index[s.ID] = i
|
||||
if _, ok := st.services[s.Service]; !ok {
|
||||
st.services[s.Service] = len(st.services)
|
||||
}
|
||||
st.total = max(st.total, s.Start+s.Duration)
|
||||
}
|
||||
for i, s := range spans {
|
||||
if _, ok := index[s.Parent]; s.Parent != "" && !ok {
|
||||
panic("mujica: TraceWaterfall span " + s.ID + " has an unknown Parent")
|
||||
}
|
||||
kids[s.Parent] = append(kids[s.Parent], i)
|
||||
}
|
||||
var walk func(parent string, depth int)
|
||||
walk = func(parent string, depth int) {
|
||||
list := kids[parent]
|
||||
slices.SortStableFunc(list, func(a, b int) int { return cmp.Compare(spans[a].Start, spans[b].Start) })
|
||||
for _, i := range list {
|
||||
st.rows = append(st.rows, traceRow{i, depth})
|
||||
walk(spans[i].ID, depth+1)
|
||||
}
|
||||
}
|
||||
walk("", 0)
|
||||
if len(st.rows) != len(spans) {
|
||||
panic("mujica: TraceWaterfall spans form a cycle")
|
||||
}
|
||||
}
|
||||
|
||||
// traceSpanRow shows a span's name indented by depth beside its bar; it reports a choice.
|
||||
func traceSpanRow(c *ui.Context, s TraceSpan, depth int, total time.Duration, slot int, selected bool) bool {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
u := d.Unit()
|
||||
row := ui.ButtonBase(c).Key(s.ID).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()-4).Padding(0, u*2).
|
||||
Label(fmt.Sprintf("%s, %s, %s", s.Name, s.Service, agentDuration(s.Duration)))
|
||||
internal.Ring(row, k.Focus, theme.ControlRadius)
|
||||
if selected {
|
||||
row.Background(k.Selection)
|
||||
} else if row.Hovered() {
|
||||
row.Background(k.SurfaceHover)
|
||||
}
|
||||
pal := internal.Palette(k)
|
||||
col := pal[slot%len(pal)]
|
||||
if s.Error {
|
||||
col = k.Danger
|
||||
}
|
||||
row.Children(func() {
|
||||
ui.Row(c).Width(220).Shrink(0).Gap(u).Padding(0, 0, 0, float32(depth)*u*4).AlignItems(ui.Center).Children(func() {
|
||||
if s.Error {
|
||||
ui.Icon(c, icons.Must("circle-x")).Size(12, 12).TextColor(k.Danger).Label(msg(c, "trace_waterfall.error"))
|
||||
}
|
||||
ui.Text(c, s.Name).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine().Shrink(1)
|
||||
})
|
||||
ui.Box(c).Grow(1).Height(14).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
span := max(float64(total), 1)
|
||||
x0 := r.X + r.W*float32(float64(s.Start)/span)
|
||||
w := max(r.W*float32(float64(s.Duration)/span), 2)
|
||||
bar := ui.Rect{X: internal.Snap(p, x0), Y: r.Y, W: w, H: r.H}
|
||||
if s.Error { // hollow, so errors differ by shape too
|
||||
p.Fill(bar, col.Alpha(0.25), 2)
|
||||
p.Stroke(bar, col, 2, 1.5)
|
||||
return
|
||||
}
|
||||
p.Fill(bar, col, 2)
|
||||
})
|
||||
ui.Text(c, agentDuration(s.Duration)).Width(64).TextAlign(ui.End).FontSize(FontSize(c, theme.CaptionSize)).
|
||||
TextColor(k.TextMuted).FontFeatures("tnum").Shrink(0)
|
||||
})
|
||||
return row.Clicked()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var traceSample = []TraceSpan{
|
||||
{ID: "db", Parent: "api", Name: "SELECT guests", Service: "postgres", Start: 40 * time.Millisecond, Duration: 90 * time.Millisecond},
|
||||
{ID: "root", Name: "GET /ball", Service: "gateway", Duration: 320 * time.Millisecond},
|
||||
{ID: "api", Parent: "root", Name: "ListGuests", Service: "api", Start: 20 * time.Millisecond, Duration: 200 * time.Millisecond},
|
||||
{ID: "pay", Parent: "root", Name: "Charge", Service: "payments", Start: 230 * time.Millisecond, Duration: 80 * time.Millisecond, Error: true},
|
||||
}
|
||||
|
||||
func traceTester(spans []TraceSpan, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if TraceWaterfall(c, spans, TraceWaterfallOptions{}).Changed() {
|
||||
*changes++
|
||||
}
|
||||
}, 700, 460)
|
||||
}
|
||||
|
||||
func TestTraceWaterfallOrderAndDetail(t *testing.T) {
|
||||
n := 0
|
||||
tt := traceTester(traceSample, &n)
|
||||
if !tt.HasText("4 spans · 320 ms") {
|
||||
t.Fatalf("summary: %q", tt.Texts())
|
||||
}
|
||||
root, _ := tt.Find("GET /ball")
|
||||
api, _ := tt.Find("ListGuests")
|
||||
db, _ := tt.Find("SELECT guests")
|
||||
pay, _ := tt.Find("Charge")
|
||||
if !(root.Y < api.Y && api.Y < db.Y && db.Y < pay.Y && db.X > api.X && api.X > root.X) {
|
||||
t.Fatalf("tree order: root %v api %v db %v pay %v", root, api, db, pay)
|
||||
}
|
||||
_ = tt.Click("Charge")
|
||||
if n != 1 || !tt.HasText("payments · starts at 230 ms · takes 80 ms") {
|
||||
t.Fatalf("detail: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyUp)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !tt.HasText("postgres · starts at 40 ms · takes 90 ms") {
|
||||
t.Fatalf("keyboard detail: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceWaterfallRulesAndMany(t *testing.T) {
|
||||
n := 0
|
||||
tt := traceTester(nil, &n)
|
||||
if !tt.HasText("No spans") {
|
||||
t.Fatal("empty")
|
||||
}
|
||||
many := make([]TraceSpan, 100000)
|
||||
many[0] = TraceSpan{ID: "0", Name: "root", Duration: time.Second}
|
||||
for i := 1; i < len(many); i++ {
|
||||
many[i] = TraceSpan{ID: fmt.Sprint(i), Parent: "0", Name: fmt.Sprint("span ", i), Start: time.Duration(i) * time.Microsecond, Duration: time.Microsecond}
|
||||
}
|
||||
tt = traceTester(many, &n)
|
||||
if got := len(tt.Texts()); got > 400 {
|
||||
t.Fatalf("%d texts for 100000 spans", got)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("cycle did not panic")
|
||||
}
|
||||
}()
|
||||
traceTester([]TraceSpan{{ID: "a", Parent: "b", Name: "a"}, {ID: "b", Parent: "a", Name: "b"}}, &n)
|
||||
}
|
||||
|
||||
func TestTraceWaterfallSnapshot(t *testing.T) {
|
||||
snapshot(t, "trace_waterfall", 680, 280, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { TraceWaterfall(c, traceSample, TraceWaterfallOptions{Height: 180}) })
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user