wip-devtools

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:56:35 -04:00
1 parent e755f79371
commit ea7db1c98c
5 files changed
+262

No files matched your search

+6
View File
@@ -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

+178
View File
@@ -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()
}
+78
View File
@@ -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}) })
})
}