summaryrefslogtreecommitdiff
path: root/internal/flamegraph
diff options
context:
space:
mode:
Diffstat (limited to 'internal/flamegraph')
-rw-r--r--internal/flamegraph/nativesvg.go11
-rw-r--r--internal/flamegraph/svgwriter.go16
-rw-r--r--internal/flamegraph/svgwriter_js.go155
-rw-r--r--internal/flamegraph/svgwriter_jscode.go155
-rw-r--r--internal/flamegraph/tool.go4
-rw-r--r--internal/flamegraph/webserver.go108
6 files changed, 421 insertions, 28 deletions
diff --git a/internal/flamegraph/nativesvg.go b/internal/flamegraph/nativesvg.go
index 2c76a7d..de0364b 100644
--- a/internal/flamegraph/nativesvg.go
+++ b/internal/flamegraph/nativesvg.go
@@ -23,7 +23,7 @@ func NewNativeSVG(fields []string, countField string) NativeSVG {
}
}
-func (n NativeSVG) WriteSVGFromFile(iorDataFile string) error {
+func (n NativeSVG) WriteSVGFromFile(iorDataFile string) (string, error) {
outFile := fmt.Sprintf("%s.%s-by-%s.svg",
strings.TrimSuffix(iorDataFile, ".ior.zst"),
strings.Join(n.fields, ":"),
@@ -32,16 +32,19 @@ func (n NativeSVG) WriteSVGFromFile(iorDataFile string) error {
iod, err := newIorDataFromFile(iorDataFile)
if err != nil {
- return fmt.Errorf("read ior data: %w", err)
+ return outFile, fmt.Errorf("read ior data: %w", err)
}
fd, err := os.Create(outFile)
if err != nil {
- return fmt.Errorf("create output %s: %w", outFile, err)
+ return outFile, fmt.Errorf("create output %s: %w", outFile, err)
}
defer fd.Close()
- return n.WriteSVGFromIter(iod.iter(), fd)
+ if err := n.WriteSVGFromIter(iod.iter(), fd); err != nil {
+ return outFile, err
+ }
+ return outFile, nil
}
func (n NativeSVG) WriteSVGFromIter(records iter.Seq[IterRecord], w io.Writer) error {
diff --git a/internal/flamegraph/svgwriter.go b/internal/flamegraph/svgwriter.go
index 26e203e..5ab8e50 100644
--- a/internal/flamegraph/svgwriter.go
+++ b/internal/flamegraph/svgwriter.go
@@ -68,7 +68,7 @@ func writeSVGHeader(bw *bufio.Writer, cfg SVGConfig, height int) error {
if err != nil {
return err
}
- _, err = fmt.Fprintf(bw, `<g class="controls"><text x="10" y="42" onclick="fgSearch()">Search</text><text x="80" y="42" onclick="fgResetSearch()">Reset Search</text><text x="190" y="42" onclick="fgResetZoom()">Reset Zoom</text><text id="fg-info" x="320" y="42"></text></g>`+"\n")
+ _, err = fmt.Fprintf(bw, `<g class="controls"><text x="10" y="42" onclick="fgSearch()">Search</text><text x="80" y="42" onclick="fgResetSearch()">Reset Search</text><text x="190" y="42" onclick="fgUndoZoom()">Undo Zoom</text><text x="280" y="42" onclick="fgResetZoom()">Reset Zoom</text><text id="fg-info" x="390" y="42"></text></g>`+"\n")
return err
}
@@ -113,11 +113,14 @@ func writeFrame(bw *bufio.Writer, name, title, fill string, x, y, w, h float64,
svgEscape(title), x, y, w, h, fill); err != nil {
return err
}
- if w > float64(fontSize*2) {
- _, err = fmt.Fprintf(bw, `<text x="%.3f" y="%.3f">%s</text>`+"\n", x+3, y+float64(fontSize), svgEscape(name))
- if err != nil {
- return err
- }
+ labelStyle := ""
+ if w <= float64(fontSize*2) {
+ labelStyle = ` style="display:none"`
+ }
+ _, err = fmt.Fprintf(bw, `<text x="%.3f" y="%.3f"%s>%s</text>`+"\n",
+ x+3, y+float64(fontSize), labelStyle, svgEscape(name))
+ if err != nil {
+ return err
}
_, err = fmt.Fprintln(bw, "</g>")
return err
@@ -139,6 +142,7 @@ func flamegraphCSS(cfg SVGConfig) string {
.controls text { font-size: %dpx; font-family: monospace; cursor: pointer; fill: #444; }
.frame text { font-size: %dpx; font-family: monospace; pointer-events: none; fill: #111; }
.frame rect { stroke: rgba(0,0,0,0.18); stroke-width: 0.5; }
+.title, .controls text, .frame text { user-select: none; -webkit-user-select: none; }
`, cfg.FontSize+2, cfg.FontSize, cfg.FontSize-1)
}
diff --git a/internal/flamegraph/svgwriter_js.go b/internal/flamegraph/svgwriter_js.go
index 7b9183f..bf8bfd2 100644
--- a/internal/flamegraph/svgwriter_js.go
+++ b/internal/flamegraph/svgwriter_js.go
@@ -5,16 +5,33 @@ const fg = {
frames: [],
info: null,
matchColor: "rgb(220, 30, 70)",
+ zoomStack: [],
+ zoomRange: null,
+ rootWidth: 0,
};
function fgInit() {
fg.frames = Array.from(document.querySelectorAll("g.frame"));
fg.info = document.getElementById("fg-info");
+ fg.rootWidth = fgDetectRootWidth();
fg.frames.forEach((frame) => {
- frame.addEventListener("click", (ev) => fgZoom(ev.currentTarget));
+ fgSnapshotOriginalGeometry(frame);
+ frame.addEventListener("click", (ev) => {
+ if (ev.detail > 1) return;
+ ev.stopPropagation();
+ fgZoom(ev.currentTarget);
+ });
+ frame.addEventListener("dblclick", (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ fgResetZoom();
+ });
frame.addEventListener("mouseenter", (ev) => fgHover(ev.currentTarget));
});
- document.addEventListener("dblclick", () => fgResetZoom());
+ document.addEventListener("dblclick", (ev) => {
+ ev.preventDefault();
+ fgResetZoom();
+ });
}
function fgHover(frame) {
@@ -24,15 +41,42 @@ function fgHover(frame) {
}
function fgZoom(frame) {
- const x = Number(frame.dataset.x || "0");
- const w = Number(frame.dataset.w || "0");
+ const x = fgOriginalX(frame);
+ const w = fgOriginalW(frame);
if (w <= 0) return;
- const end = x + w;
+ if (fg.zoomRange) {
+ fg.zoomStack.push(fg.zoomRange);
+ }
+ fg.zoomRange = { x: x, w: w, depth: Number(frame.dataset.depth || "0") };
+ fgApplyZoom();
+}
+
+function fgApplyZoom() {
+ if (!fg.zoomRange) {
+ fg.frames.forEach((frame) => {
+ frame.style.display = "";
+ });
+ return;
+ }
+ const x = fg.zoomRange.x;
+ const end = x + fg.zoomRange.w;
+ const width = fg.zoomRange.w;
+ const minDepth = fg.zoomRange.depth;
+ const eps = 1e-6;
+ const scale = fg.rootWidth / width;
fg.frames.forEach((other) => {
- const ox = Number(other.dataset.x || "0");
- const ow = Number(other.dataset.w || "0");
- const sameBand = Number(other.dataset.depth || "0") >= Number(frame.dataset.depth || "0");
- if (sameBand && ox >= x && ox + ow <= end) {
+ const ox = fgOriginalX(other);
+ const ow = fgOriginalW(other);
+ const depth = Number(other.dataset.depth || "0");
+ const inSelectedRange = ox >= x-eps && ox+ow <= end+eps;
+ const isAncestor = depth < minDepth && ox <= x+eps && ox+ow >= end-eps;
+
+ if (isAncestor || (depth >= minDepth && inSelectedRange)) {
+ if (isAncestor) {
+ fgSetFrameGeometry(other, 0, fg.rootWidth);
+ } else {
+ fgSetFrameGeometry(other, (ox-x)*scale, ow*scale);
+ }
other.style.display = "";
} else {
other.style.display = "none";
@@ -40,8 +84,20 @@ function fgZoom(frame) {
});
}
+function fgUndoZoom() {
+ if (fg.zoomStack.length === 0) {
+ fgResetZoom();
+ return;
+ }
+ fg.zoomRange = fg.zoomStack.pop();
+ fgApplyZoom();
+}
+
function fgResetZoom() {
+ fg.zoomStack = [];
+ fg.zoomRange = null;
fg.frames.forEach((frame) => {
+ fgRestoreFrameGeometry(frame);
frame.style.display = "";
});
}
@@ -71,5 +127,86 @@ function fgResetSearch() {
});
}
+function fgDetectRootWidth() {
+ let maxEnd = 0;
+ fg.frames.forEach((frame) => {
+ const x = Number(frame.dataset.x || "0");
+ const w = Number(frame.dataset.w || "0");
+ maxEnd = Math.max(maxEnd, x + w);
+ });
+ return maxEnd;
+}
+
+function fgSnapshotOriginalGeometry(frame) {
+ const rect = frame.querySelector("rect");
+ const text = frame.querySelector("text");
+ frame.dataset.ox = frame.dataset.x || "0";
+ frame.dataset.ow = frame.dataset.w || "0";
+ if (rect) {
+ rect.dataset.ox = rect.getAttribute("x") || "0";
+ rect.dataset.ow = rect.getAttribute("width") || "0";
+ }
+ if (text) {
+ text.dataset.ox = text.getAttribute("x") || "0";
+ text.dataset.hidden = text.style.display === "none" ? "1" : "0";
+ text.dataset.full = text.textContent || frame.dataset.name || "";
+ }
+}
+
+function fgOriginalX(frame) {
+ return Number(frame.dataset.ox || frame.dataset.x || "0");
+}
+
+function fgOriginalW(frame) {
+ return Number(frame.dataset.ow || frame.dataset.w || "0");
+}
+
+function fgSetFrameGeometry(frame, x, w) {
+ const rect = frame.querySelector("rect");
+ const text = frame.querySelector("text");
+ if (rect) {
+ rect.setAttribute("x", String(x));
+ rect.setAttribute("width", String(w));
+ }
+ if (text) {
+ text.setAttribute("x", String(x + 3));
+ fgFitLabel(text, w);
+ }
+}
+
+function fgRestoreFrameGeometry(frame) {
+ const rect = frame.querySelector("rect");
+ const text = frame.querySelector("text");
+ if (rect) {
+ rect.setAttribute("x", rect.dataset.ox || "0");
+ rect.setAttribute("width", rect.dataset.ow || "0");
+ }
+ if (text) {
+ text.setAttribute("x", text.dataset.ox || "0");
+ if (text.dataset.hidden === "1") {
+ text.style.display = "none";
+ text.textContent = text.dataset.full || "";
+ } else {
+ fgFitLabel(text, Number(rect ? (rect.dataset.ow || "0") : "0"));
+ }
+ }
+}
+
+function fgFitLabel(text, width) {
+ const full = text.dataset.full || text.textContent || "";
+ const maxChars = Math.floor((width - 6) / 7);
+ if (maxChars < 3) {
+ text.style.display = "none";
+ text.textContent = full;
+ return;
+ }
+ text.style.display = "";
+ if (full.length <= maxChars) {
+ text.textContent = full;
+ return;
+ }
+ text.textContent = full.slice(0, maxChars - 1) + "…";
+}
+
window.addEventListener("DOMContentLoaded", fgInit);
`
diff --git a/internal/flamegraph/svgwriter_jscode.go b/internal/flamegraph/svgwriter_jscode.go
index 52f8818..3ac00fd 100644
--- a/internal/flamegraph/svgwriter_jscode.go
+++ b/internal/flamegraph/svgwriter_jscode.go
@@ -7,16 +7,33 @@ const fg = {
frames: [],
info: null,
matchColor: "rgb(220, 30, 70)",
+ zoomStack: [],
+ zoomRange: null,
+ rootWidth: 0,
};
function fgInit() {
fg.frames = Array.from(document.querySelectorAll("g.frame"));
fg.info = document.getElementById("fg-info");
+ fg.rootWidth = fgDetectRootWidth();
fg.frames.forEach((frame) => {
- frame.addEventListener("click", (ev) => fgZoom(ev.currentTarget));
+ fgSnapshotOriginalGeometry(frame);
+ frame.addEventListener("click", (ev) => {
+ if (ev.detail > 1) return;
+ ev.stopPropagation();
+ fgZoom(ev.currentTarget);
+ });
+ frame.addEventListener("dblclick", (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ fgResetZoom();
+ });
frame.addEventListener("mouseenter", (ev) => fgHover(ev.currentTarget));
});
- document.addEventListener("dblclick", () => fgResetZoom());
+ document.addEventListener("dblclick", (ev) => {
+ ev.preventDefault();
+ fgResetZoom();
+ });
}
function fgHover(frame) {
@@ -26,15 +43,42 @@ function fgHover(frame) {
}
function fgZoom(frame) {
- const x = Number(frame.dataset.x || "0");
- const w = Number(frame.dataset.w || "0");
+ const x = fgOriginalX(frame);
+ const w = fgOriginalW(frame);
if (w <= 0) return;
- const end = x + w;
+ if (fg.zoomRange) {
+ fg.zoomStack.push(fg.zoomRange);
+ }
+ fg.zoomRange = { x: x, w: w, depth: Number(frame.dataset.depth || "0") };
+ fgApplyZoom();
+}
+
+function fgApplyZoom() {
+ if (!fg.zoomRange) {
+ fg.frames.forEach((frame) => {
+ frame.style.display = "";
+ });
+ return;
+ }
+ const x = fg.zoomRange.x;
+ const end = x + fg.zoomRange.w;
+ const width = fg.zoomRange.w;
+ const minDepth = fg.zoomRange.depth;
+ const eps = 1e-6;
+ const scale = fg.rootWidth / width;
fg.frames.forEach((other) => {
- const ox = Number(other.dataset.x || "0");
- const ow = Number(other.dataset.w || "0");
- const sameBand = Number(other.dataset.depth || "0") >= Number(frame.dataset.depth || "0");
- if (sameBand && ox >= x && ox + ow <= end) {
+ const ox = fgOriginalX(other);
+ const ow = fgOriginalW(other);
+ const depth = Number(other.dataset.depth || "0");
+ const inSelectedRange = ox >= x-eps && ox+ow <= end+eps;
+ const isAncestor = depth < minDepth && ox <= x+eps && ox+ow >= end-eps;
+
+ if (isAncestor || (depth >= minDepth && inSelectedRange)) {
+ if (isAncestor) {
+ fgSetFrameGeometry(other, 0, fg.rootWidth);
+ } else {
+ fgSetFrameGeometry(other, (ox-x)*scale, ow*scale);
+ }
other.style.display = "";
} else {
other.style.display = "none";
@@ -42,8 +86,20 @@ function fgZoom(frame) {
});
}
+function fgUndoZoom() {
+ if (fg.zoomStack.length === 0) {
+ fgResetZoom();
+ return;
+ }
+ fg.zoomRange = fg.zoomStack.pop();
+ fgApplyZoom();
+}
+
function fgResetZoom() {
+ fg.zoomStack = [];
+ fg.zoomRange = null;
fg.frames.forEach((frame) => {
+ fgRestoreFrameGeometry(frame);
frame.style.display = "";
});
}
@@ -73,5 +129,86 @@ function fgResetSearch() {
});
}
+function fgDetectRootWidth() {
+ let maxEnd = 0;
+ fg.frames.forEach((frame) => {
+ const x = Number(frame.dataset.x || "0");
+ const w = Number(frame.dataset.w || "0");
+ maxEnd = Math.max(maxEnd, x + w);
+ });
+ return maxEnd;
+}
+
+function fgSnapshotOriginalGeometry(frame) {
+ const rect = frame.querySelector("rect");
+ const text = frame.querySelector("text");
+ frame.dataset.ox = frame.dataset.x || "0";
+ frame.dataset.ow = frame.dataset.w || "0";
+ if (rect) {
+ rect.dataset.ox = rect.getAttribute("x") || "0";
+ rect.dataset.ow = rect.getAttribute("width") || "0";
+ }
+ if (text) {
+ text.dataset.ox = text.getAttribute("x") || "0";
+ text.dataset.hidden = text.style.display === "none" ? "1" : "0";
+ text.dataset.full = text.textContent || frame.dataset.name || "";
+ }
+}
+
+function fgOriginalX(frame) {
+ return Number(frame.dataset.ox || frame.dataset.x || "0");
+}
+
+function fgOriginalW(frame) {
+ return Number(frame.dataset.ow || frame.dataset.w || "0");
+}
+
+function fgSetFrameGeometry(frame, x, w) {
+ const rect = frame.querySelector("rect");
+ const text = frame.querySelector("text");
+ if (rect) {
+ rect.setAttribute("x", String(x));
+ rect.setAttribute("width", String(w));
+ }
+ if (text) {
+ text.setAttribute("x", String(x + 3));
+ fgFitLabel(text, w);
+ }
+}
+
+function fgRestoreFrameGeometry(frame) {
+ const rect = frame.querySelector("rect");
+ const text = frame.querySelector("text");
+ if (rect) {
+ rect.setAttribute("x", rect.dataset.ox || "0");
+ rect.setAttribute("width", rect.dataset.ow || "0");
+ }
+ if (text) {
+ text.setAttribute("x", text.dataset.ox || "0");
+ if (text.dataset.hidden === "1") {
+ text.style.display = "none";
+ text.textContent = text.dataset.full || "";
+ } else {
+ fgFitLabel(text, Number(rect ? (rect.dataset.ow || "0") : "0"));
+ }
+ }
+}
+
+function fgFitLabel(text, width) {
+ const full = text.dataset.full || text.textContent || "";
+ const maxChars = Math.floor((width - 6) / 7);
+ if (maxChars < 3) {
+ text.style.display = "none";
+ text.textContent = full;
+ return;
+ }
+ text.style.display = "";
+ if (full.length <= maxChars) {
+ text.textContent = full;
+ return;
+ }
+ text.textContent = full.slice(0, maxChars - 1) + "…";
+}
+
window.addEventListener("DOMContentLoaded", fgInit);
`
diff --git a/internal/flamegraph/tool.go b/internal/flamegraph/tool.go
index 3719b0a..a83c44f 100644
--- a/internal/flamegraph/tool.go
+++ b/internal/flamegraph/tool.go
@@ -69,6 +69,10 @@ func (t Tool) WriteSVG() error {
return nil
}
+func (t Tool) OutFile() string {
+ return t.outFile
+}
+
func decompress(compressedFile string) (string, error) {
decompressedFile := strings.TrimSuffix(compressedFile, ".zst")
diff --git a/internal/flamegraph/webserver.go b/internal/flamegraph/webserver.go
new file mode 100644
index 0000000..7925011
--- /dev/null
+++ b/internal/flamegraph/webserver.go
@@ -0,0 +1,108 @@
+package flamegraph
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "time"
+)
+
+func ServeSVG(svgFile string) error {
+ absPath, err := filepath.Abs(svgFile)
+ if err != nil {
+ return fmt.Errorf("resolve svg path: %w", err)
+ }
+ urlPath := buildURLPath(absPath)
+ srv := &http.Server{Handler: buildSVGHandler(absPath, urlPath)}
+
+ listener, err := listenRandomPort()
+ if err != nil {
+ return err
+ }
+ defer listener.Close()
+
+ hostname, port := serverHostPort(listener)
+ printServerURL(hostname, port, urlPath)
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- srv.Serve(listener)
+ }()
+
+ return waitForStop(srv, errCh)
+}
+
+func buildURLPath(absPath string) string {
+ urlPath := filepath.ToSlash(absPath)
+ if !strings.HasPrefix(urlPath, "/") {
+ return "/" + urlPath
+ }
+ return urlPath
+}
+
+func buildSVGHandler(absPath, urlPath string) http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, urlPath, http.StatusFound)
+ })
+ mux.HandleFunc(urlPath, func(w http.ResponseWriter, r *http.Request) {
+ http.ServeFile(w, r, absPath)
+ })
+ return mux
+}
+
+func listenRandomPort() (net.Listener, error) {
+ listener, err := net.Listen("tcp", ":0")
+ if err != nil {
+ return nil, fmt.Errorf("start web server: %w", err)
+ }
+ return listener, nil
+}
+
+func serverHostPort(listener net.Listener) (string, int) {
+ hostname, err := os.Hostname()
+ if err != nil {
+ hostname = "localhost"
+ }
+ port := listener.Addr().(*net.TCPAddr).Port
+ return hostname, port
+}
+
+func printServerURL(hostname string, port int, urlPath string) {
+ fmt.Printf("Flamegraph available at http://%s:%d%s\n", hostname, port, urlPath)
+ fmt.Println("Press Ctrl+C to stop the web server.")
+}
+
+func waitForStop(srv *http.Server, errCh <-chan error) error {
+ stopCh := make(chan os.Signal, 1)
+ signal.Notify(stopCh, os.Interrupt, syscall.SIGTERM)
+ defer signal.Stop(stopCh)
+
+ select {
+ case sig := <-stopCh:
+ _ = sig
+ case serveErr := <-errCh:
+ if serveErr != nil && serveErr != http.ErrServerClosed {
+ return serveErr
+ }
+ return nil
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(ctx); err != nil {
+ return fmt.Errorf("shutdown web server: %w", err)
+ }
+
+ serveErr := <-errCh
+ if serveErr != nil && serveErr != http.ErrServerClosed {
+ return serveErr
+ }
+ return nil
+}