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.waiting": {"%d new lines", "%d 条新日志"},
|
||||||
"log_stream.empty": {"No log lines", "没有日志"},
|
"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", "没有告警"},
|
"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