summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-27 18:52:23 +0200
committerPaul Buetow <paul@buetow.org>2026-02-27 18:52:23 +0200
commit5f23af510bd9031c515f2a3cc495bd996c795e69 (patch)
tree151d94f6ffacf7446b72481d51f8f8925d5dee8d /internal
parent3783d23b8d608c3bf4a2dedd6b4bfb9165439bed (diff)
flamegraph: add live baseline reset hotkey
Diffstat (limited to 'internal')
-rw-r--r--internal/flamegraph/livehtml.go43
-rw-r--r--internal/flamegraph/livehtml_browser_test.go2
-rw-r--r--internal/flamegraph/livehtml_interaction_test.go97
-rw-r--r--internal/flamegraph/liveserver.go16
-rw-r--r--internal/flamegraph/liveserver_test.go47
-rw-r--r--internal/flamegraph/livetrie.go16
-rw-r--r--internal/flamegraph/livetrie_test.go27
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))