diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-27 21:55:44 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-27 21:55:44 +0200 |
| commit | 89e864669d62a88f337c02d2097de4afb37e6333 (patch) | |
| tree | 38d0034de8a3bca44a2d8af616e58c19f4ddf02a | |
| parent | 281a433b9ff39c0b290adfc901bdf47cc486491f (diff) | |
flamegraph: add live field-order toggle and reconfigure API
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | internal/flamegraph/livehtml.go | 83 | ||||
| -rw-r--r-- | internal/flamegraph/livehtml_browser_test.go | 9 | ||||
| -rw-r--r-- | internal/flamegraph/livehtml_interaction_test.go | 87 | ||||
| -rw-r--r-- | internal/flamegraph/liveserver.go | 40 | ||||
| -rw-r--r-- | internal/flamegraph/liveserver_test.go | 77 | ||||
| -rw-r--r-- | internal/flamegraph/livetrie.go | 84 | ||||
| -rw-r--r-- | internal/flamegraph/livetrie_test.go | 38 |
8 files changed, 407 insertions, 12 deletions
@@ -94,6 +94,7 @@ Live controls: - `Escape`: reset zoom and search highlighting. - `r`: reset baseline (clears all live aggregated stats on the server and restarts from zero). - `Reset Baseline` button: same behavior as `r`. +- `Order: ...` toggle button: cycles stack order presets on the fly and re-baselines live aggregation for the new order. ## TUI Hotkeys diff --git a/internal/flamegraph/livehtml.go b/internal/flamegraph/livehtml.go index e136a18..90a6d3d 100644 --- a/internal/flamegraph/livehtml.go +++ b/internal/flamegraph/livehtml.go @@ -57,6 +57,11 @@ const liveHTML = `<!doctype html> background: var(--fg-btn-hover); } + #controls .order-toggle { + min-width: 220px; + text-align: left; + } + #status { margin-left: 8px; font-size: 12px; @@ -111,6 +116,7 @@ const liveHTML = `<!doctype html> <button id="btn-undo-zoom" type="button">Undo Zoom</button> <button id="btn-reset-zoom" type="button">Reset Zoom</button> <button id="btn-reset-baseline" type="button">Reset Baseline</button> + <button id="btn-toggle-order" class="order-toggle" type="button">Order: comm > path > tracepoint</button> <span id="status">LIVE</span> </div> @@ -138,6 +144,14 @@ const liveHTML = `<!doctype html> undoZoomBtn: document.getElementById('btn-undo-zoom'), resetZoomBtn: document.getElementById('btn-reset-zoom'), resetBaselineBtn: document.getElementById('btn-reset-baseline'), + toggleOrderBtn: document.getElementById('btn-toggle-order'), + orderPresets: [ + 'comm,path,tracepoint', + 'path,tracepoint,comm', + 'tracepoint,comm,path', + 'pid,path,tracepoint' + ], + orderIndex: 0, cfg: { baseWidth: 1200, baseFrameHeight: 16, @@ -297,6 +311,27 @@ const liveHTML = `<!doctype html> fg.status.textContent = suffix ? (prefix + ' | ' + suffix) : prefix; } + function fgOrderLabel(csv) { + return String(csv || '').split(',').join(' > '); + } + + function fgOrderFields(csv) { + return String(csv || '').split(',').filter(function (s) { return s; }); + } + + function fgSetOrderIndexByCSV(csv) { + for (var i = 0; i < fg.orderPresets.length; i++) { + if (fg.orderPresets[i] === csv) { + fg.orderIndex = i; + return; + } + } + } + + function fgUpdateOrderButton() { + fg.toggleOrderBtn.textContent = 'Order: ' + fgOrderLabel(fg.orderPresets[fg.orderIndex] || ''); + } + function fgHover(frame) { var title = frame.querySelector('title'); fgSetStatus(title ? title.textContent : ''); @@ -661,6 +696,51 @@ const liveHTML = `<!doctype html> fgApplySearch(); } + function fgApplyOrder(csv, expectedIndex) { + if (fg.resetting) { + return; + } + fg.resetting = true; + fgSetStatus('changing order...'); + fetch('/order', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fields: fgOrderFields(csv) }) + }) + .then(function (resp) { + if (!resp.ok) { + throw new Error('order change failed'); + } + return resp.json(); + }) + .then(function (payload) { + fg.orderIndex = expectedIndex; + if (payload && Array.isArray(payload.fields)) { + fgSetOrderIndexByCSV(payload.fields.join(',')); + } + fgUpdateOrderButton(); + fgClearLocalState(); + if (payload && payload.snapshot) { + fg.lastTreeData = payload.snapshot; + fgRender(payload.snapshot); + } else { + fgProcessUpdate('{"n":"","v":0,"t":0}'); + } + fgSetStatus('order: ' + fgOrderLabel(fg.orderPresets[fg.orderIndex] || csv)); + }) + .catch(function () { + fgSetStatus('order change failed'); + }) + .then(function () { + fg.resetting = false; + }); + } + + function fgToggleOrder() { + var nextIndex = (fg.orderIndex + 1) % fg.orderPresets.length; + fgApplyOrder(fg.orderPresets[nextIndex], nextIndex); + } + function fgConnect() { fg.eventSource = new EventSource('/events'); fg.eventSource.onmessage = function (e) { @@ -731,9 +811,11 @@ const liveHTML = `<!doctype html> fg.undoZoomBtn.addEventListener('click', fgUndoZoom); fg.resetZoomBtn.addEventListener('click', fgResetZoom); fg.resetBaselineBtn.addEventListener('click', fgResetBaseline); + fg.toggleOrderBtn.addEventListener('click', fgToggleOrder); document.addEventListener('keydown', fgHandleKeydown); window.addEventListener('resize', fgHandleResize); + fgUpdateOrderButton(); fgSetStatus(''); fgConnect(); @@ -750,6 +832,7 @@ const liveHTML = `<!doctype html> window.fgResetSearch = fgResetSearch; window.fgTogglePause = fgTogglePause; window.fgResetBaseline = fgResetBaseline; + window.fgToggleOrder = fgToggleOrder; window.liveFlamegraphState = fg; })(); </script> diff --git a/internal/flamegraph/livehtml_browser_test.go b/internal/flamegraph/livehtml_browser_test.go index 4105453..c7a16c7 100644 --- a/internal/flamegraph/livehtml_browser_test.go +++ b/internal/flamegraph/livehtml_browser_test.go @@ -148,7 +148,7 @@ function makeElement(id) { } const elements = {}; -["controls", "flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline"].forEach((id) => { +["controls", "flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline", "btn-toggle-order"].forEach((id) => { elements[id] = makeElement(id); }); elements["body"] = makeElement("body"); @@ -163,6 +163,13 @@ global.document = { }; global.window = global; global.prompt = function(){ return ""; }; +global.fetch = function() { + return Promise.resolve({ + ok: true, + json: function() { return Promise.resolve({ fields: ["comm", "path", "tracepoint"], snapshot: { n: "", v: 0, t: 0 } }); }, + text: function() { return Promise.resolve("{\"n\":\"\",\"v\":0,\"t\":0}"); } + }); +}; global.requestAnimationFrame = function(cb){ cb(); }; global.EventSource = function() { this.onmessage = null; diff --git a/internal/flamegraph/livehtml_interaction_test.go b/internal/flamegraph/livehtml_interaction_test.go index 675b03f..0de1466 100644 --- a/internal/flamegraph/livehtml_interaction_test.go +++ b/internal/flamegraph/livehtml_interaction_test.go @@ -40,6 +40,12 @@ type resetBaselineResult struct { ResetCallsValid bool `json:"resetCallsValid"` } +type orderToggleResult struct { + OrderButtonUpdated bool `json:"orderButtonUpdated"` + OrderCallValid bool `json:"orderCallValid"` + OrderSnapshotShown bool `json:"orderSnapshotShown"` +} + func TestLiveHTMLJSZoomSearchStatePreservedAcrossUpdates(t *testing.T) { if _, err := exec.LookPath("node"); err != nil { t.Skip("node not available") @@ -389,6 +395,78 @@ setTimeout(function() { } } +func TestLiveHTMLJSOrderToggle(t *testing.T) { + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node not available") + } + + snippet := ` +const fg = liveFlamegraphState; +const orderCalls = []; +fetch = function(url, opts) { + orderCalls.push({ + url: url, + method: (opts && opts.method) || "GET", + body: (opts && opts.body) || "" + }); + return Promise.resolve({ + ok: true, + json: function() { + return Promise.resolve({ + fields: ["path", "tracepoint", "comm"], + snapshot: { + n: "", + v: 0, + t: 1, + c: [{ n: "/tmp", v: 1, t: 1 }] + } + }); + } + }); +}; + +document.getElementById("btn-toggle-order").listeners.click(); + +setTimeout(function() { + const orderButtonUpdated = document.getElementById("btn-toggle-order").textContent.indexOf("path > tracepoint > comm") >= 0; + const orderSnapshotShown = fg.svg.innerHTML.indexOf('data-name="/tmp"') >= 0; + const req = orderCalls[0] || {}; + let bodyFields = []; + try { + bodyFields = JSON.parse(req.body || "{}").fields || []; + } catch (err) { + bodyFields = []; + } + const orderCallValid = orderCalls.length === 1 && + req.url === "/order" && + req.method === "POST" && + JSON.stringify(bodyFields) === JSON.stringify(["path", "tracepoint", "comm"]); + + console.log(JSON.stringify({ + orderButtonUpdated, + orderCallValid, + orderSnapshotShown + })); +}, 0); +` + + out := runLiveHTMLNodeSnippet(t, snippet) + var got orderToggleResult + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode node result: %v\nraw:\n%s", err, out) + } + + if !got.OrderButtonUpdated { + t.Fatalf("expected toggle button label to update to next order") + } + if !got.OrderCallValid { + t.Fatalf("expected toggle to POST /order with next preset fields") + } + if !got.OrderSnapshotShown { + t.Fatalf("expected returned order snapshot to render immediately") + } +} + func runLiveHTMLNodeSnippet(t *testing.T, snippet string) string { t.Helper() @@ -477,7 +555,7 @@ function makeFrame(name, path, depth, x, w) { } const elements = {}; -["controls", "flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline"].forEach((id) => { +["controls", "flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline", "btn-toggle-order"].forEach((id) => { elements[id] = makeElement(id); }); elements["body"] = makeElement("body"); @@ -493,6 +571,13 @@ global.document = { }; global.window = global; global.prompt = function(){ return ""; }; +global.fetch = function() { + return Promise.resolve({ + ok: true, + json: function() { return Promise.resolve({ fields: ["comm", "path", "tracepoint"], snapshot: { n: "", v: 0, t: 0 } }); }, + text: function() { return Promise.resolve("{\"n\":\"\",\"v\":0,\"t\":0}"); } + }); +}; global.requestAnimationFrame = function(cb){ cb(); }; global.EventSource = function() { this.onmessage = null; diff --git a/internal/flamegraph/liveserver.go b/internal/flamegraph/liveserver.go index 5790cb0..1cbb1d4 100644 --- a/internal/flamegraph/liveserver.go +++ b/internal/flamegraph/liveserver.go @@ -2,6 +2,7 @@ package flamegraph import ( "context" + "encoding/json" "fmt" "net/http" "time" @@ -13,6 +14,7 @@ func ServeLive(ctx context.Context, lt *LiveTrie, interval time.Duration) error mux.HandleFunc("/", handleLivePage()) mux.HandleFunc("/events", handleSSE(lt, interval)) mux.HandleFunc("/reset", handleReset(lt)) + mux.HandleFunc("/order", handleOrder(lt)) srv := &http.Server{Handler: mux} listener, err := listenRandomPort() @@ -112,6 +114,44 @@ func handleReset(lt *LiveTrie) http.HandlerFunc { } } +type orderRequest struct { + Fields []string `json:"fields"` +} + +type orderResponse struct { + Fields []string `json:"fields"` + Snapshot json.RawMessage `json:"snapshot,omitempty"` +} + +func handleOrder(lt *LiveTrie) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(orderResponse{Fields: lt.Fields()}) + case http.MethodPost: + var req orderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json body", http.StatusBadRequest) + return + } + if err := lt.Reconfigure(req.Fields); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + snap, _ := lt.SnapshotJSON() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(orderResponse{ + Fields: lt.Fields(), + Snapshot: snap, + }) + default: + w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + } +} + func sendSnapshot(w http.ResponseWriter, flusher http.Flusher, lt *LiveTrie, lastVersion uint64) (uint64, error) { payload, version := lt.SnapshotJSON() if version == lastVersion { diff --git a/internal/flamegraph/liveserver_test.go b/internal/flamegraph/liveserver_test.go index 15e8f37..59a3782 100644 --- a/internal/flamegraph/liveserver_test.go +++ b/internal/flamegraph/liveserver_test.go @@ -179,6 +179,83 @@ func TestHandleResetClearsTrieAndReturnsEmptySnapshot(t *testing.T) { } } +func TestHandleOrderGetReturnsCurrentFields(t *testing.T) { + lt := NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + req := httptest.NewRequest(http.MethodGet, "/order", nil) + rec := httptest.NewRecorder() + handleOrder(lt).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var resp orderResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if strings.Join(resp.Fields, ",") != "comm,path,tracepoint" { + t.Fatalf("fields = %v, want [comm path tracepoint]", resp.Fields) + } +} + +func TestHandleOrderPostReconfiguresAndResets(t *testing.T) { + lt := NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1)) + + req := httptest.NewRequest(http.MethodPost, "/order", strings.NewReader(`{"fields":["path","tracepoint","comm"]}`)) + rec := httptest.NewRecorder() + handleOrder(lt).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var resp orderResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if strings.Join(resp.Fields, ",") != "path,tracepoint,comm" { + t.Fatalf("fields = %v, want [path tracepoint comm]", resp.Fields) + } + var snap trieSnapshot + if err := json.Unmarshal(resp.Snapshot, &snap); err != nil { + t.Fatalf("decode snapshot: %v", err) + } + if snap.Total != 0 { + t.Fatalf("snapshot total after reconfigure = %d, want 0", snap.Total) + } +} + +func TestHandleOrderPostRejectsInvalidRequest(t *testing.T) { + lt := NewLiveTrie([]string{"comm"}, "count") + + req := httptest.NewRequest(http.MethodPost, "/order", strings.NewReader(`{"fields":["comm","bogus"]}`)) + rec := httptest.NewRecorder() + handleOrder(lt).ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + + req = httptest.NewRequest(http.MethodPost, "/order", strings.NewReader(`{"fields":[}`)) + rec = httptest.NewRecorder() + handleOrder(lt).ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestHandleOrderRequiresGetOrPost(t *testing.T) { + lt := NewLiveTrie([]string{"comm"}, "count") + req := httptest.NewRequest(http.MethodPut, "/order", nil) + rec := httptest.NewRecorder() + handleOrder(lt).ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) + } + if allow := rec.Header().Get("Allow"); allow != http.MethodGet+", "+http.MethodPost { + t.Fatalf("allow = %q, want %q", allow, http.MethodGet+", "+http.MethodPost) + } +} + func TestServeLivePrintsURLAndStopsOnCancel(t *testing.T) { lt := NewLiveTrie([]string{"comm"}, "count") ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/flamegraph/livetrie.go b/internal/flamegraph/livetrie.go index 6ca9bc3..100e03b 100644 --- a/internal/flamegraph/livetrie.go +++ b/internal/flamegraph/livetrie.go @@ -2,6 +2,7 @@ package flamegraph import ( "encoding/json" + "fmt" "ior/internal/event" "slices" "sort" @@ -68,13 +69,28 @@ func (lt *LiveTrie) addLocked(frames []string, value uint64) { } } +func (lt *LiveTrie) resetLocked() { + lt.root = &trieNode{ + childMap: make(map[string]*trieNode), + } + lt.maxDepth = 0 + lt.version.Add(1) +} + +func (lt *LiveTrie) invalidateCache() { + lt.cacheMu.Lock() + lt.cacheVersion = 0 + lt.cacheJSON = nil + lt.cacheMu.Unlock() +} + // Ingest adds one event pair into the live trie and recycles the pair. func (lt *LiveTrie) Ingest(ep *event.Pair) { record := eventPairToRecord(ep) - frames := lt.buildFrames(record) value := record.Cnt.ValueByName(lt.countField) lt.mu.Lock() + frames := lt.buildFrames(record) lt.addLocked(frames, value) lt.version.Add(1) lt.mu.Unlock() @@ -85,17 +101,32 @@ func (lt *LiveTrie) Ingest(ep *event.Pair) { // Reset clears the trie so live snapshots start from a new baseline. func (lt *LiveTrie) Reset() { lt.mu.Lock() - lt.root = &trieNode{ - childMap: make(map[string]*trieNode), - } - lt.maxDepth = 0 - lt.version.Add(1) + lt.resetLocked() lt.mu.Unlock() + lt.invalidateCache() +} - lt.cacheMu.Lock() - lt.cacheVersion = 0 - lt.cacheJSON = nil - lt.cacheMu.Unlock() +// Fields returns the currently configured frame fields in stack order. +func (lt *LiveTrie) Fields() []string { + lt.mu.RLock() + out := slices.Clone(lt.fields) + lt.mu.RUnlock() + return out +} + +// Reconfigure changes frame fields and clears accumulated data for a new baseline. +func (lt *LiveTrie) Reconfigure(fields []string) error { + normalized, err := normalizeLiveTrieFields(fields) + if err != nil { + return err + } + + lt.mu.Lock() + lt.fields = slices.Clone(normalized) + lt.resetLocked() + lt.mu.Unlock() + lt.invalidateCache() + return nil } // Version returns the current ingest version of the trie. @@ -166,6 +197,39 @@ func (lt *LiveTrie) buildFrames(record IterRecord) []string { return frames } +func normalizeLiveTrieFields(fields []string) ([]string, error) { + if len(fields) == 0 { + return nil, fmt.Errorf("fields cannot be empty") + } + + normalized := make([]string, 0, len(fields)) + seen := make(map[string]struct{}, len(fields)) + for _, raw := range fields { + field := strings.TrimSpace(raw) + if field == "" { + return nil, fmt.Errorf("fields cannot contain empty values") + } + if !isLiveTrieField(field) { + return nil, fmt.Errorf("invalid field %q", field) + } + if _, exists := seen[field]; exists { + return nil, fmt.Errorf("duplicate field %q", field) + } + seen[field] = struct{}{} + normalized = append(normalized, field) + } + return normalized, nil +} + +func isLiveTrieField(field string) bool { + switch field { + case "path", "comm", "tracepoint", "pid", "tid", "flags": + return true + default: + return false + } +} + func subtreeTotal(node *trieNode) uint64 { total := node.value for _, child := range node.children { diff --git a/internal/flamegraph/livetrie_test.go b/internal/flamegraph/livetrie_test.go index 9bd16c2..1315c71 100644 --- a/internal/flamegraph/livetrie_test.go +++ b/internal/flamegraph/livetrie_test.go @@ -87,6 +87,44 @@ func TestLiveTrieResetClearsDataAndAdvancesVersion(t *testing.T) { } } +func TestLiveTrieReconfigureChangesOrderAndResets(t *testing.T) { + lt := NewLiveTrie([]string{"comm", "pid"}, "count") + lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1)) + + if err := lt.Reconfigure([]string{"path", "comm"}); err != nil { + t.Fatalf("reconfigure: %v", err) + } + if got := lt.Fields(); len(got) != 2 || got[0] != "path" || got[1] != "comm" { + t.Fatalf("fields after reconfigure = %v, want [path comm]", got) + } + + empty := decodeLiveSnapshot(t, lt) + if empty.Total != 0 { + t.Fatalf("snapshot total after reconfigure = %d, want 0", empty.Total) + } + + lt.Ingest(newTestPair("svc", 42, 1002, "/tmp/a", 1, 1, 1)) + snap := decodeLiveSnapshot(t, lt) + findSnapshotPath(t, &snap, "/tmp", "/a", "svc") +} + +func TestLiveTrieReconfigureRejectsInvalidFields(t *testing.T) { + lt := NewLiveTrie([]string{"comm"}, "count") + + cases := [][]string{ + nil, + {}, + {"comm", "comm"}, + {"comm", ""}, + {"comm", "bogus"}, + } + for _, tc := range cases { + if err := lt.Reconfigure(tc); err == nil { + t.Fatalf("expected error for fields=%v", tc) + } + } +} + func TestLiveTrieSnapshotJSONCaching(t *testing.T) { lt := NewLiveTrie([]string{"comm"}, "count") lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1)) |
