diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-27 18:52:23 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-27 18:52:23 +0200 |
| commit | 5f23af510bd9031c515f2a3cc495bd996c795e69 (patch) | |
| tree | 151d94f6ffacf7446b72481d51f8f8925d5dee8d /internal | |
| parent | 3783d23b8d608c3bf4a2dedd6b4bfb9165439bed (diff) | |
flamegraph: add live baseline reset hotkey
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/flamegraph/livehtml.go | 43 | ||||
| -rw-r--r-- | internal/flamegraph/livehtml_browser_test.go | 2 | ||||
| -rw-r--r-- | internal/flamegraph/livehtml_interaction_test.go | 97 | ||||
| -rw-r--r-- | internal/flamegraph/liveserver.go | 16 | ||||
| -rw-r--r-- | internal/flamegraph/liveserver_test.go | 47 | ||||
| -rw-r--r-- | internal/flamegraph/livetrie.go | 16 | ||||
| -rw-r--r-- | internal/flamegraph/livetrie_test.go | 27 |
7 files changed, 246 insertions, 2 deletions
diff --git a/internal/flamegraph/livehtml.go b/internal/flamegraph/livehtml.go index 9531f85..8ca74cd 100644 --- a/internal/flamegraph/livehtml.go +++ b/internal/flamegraph/livehtml.go @@ -109,6 +109,7 @@ const liveHTML = `<!doctype html> <button id="btn-reset-search" type="button">Reset Search</button> <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> <span id="status">LIVE</span> </div> @@ -118,6 +119,7 @@ const liveHTML = `<!doctype html> (function () { var fg = { paused: false, + resetting: false, pendingData: null, searchQuery: '', zoomStack: [], @@ -133,6 +135,7 @@ const liveHTML = `<!doctype html> resetSearchBtn: document.getElementById('btn-reset-search'), undoZoomBtn: document.getElementById('btn-undo-zoom'), resetZoomBtn: document.getElementById('btn-reset-zoom'), + resetBaselineBtn: document.getElementById('btn-reset-baseline'), cfg: { width: 1200, frameHeight: 16, @@ -465,6 +468,39 @@ const liveHTML = `<!doctype html> } } + function fgClearLocalState() { + fg.pendingData = null; + fg.searchQuery = ''; + fg.zoomStack = []; + fg.zoomRange = null; + } + + function fgResetBaseline() { + if (fg.resetting) { + return; + } + fg.resetting = true; + fgSetStatus('resetting baseline...'); + fetch('/reset', { method: 'POST' }) + .then(function (resp) { + if (!resp.ok) { + throw new Error('reset failed'); + } + return resp.text(); + }) + .then(function (payload) { + fgClearLocalState(); + fgProcessUpdate(payload); + fgSetStatus('baseline reset'); + }) + .catch(function () { + fgSetStatus('reset failed'); + }) + .then(function () { + fg.resetting = false; + }); + } + function fgBindFrameEvents() { for (var i = 0; i < fg.frames.length; i++) { fg.frames[i].addEventListener('mouseenter', function () { fgHover(this); }); @@ -579,6 +615,11 @@ const liveHTML = `<!doctype html> fgSearch(); return; } + if (ev.key === 'r' || ev.key === 'R') { + ev.preventDefault(); + fgResetBaseline(); + return; + } if (ev.key === 'Escape') { ev.preventDefault(); fgResetZoom(); @@ -591,6 +632,7 @@ const liveHTML = `<!doctype html> fg.resetSearchBtn.addEventListener('click', fgResetSearch); fg.undoZoomBtn.addEventListener('click', fgUndoZoom); fg.resetZoomBtn.addEventListener('click', fgResetZoom); + fg.resetBaselineBtn.addEventListener('click', fgResetBaseline); document.addEventListener('keydown', fgHandleKeydown); fgSetStatus(''); @@ -608,6 +650,7 @@ const liveHTML = `<!doctype html> window.fgSearch = fgSearch; window.fgResetSearch = fgResetSearch; window.fgTogglePause = fgTogglePause; + window.fgResetBaseline = fgResetBaseline; window.liveFlamegraphState = fg; })(); </script> diff --git a/internal/flamegraph/livehtml_browser_test.go b/internal/flamegraph/livehtml_browser_test.go index d8f1951..7644084 100644 --- a/internal/flamegraph/livehtml_browser_test.go +++ b/internal/flamegraph/livehtml_browser_test.go @@ -135,7 +135,7 @@ function makeElement(id) { } const elements = {}; -["flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom"].forEach((id) => { +["flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline"].forEach((id) => { elements[id] = makeElement(id); }); elements["body"] = makeElement("body"); diff --git a/internal/flamegraph/livehtml_interaction_test.go b/internal/flamegraph/livehtml_interaction_test.go index 3dbba60..59aaef9 100644 --- a/internal/flamegraph/livehtml_interaction_test.go +++ b/internal/flamegraph/livehtml_interaction_test.go @@ -32,6 +32,13 @@ type pauseKeyboardResult struct { TypingIgnoresShortcuts bool `json:"typingIgnoresShortcuts"` } +type resetBaselineResult struct { + HotkeyPrevented bool `json:"hotkeyPrevented"` + HotkeyResetApplied bool `json:"hotkeyResetApplied"` + ButtonResetApplied bool `json:"buttonResetApplied"` + ResetCallsValid bool `json:"resetCallsValid"` +} + func TestLiveHTMLJSZoomSearchStatePreservedAcrossUpdates(t *testing.T) { if _, err := exec.LookPath("node"); err != nil { t.Skip("node not available") @@ -287,6 +294,94 @@ console.log(JSON.stringify({ } } +func TestLiveHTMLJSResetBaselineHotkeyAndButton(t *testing.T) { + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node not available") + } + + snippet := ` +const fg = liveFlamegraphState; +const keydown = __docListeners["keydown"]; + +function keyEvent(key, code, target) { + let prevented = false; + keydown({ + key: key, + code: code, + target: target || { tagName: "BODY", isContentEditable: false }, + preventDefault: function(){ prevented = true; } + }); + return prevented; +} + +const frame = makeFrame("needle", "needle", 1, 0, 1200); +fg.frames = [frame]; +fg.rootWidth = 1200; +fgZoom(frame); +prompt = function(){ return "needle"; }; +fgSearch(); + +const resetPayload = "{\"n\":\"\",\"v\":0,\"t\":0}"; +const resetCalls = []; +fetch = function(url, opts) { + resetCalls.push({ + url: url, + method: (opts && opts.method) || "GET" + }); + return Promise.resolve({ + ok: true, + text: function() { return Promise.resolve(resetPayload); } + }); +}; + +const hotkeyPrevented = keyEvent("r", "KeyR"); + +setTimeout(function() { + const hotkeyResetApplied = fg.zoomRange === null && fg.searchQuery === "" && fg.frames.length === 0; + + const frame2 = makeFrame("again", "again", 1, 0, 1200); + fg.frames = [frame2]; + fg.rootWidth = 1200; + fgZoom(frame2); + fg.searchQuery = "again"; + document.getElementById("btn-reset-baseline").listeners.click(); + + setTimeout(function() { + const buttonResetApplied = fg.zoomRange === null && fg.searchQuery === "" && fg.frames.length === 0; + const resetCallsValid = resetCalls.length === 2 && + resetCalls[0].url === "/reset" && resetCalls[0].method === "POST" && + resetCalls[1].url === "/reset" && resetCalls[1].method === "POST"; + + console.log(JSON.stringify({ + hotkeyPrevented, + hotkeyResetApplied, + buttonResetApplied, + resetCallsValid + })); + }, 0); +}, 0); +` + + out := runLiveHTMLNodeSnippet(t, snippet) + var got resetBaselineResult + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("decode node result: %v\nraw:\n%s", err, out) + } + + if !got.HotkeyPrevented { + t.Fatalf("expected reset hotkey to prevent default browser handling") + } + if !got.HotkeyResetApplied { + t.Fatalf("expected 'r' hotkey to reset baseline and clear UI state") + } + if !got.ButtonResetApplied { + t.Fatalf("expected Reset Baseline button to clear UI state") + } + if !got.ResetCallsValid { + t.Fatalf("expected reset interactions to POST /reset") + } +} + func runLiveHTMLNodeSnippet(t *testing.T, snippet string) string { t.Helper() @@ -374,7 +469,7 @@ function makeFrame(name, path, depth, x, w) { } const elements = {}; -["flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom"].forEach((id) => { +["flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline"].forEach((id) => { elements[id] = makeElement(id); }); elements["body"] = makeElement("body"); diff --git a/internal/flamegraph/liveserver.go b/internal/flamegraph/liveserver.go index 6b9a72b..5790cb0 100644 --- a/internal/flamegraph/liveserver.go +++ b/internal/flamegraph/liveserver.go @@ -12,6 +12,7 @@ func ServeLive(ctx context.Context, lt *LiveTrie, interval time.Duration) error mux := http.NewServeMux() mux.HandleFunc("/", handleLivePage()) mux.HandleFunc("/events", handleSSE(lt, interval)) + mux.HandleFunc("/reset", handleReset(lt)) srv := &http.Server{Handler: mux} listener, err := listenRandomPort() @@ -96,6 +97,21 @@ func handleSSE(lt *LiveTrie, interval time.Duration) http.HandlerFunc { } } +func handleReset(lt *LiveTrie) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + lt.Reset() + payload, _ := lt.SnapshotJSON() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(payload) + } +} + 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 0d55794..15e8f37 100644 --- a/internal/flamegraph/liveserver_test.go +++ b/internal/flamegraph/liveserver_test.go @@ -132,6 +132,53 @@ func TestHandleSSEDelayedClientLargeTrieGetsValidSnapshot(t *testing.T) { } } +func TestHandleResetRequiresPost(t *testing.T) { + lt := NewLiveTrie([]string{"comm"}, "count") + req := httptest.NewRequest(http.MethodGet, "/reset", nil) + rec := httptest.NewRecorder() + + handleReset(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.MethodPost { + t.Fatalf("allow = %q, want %q", allow, http.MethodPost) + } +} + +func TestHandleResetClearsTrieAndReturnsEmptySnapshot(t *testing.T) { + lt := NewLiveTrie([]string{"path"}, "count") + lt.Ingest(newTestPair("reset", 1, 1001, "/tmp/a", 1, 1, 1)) + lt.Ingest(newTestPair("reset", 1, 1002, "/tmp/b", 1, 1, 1)) + if before := decodeLiveSnapshot(t, lt); before.Total == 0 { + t.Fatalf("expected non-empty trie before reset") + } + + req := httptest.NewRequest(http.MethodPost, "/reset", nil) + rec := httptest.NewRecorder() + handleReset(lt).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if ctype := rec.Header().Get("Content-Type"); !strings.Contains(ctype, "application/json") { + t.Fatalf("content-type = %q, want application/json", ctype) + } + var snap trieSnapshot + if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil { + t.Fatalf("decode reset snapshot: %v", err) + } + if snap.Total != 0 { + t.Fatalf("reset snapshot total = %d, want 0", snap.Total) + } + + after := decodeLiveSnapshot(t, lt) + if after.Total != 0 { + t.Fatalf("trie total after reset = %d, want 0", after.Total) + } +} + 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 e061add..6ca9bc3 100644 --- a/internal/flamegraph/livetrie.go +++ b/internal/flamegraph/livetrie.go @@ -82,6 +82,22 @@ func (lt *LiveTrie) Ingest(ep *event.Pair) { ep.Recycle() } +// 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.mu.Unlock() + + lt.cacheMu.Lock() + lt.cacheVersion = 0 + lt.cacheJSON = nil + lt.cacheMu.Unlock() +} + // Version returns the current ingest version of the trie. func (lt *LiveTrie) Version() uint64 { return lt.version.Load() diff --git a/internal/flamegraph/livetrie_test.go b/internal/flamegraph/livetrie_test.go index 5d32209..9bd16c2 100644 --- a/internal/flamegraph/livetrie_test.go +++ b/internal/flamegraph/livetrie_test.go @@ -60,6 +60,33 @@ func TestLiveTrieVersionIncrementsPerIngest(t *testing.T) { } } +func TestLiveTrieResetClearsDataAndAdvancesVersion(t *testing.T) { + lt := NewLiveTrie([]string{"comm"}, "count") + lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1)) + lt.Ingest(newTestPair("svc", 42, 1002, "/tmp/b", 1, 1, 1)) + + before := lt.Version() + if before == 0 { + t.Fatalf("expected non-zero version before reset") + } + + lt.Reset() + if got := lt.Version(); got != before+1 { + t.Fatalf("version after reset = %d, want %d", got, before+1) + } + + snap := decodeLiveSnapshot(t, lt) + if snap.Total != 0 { + t.Fatalf("snapshot total after reset = %d, want 0", snap.Total) + } + + lt.Ingest(newTestPair("svc", 42, 1003, "/tmp/c", 1, 1, 1)) + next := decodeLiveSnapshot(t, lt) + if next.Total != 1 { + t.Fatalf("snapshot total after new baseline ingest = %d, want 1", next.Total) + } +} + func TestLiveTrieSnapshotJSONCaching(t *testing.T) { lt := NewLiveTrie([]string{"comm"}, "count") lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1)) |
