diff --git a/src/data/hotline.rs b/src/data/hotline.rs index 05ecd7cb..884aa613 100644 --- a/src/data/hotline.rs +++ b/src/data/hotline.rs @@ -303,10 +303,9 @@ impl ProcessData for Hotline { diff --git a/src/profiling/jfr/convert.rs b/src/profiling/jfr/convert.rs index c86c7a93..7c0f5f38 100644 --- a/src/profiling/jfr/convert.rs +++ b/src/profiling/jfr/convert.rs @@ -302,6 +302,8 @@ pub fn jfr_to_profiler(path: &Path) -> Result { .unwrap_or_default(); let mut profiler = Profiler::new(start_time_ms); + // Computed lazily after settings are populated from ActiveSetting events + let mut wall_interval_ms: Option = None; // Cache: (method_id, frame_type) -> formatted frame string let mut frame_cache: HashMap<(i64, u8), String> = HashMap::new(); @@ -382,13 +384,41 @@ pub fn jfr_to_profiler(path: &Path) -> Result { } if !frames.is_empty() { - profiler.insert_stack( - profile_type, - sample_time_ms, - thread_state, - &frames, - samples, - ); + // Wall clock events with samples > 1 represent consecutive sampling + // intervals. Spread them across time blocks to match async-profiler + // heatmap behavior. + // Source https://github.com/async-profiler/async-profiler/blob/20e0b5cfc344bf5e32e5554750c2b08462c353b8/src/converter/one/convert/JfrToHeatmap.java#L34 + if profile_type == "wall" && samples > 1 { + let interval = *wall_interval_ms.get_or_insert_with(|| { + reader + .settings + .get("wall") + .or_else(|| reader.settings.get("interval")) + .and_then(|s| s.parse::().ok()) + .filter(|&v| v > 0) + .unwrap_or(50_000_000) + / 1_000_000 + }); + let mut time_ms = sample_time_ms; + for _ in 0..samples { + profiler.insert_stack( + profile_type, + time_ms, + thread_state, + &frames, + 1, + ); + time_ms += interval; + } + } else { + profiler.insert_stack( + profile_type, + sample_time_ms, + thread_state, + &frames, + samples, + ); + } } } } diff --git a/src/profiling/mod.rs b/src/profiling/mod.rs index 19ce8525..183b7f53 100644 --- a/src/profiling/mod.rs +++ b/src/profiling/mod.rs @@ -8,7 +8,7 @@ pub mod jfr; pub mod perf; pub mod symbols; -pub const BUCKET_WIDTH_MS: u64 = 100; +pub const BUCKET_WIDTH_MS: u64 = 20; use crate::data::common::data_formats::Profiler; use anyhow::Result; @@ -360,7 +360,9 @@ impl Profile { ) { // Calculate block index and extend blocks vec if necessary let thread_state_id = thread_state.id(); - let offset_ms = (sample_time_ms - start_time_ms).max(0) as u64; + // Align blocks by width + let start_block_ms = start_time_ms - (start_time_ms % (bucket_width_ms as i64)); + let offset_ms = (sample_time_ms - start_block_ms).max(0) as u64; let block_idx = (offset_ms / bucket_width_ms) as usize; while self.blocks.len() <= block_idx { diff --git a/src/report_frontend/src/components/Report.tsx b/src/report_frontend/src/components/Report.tsx index 59b79804..5cff30e4 100644 --- a/src/report_frontend/src/components/Report.tsx +++ b/src/report_frontend/src/components/Report.tsx @@ -110,7 +110,9 @@ export default function () { {!preprocessing && dataFormat == "key_value" && ( )} - {!preprocessing && dataFormat == "profile" && } + {!preprocessing && dataFormat == "profile" && ( + + )} {!preprocessing && dataFormat == "text" && } {!preprocessing && (dataFormat == "unknown" || (dataFormat as string) === "" || dataFormat === undefined) && ( diff --git a/src/report_frontend/src/components/analytics/AnalyticalFindings.tsx b/src/report_frontend/src/components/analytics/AnalyticalFindings.tsx index 10956437..5ca074ae 100644 --- a/src/report_frontend/src/components/analytics/AnalyticalFindings.tsx +++ b/src/report_frontend/src/components/analytics/AnalyticalFindings.tsx @@ -1,5 +1,11 @@ import React from "react"; -import { AnalyticalFinding, DataType, FindingType, TimeSeriesMetricProps } from "../../definitions/types"; +import { + AnalyticalFinding, + DataType, + FindingType, + TimeSeriesMetricProps, + ProfilingDataMetricProps, +} from "../../definitions/types"; import { Icon, SpaceBetween, @@ -154,6 +160,30 @@ export function MetricAnalyticalFindings(props: TimeSeriesMetricProps) { ); } +/** + * This component renders all analytical findings of a specific profiler instance. + */ +export function ProfileAnalyticalFindings(props: ProfilingDataMetricProps) { + const dataFindings = PER_DATA_ANALYTICAL_FINDINGS[props.dataType]; + if (dataFindings == undefined) return null; + const curRunFindings = dataFindings.per_run_findings[props.runName]; + if (curRunFindings == undefined) return null; + + const profileFindings = curRunFindings.findings[props.profileInstance]; + if (profileFindings == undefined) return null; + + const sortedProfileFindings = [...profileFindings]; + sortedProfileFindings.sort((a, b) => a.score - b.score); + + return ( + + {sortedProfileFindings.map((finding) => ( + + ))} + + ); +} + /** * Defines how filtering texts (in the search bar) can be used to filter analytical findings */ diff --git a/src/report_frontend/src/components/data/profile-panel/FlamegraphCanvas.tsx b/src/report_frontend/src/components/data/profile-panel/FlamegraphCanvas.tsx new file mode 100644 index 00000000..109be639 --- /dev/null +++ b/src/report_frontend/src/components/data/profile-panel/FlamegraphCanvas.tsx @@ -0,0 +1,262 @@ +import React from "react"; +import { useReportState } from "../../ReportStateProvider"; +import { FlamegraphNode, getNodeStackPath } from "./utils"; +import { defaultFlamegraphColor, diffFlamegraphColor, getThemeColors } from "./colors"; + +const FLAMEGRAPH_ROW_HEIGHT = 18; +const MIN_RENDER_WIDTH_PX = 0.5; + +interface Props { + readonly flatNodes: FlamegraphNode[]; + readonly flamegraphRoot: FlamegraphNode | null; + readonly containerWidth: number; + readonly baselineByStack: Map | null; + readonly currentByStack: Map | null; + readonly searchRe: RegExp | null; + readonly zoomedNode: FlamegraphNode | null; + readonly setZoomedNode: (node: FlamegraphNode | null) => void; + readonly setTooltip: (t: { x: number; y: number; text: string } | null) => void; + readonly heatmapGridHeight: number; + readonly axisHeight: number; +} + +export function FlamegraphCanvas({ + flatNodes, + flamegraphRoot, + containerWidth, + baselineByStack, + currentByStack, + searchRe, + zoomedNode, + setZoomedNode, + setTooltip, + heatmapGridHeight, + axisHeight, +}: Props) { + const { darkMode } = useReportState(); + const theme = getThemeColors(darkMode); + const canvasRef = React.useRef(null); + + const maxDepth = React.useMemo(() => { + if (flatNodes.length === 0) return 1; + const zoomX = zoomedNode?.x ?? 0; + const zoomW = zoomedNode?.w ?? Infinity; + const zoomDepth = zoomedNode?.depth ?? 0; + let max = 0; + for (const n of flatNodes) { + if (n.depth < zoomDepth) continue; + if (n.x + n.w <= zoomX || n.x >= zoomX + zoomW) continue; + if (n.depth > max) max = n.depth; + } + return max + 1; + }, [flatNodes, zoomedNode]); + + const flamegraphHeight = maxDepth * FLAMEGRAPH_ROW_HEIGHT; + + // Precompute normalized deltas per node (difffolded.pl: delta = cur - base, normalize by max) + const nodeDeltaMap = React.useMemo(() => { + if (!baselineByStack || !currentByStack) return null; + const deltas = new Map(); + let maxAbs = 0; + for (const node of flatNodes) { + if (node.depth === 0) continue; + const path = getNodeStackPath(node); + const cur = currentByStack.get(path)?.total || 0; + const base = baselineByStack.get(path)?.total || 0; + const d = cur - base; + deltas.set(node, d); + if (Math.abs(d) > maxAbs) maxAbs = Math.abs(d); + } + if (maxAbs === 0) return null; + const normalized = new Map(); + for (const [node, d] of deltas) normalized.set(node, d / maxAbs); + return normalized; + }, [flatNodes, baselineByStack, currentByStack]); + + // --- Draw flamegraph --- + React.useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + const w = containerWidth; + const h = flamegraphHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + const ctx = canvas.getContext("2d")!; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + + if (!flamegraphRoot || flamegraphRoot.totalSamples === 0) return; + + const rootTotal = flamegraphRoot.totalSamples; + + const zoomX = zoomedNode ? zoomedNode.x : 0; + const zoomW = zoomedNode ? zoomedNode.w : rootTotal; + const zoomDepth = zoomedNode ? zoomedNode.depth : 0; + const scale = zoomW > 0 ? w / zoomW : 0; + + // Collect ancestor chain from zoomed node to root + const ancestors: FlamegraphNode[] = []; + if (zoomedNode) { + let cur = zoomedNode.parent; + while (cur) { + ancestors.push(cur); + cur = cur.parent; + } + } + + const drawNode = (node: FlamegraphNode, x: number, nodeW: number, y: number, dimmed: boolean) => { + if (nodeW < MIN_RENDER_WIDTH_PX) return; + + let fill: string; + if (node.depth === 0) { + fill = theme.bgRoot; + } else if (nodeDeltaMap) { + fill = diffFlamegraphColor(nodeDeltaMap.get(node) || 0); + } else { + fill = defaultFlamegraphColor(node.name); + } + ctx.fillStyle = fill; + ctx.fillRect(x, y, nodeW - 0.5, FLAMEGRAPH_ROW_HEIGHT - 1); + + if (dimmed) { + ctx.fillStyle = theme.overlayDimmed; + ctx.fillRect(x, y, nodeW - 0.5, FLAMEGRAPH_ROW_HEIGHT - 1); + } + + // Search highlight overlay + if (searchRe && node.depth > 0 && searchRe.test(node.name)) { + ctx.fillStyle = "rgba(64,178,255,0.55)"; + ctx.fillRect(x, y, nodeW - 0.5, FLAMEGRAPH_ROW_HEIGHT - 1); + ctx.strokeStyle = theme.accent; + ctx.lineWidth = 1; + ctx.strokeRect(x + 0.5, y + 0.5, Math.max(0, nodeW - 1.5), FLAMEGRAPH_ROW_HEIGHT - 2); + } + + if (nodeW > 30) { + ctx.fillStyle = theme.textSet; + ctx.font = "11px monospace"; + ctx.textAlign = "left"; + const maxChars = Math.floor((nodeW - 6) / 6.6); + const label = node.name.length > maxChars ? node.name.slice(0, maxChars - 1) + "…" : node.name; + ctx.fillText(label, x + 3, y + 13); + } + }; + + // Draw ancestors full-width (dimmed) + for (const anc of ancestors) { + drawNode(anc, 0, w, anc.depth * FLAMEGRAPH_ROW_HEIGHT, true); + } + + // Draw subtree (zoomed-in region) + for (const node of flatNodes) { + if (node.depth < zoomDepth) continue; + if (node.x + node.w <= zoomX || node.x >= zoomX + zoomW) continue; + const x = (node.x - zoomX) * scale; + const nodeW = node.w * scale; + const y = node.depth * FLAMEGRAPH_ROW_HEIGHT; + drawNode(node, x, nodeW, y, false); + } + }, [flatNodes, flamegraphRoot, containerWidth, flamegraphHeight, theme, nodeDeltaMap, searchRe, zoomedNode]); + + const nodesByDepth = React.useMemo(() => { + const map = new Map(); + for (const n of flatNodes) { + const arr = map.get(n.depth); + if (arr) arr.push(n); + else map.set(n.depth, [n]); + } + return map; + }, [flatNodes]); + + // --- Find node at canvas position --- + const findNodeAt = React.useCallback( + (mx: number, my: number, canvasWidth: number): FlamegraphNode | null => { + if (!flamegraphRoot || flamegraphRoot.totalSamples === 0) return null; + const zoomX = zoomedNode ? zoomedNode.x : 0; + const zoomW = zoomedNode ? zoomedNode.w : flamegraphRoot.totalSamples; + const zoomDepth = zoomedNode ? zoomedNode.depth : 0; + if (zoomW <= 0) return null; + const depth = Math.floor(my / FLAMEGRAPH_ROW_HEIGHT); + if (depth < zoomDepth && zoomedNode) { + let cur = zoomedNode.parent; + while (cur) { + if (cur.depth === depth) return cur; + cur = cur.parent; + } + return null; + } + const scale = canvasWidth / zoomW; + const nodesAtDepth = nodesByDepth.get(depth); + if (!nodesAtDepth) return null; + for (const node of nodesAtDepth) { + const x = (node.x - zoomX) * scale; + const nw = node.w * scale; + if (mx >= x && mx < x + nw) return node; + } + return null; + }, + [flamegraphRoot, nodesByDepth, zoomedNode], + ); + + const onMouseMove = React.useCallback( + (e: React.MouseEvent) => { + if (!flamegraphRoot || flamegraphRoot.totalSamples === 0) { + setTooltip(null); + return; + } + const rect = e.currentTarget.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const rootTotal = flamegraphRoot.totalSamples; + const node = findNodeAt(mx, my, rect.width); + if (node) { + const pct = ((node.totalSamples / rootTotal) * 100).toFixed(2); + setTooltip({ + x: mx, + y: my + heatmapGridHeight + axisHeight + 8, + text: `${node.name}\n${node.totalSamples} samples (${pct}%) | self: ${node.selfSamples}\n(click to zoom)`, + }); + } else { + setTooltip(null); + } + }, + [flamegraphRoot, findNodeAt, heatmapGridHeight, axisHeight, setTooltip], + ); + + const onClick = React.useCallback( + (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const node = findNodeAt(mx, my, rect.width); + if (!node) return; + if (zoomedNode && node === zoomedNode) { + setZoomedNode(null); + } else if (!zoomedNode && node.depth === 0) { + setZoomedNode(null); + } else { + setZoomedNode(node); + } + }, + [findNodeAt, zoomedNode, setZoomedNode], + ); + + const onMouseLeave = React.useCallback(() => setTooltip(null), [setTooltip]); + + return ( +
+ +
+ ); +} + +export { FLAMEGRAPH_ROW_HEIGHT }; diff --git a/src/report_frontend/src/components/data/profile-panel/Heatmap.tsx b/src/report_frontend/src/components/data/profile-panel/Heatmap.tsx new file mode 100644 index 00000000..b197bd4a --- /dev/null +++ b/src/report_frontend/src/components/data/profile-panel/Heatmap.tsx @@ -0,0 +1,471 @@ +import React from "react"; +import { useReportState } from "../../ReportStateProvider"; +import { ZoomLevel } from "./utils"; +import { heatmapColor, getThemeColors } from "./colors"; +import { FlamegraphNode } from "./utils"; + +/** + * HeatmapCanvas: renders the time-series heatmap as a canvas element. + * - Each cell represents one or more profiling blocks (depending on zoom level). + * - Cell color intensity = sample count normalized against the hottest cell. + * - Cells outside the active selection are dimmed. + * - Drag to select a time range; ctrl/cmd-drag to set a baseline range for diff. + * - Hover shows a tooltip with UTC time, offset, and sample count. + * + * HeatmapCanvasProps + * Grid geometry (from useHeatmapLayout): + * numBlocks — total raw profiling blocks + * numDataCells — blocks after grouping (cells with real data) + * totalCells — data cells + buffer cells (fills the grid evenly) + * bufferCellsBefore — empty cells before real data starts + * groupSize — how many raw blocks map to one cell + * rowsPerCol, numColumns, cellSize — grid dimensions and pixel size per cell + * heatmapGridHeight — total pixel height of the grid + * axisHeight — pixel height reserved for the time axis label + * + * Data: + * cellTotals — sample count per grouped cell (drives color intensity) + * maxCellTotal — peak value for normalizing the color scale + * + * Time: + * blockWidthMs — duration each raw block represents + * alignedStartTimeMs — wall-clock start snapped to block boundaries + * alignedStartMs — visible start after zoom applied + * + * Zoom: + * zoom — current zoom level object (range, scale factor) + * + * Selection/drag state (from useHeatmapSelection): + * selection — current selected block range [start, end) + * baselineSelection — ctrl-drag baseline range (for intra-profile diff) + * dragStart, dragEnd, dragIsBaseline — in-progress drag tracking + * + * Callbacks: + * setSelection, setBaselineSelection, setDragStart, setDragEnd, setDragIsBaseline — state setters for interaction + * setTooltip — show/hide hover tooltip + * onDoubleClick — resets selection + */ +interface HeatmapCanvasProps { + readonly numBlocks: number; + readonly numDataCells: number; + readonly totalCells: number; + readonly bufferCellsBefore: number; + readonly cellTotals: number[]; + readonly maxCellTotal: number; + readonly groupSize: number; + readonly rowsPerCol: number; + readonly numColumns: number; + readonly cellSize: number; + readonly heatmapGridHeight: number; + readonly blockWidthMs: number; + readonly alignedStartTimeMs: number; + readonly alignedStartMs: number; + readonly zoom: ZoomLevel; + readonly selection: [number, number]; + readonly baselineSelection: [number, number] | null; + readonly dragStart: number | null; + readonly dragEnd: number | null; + readonly dragIsBaseline: boolean; + readonly axisHeight: number; + readonly setSelection: React.Dispatch>; + readonly setBaselineSelection: (v: [number, number] | null) => void; + readonly setDragStart: (v: number | null) => void; + readonly setDragEnd: (v: number | null) => void; + readonly setDragIsBaseline: (v: boolean) => void; + readonly setTooltip: (t: { x: number; y: number; text: string } | null) => void; + readonly onDoubleClick: () => void; +} + +export function HeatmapCanvas({ + numBlocks, + numDataCells, + totalCells, + bufferCellsBefore, + cellTotals, + maxCellTotal, + groupSize, + rowsPerCol, + numColumns, + cellSize, + heatmapGridHeight, + blockWidthMs, + alignedStartTimeMs, + alignedStartMs, + zoom, + selection, + baselineSelection, + dragStart, + dragEnd, + dragIsBaseline, + axisHeight, + setSelection, + setBaselineSelection, + setDragStart, + setDragEnd, + setDragIsBaseline, + setTooltip, + onDoubleClick, +}: HeatmapCanvasProps) { + const { darkMode } = useReportState(); + const theme = getThemeColors(darkMode); + const canvasRef = React.useRef(null); + + // Draw the heatmap grid onto the canvas. Re-runs when any visual input changes. + React.useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + const w = numColumns * cellSize; + const h = heatmapGridHeight + axisHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + const c = canvas.getContext("2d")!; + c.scale(dpr, dpr); + c.clearRect(0, 0, w, h); + + if (numBlocks === 0) return; + + const selStart = Math.min(selection[0], selection[1]); + const selEnd = Math.max(selection[0], selection[1]); + const selCellStart = Math.floor(selStart / groupSize) + bufferCellsBefore; + const selCellEnd = Math.ceil(selEnd / groupSize) + bufferCellsBefore; + const dragActive = dragStart !== null && dragEnd !== null; + const dragCellMin = dragActive ? Math.floor(Math.min(dragStart!, dragEnd!) / groupSize) + bufferCellsBefore : -1; + const dragCellMax = dragActive ? Math.floor(Math.max(dragStart!, dragEnd!) / groupSize) + bufferCellsBefore : -1; + const bsCellStart = baselineSelection ? Math.floor(baselineSelection[0] / groupSize) + bufferCellsBefore : -1; + const bsCellEnd = baselineSelection ? Math.ceil(baselineSelection[1] / groupSize) + bufferCellsBefore : -1; + + for (let i = 0; i < totalCells; i++) { + const col = Math.floor(i / rowsPerCol); + const row = i % rowsPerCol; + const x = col * cellSize; + const y = axisHeight + row * cellSize; + + const isBuffer = i < bufferCellsBefore || i >= bufferCellsBefore + numDataCells; + if (isBuffer) { + c.fillStyle = theme.bgBuffer; + c.fillRect(x, y, cellSize, cellSize); + } else { + const dataIdx = i - bufferCellsBefore; + const ratio = cellTotals[dataIdx] / maxCellTotal; + c.fillStyle = heatmapColor(ratio); + c.fillRect(x, y, cellSize, cellSize); + } + + if (!isBuffer && (i < selCellStart || i >= selCellEnd)) { + c.fillStyle = theme.overlayDim; + c.fillRect(x, y, cellSize, cellSize); + } + + if (dragActive && i >= dragCellMin && i <= dragCellMax) { + c.fillStyle = dragIsBaseline ? theme.baselineDragOverlay : theme.dragOverlay; + c.fillRect(x, y, cellSize, cellSize); + } + + if (baselineSelection && i >= bsCellStart && i < bsCellEnd) { + c.fillStyle = theme.baselineOverlay; + c.fillRect(x, y, cellSize, cellSize); + } + } + + strokeCellRangeBorder(c, selCellStart, selCellEnd, rowsPerCol, cellSize, theme.selectionBorder, axisHeight); + + if (baselineSelection) { + strokeCellRangeBorder(c, bsCellStart, bsCellEnd, rowsPerCol, cellSize, theme.baselineBorder, axisHeight); + } + + c.fillStyle = theme.textSubtle; + c.font = "11px monospace"; + const startLabel = new Date(alignedStartMs).toISOString().slice(11, 19); + c.textAlign = "left"; + c.fillText(startLabel, 0, 12); + }, [ + numBlocks, + numDataCells, + totalCells, + bufferCellsBefore, + cellTotals, + maxCellTotal, + groupSize, + selection, + baselineSelection, + dragStart, + dragEnd, + dragIsBaseline, + rowsPerCol, + numColumns, + cellSize, + heatmapGridHeight, + alignedStartMs, + theme, + ]); + + // Convert a mouse event position to the corresponding block index in the profile data. + // Returns null if the cursor is over the axis, buffer area, or out of bounds. + const getBlockAt = React.useCallback( + (e: React.MouseEvent): number | null => { + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top - axisHeight; + if (y < 0 || y >= heatmapGridHeight) return null; + const col = Math.floor(x / cellSize); + const row = Math.floor(y / cellSize); + if (col < 0 || col >= numColumns || row < 0 || row >= rowsPerCol) return null; + const cellIdx = col * rowsPerCol + row; + if (cellIdx < 0 || cellIdx >= totalCells) return null; + if (cellIdx < bufferCellsBefore || cellIdx >= bufferCellsBefore + numDataCells) return null; + const dataIdx = cellIdx - bufferCellsBefore; + const blockIdx = dataIdx * groupSize; + if (blockIdx >= numBlocks) return null; + return blockIdx; + }, + [ + cellSize, + rowsPerCol, + numColumns, + totalCells, + numBlocks, + groupSize, + heatmapGridHeight, + bufferCellsBefore, + numDataCells, + ], + ); + + // Start a drag: record the starting block and whether ctrl/cmd is held (baseline mode) + const onMouseDown = React.useCallback( + (e: React.MouseEvent) => { + const idx = getBlockAt(e); + if (idx === null) return; + setDragStart(idx); + setDragEnd(idx); + setDragIsBaseline(e.ctrlKey || e.metaKey); + }, + [getBlockAt, setDragStart, setDragEnd, setDragIsBaseline], + ); + + // During drag: update drag end. Otherwise: show tooltip with cell info. + const onMouseMove = React.useCallback( + (e: React.MouseEvent) => { + const idx = getBlockAt(e); + if (dragStart !== null && idx !== null) { + setDragEnd(idx); + return; + } + if (idx === null) { + setTooltip(null); + return; + } + const rect = e.currentTarget.getBoundingClientRect(); + const cellIdx = Math.floor(idx / groupSize); + const startMs = idx * blockWidthMs; + const cellDurationLabel = zoom.cellMs >= 1000 ? `${zoom.cellMs / 1000}s` : `${zoom.cellMs}ms`; + const utcLabel = new Date(alignedStartTimeMs + startMs).toISOString().slice(11, 22) + " UTC"; + setTooltip({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + text: `${utcLabel} t=${(startMs / 1000).toFixed(2)}s (window=${cellDurationLabel}) samples=${cellTotals[cellIdx] || 0}`, + }); + }, + [dragStart, getBlockAt, groupSize, blockWidthMs, cellTotals, zoom, alignedStartTimeMs, setDragEnd, setTooltip], + ); + + // End drag: commit the dragged range as either the active selection or baseline selection + const onMouseUp = React.useCallback(() => { + if (dragStart !== null && dragEnd !== null) { + const s = Math.min(dragStart, dragEnd); + const e = Math.max(dragStart, dragEnd); + const end = Math.min(numBlocks, e + groupSize); + if (end > s) { + if (dragIsBaseline) { + setBaselineSelection([s, end]); + } else { + setSelection([s, end]); + } + } + } + setDragStart(null); + setDragEnd(null); + setDragIsBaseline(false); + }, [ + dragStart, + dragEnd, + numBlocks, + groupSize, + dragIsBaseline, + setSelection, + setBaselineSelection, + setDragStart, + setDragEnd, + setDragIsBaseline, + ]); + + // Cancel drag and hide tooltip when cursor leaves the canvas + const onMouseLeave = React.useCallback(() => { + setTooltip(null); + if (dragStart !== null) { + setDragStart(null); + setDragEnd(null); + } + }, [dragStart, setTooltip, setDragStart, setDragEnd]); + + return ( +
+ +
+ ); +} + +/** + * HeatmapCanvas helper: Draws a rectangular border around a contiguous range of + * cells in the heatmap grid. Used for both the active selection (blue) and + * baseline selection (red) borders. + */ +function strokeCellRangeBorder( + c: CanvasRenderingContext2D, + start: number, + end: number, + rowsPerCol: number, + cellSize: number, + strokeStyle: string, + axisHeight: number, +) { + c.strokeStyle = strokeStyle; + c.lineWidth = 1.5; + for (let i = start; i < end; i++) { + const col = Math.floor(i / rowsPerCol); + const row = i % rowsPerCol; + const x = col * cellSize + 0.5; + const y = axisHeight + row * cellSize + 0.5; + if (i === start || Math.floor((i - 1) / rowsPerCol) !== col) { + c.beginPath(); + c.moveTo(x, y); + c.lineTo(x + cellSize, y); + c.stroke(); + } + if (i === end - 1 || Math.floor((i + 1) / rowsPerCol) !== col) { + c.beginPath(); + c.moveTo(x, y + cellSize - 1); + c.lineTo(x + cellSize, y + cellSize - 1); + c.stroke(); + } + if (i < start + rowsPerCol) { + c.beginPath(); + c.moveTo(x, y); + c.lineTo(x, y + cellSize); + c.stroke(); + } + if (i >= end - rowsPerCol) { + c.beginPath(); + c.moveTo(x + cellSize - 1, y); + c.lineTo(x + cellSize - 1, y + cellSize); + c.stroke(); + } + } +} + +/** + * HeatmapInfoBar: displays selection range, time duration, sample count, + * search match stats, zoomed node name, zoom level selector, and reset button. + */ +interface HeatmapInfoBarProps { + readonly selection: [number, number]; + readonly blockWidthMs: number; + readonly selSamples: number; + readonly searchRe: RegExp | null; + readonly flamegraphRoot: FlamegraphNode | null; + readonly searchMatches: { count: number; totalSamples: number }; + readonly zoomedNode: FlamegraphNode | null; + readonly setZoomedNode: (node: FlamegraphNode | null) => void; + readonly zoomLevels: ZoomLevel[]; + readonly zoomId: string; + readonly setZoomId: (id: string) => void; + readonly onReset: () => void; +} + +export function HeatmapInfoBar({ + selection, + blockWidthMs, + selSamples, + searchRe, + flamegraphRoot, + searchMatches, + zoomedNode, + setZoomedNode, + zoomLevels, + zoomId, + setZoomId, + onReset, +}: HeatmapInfoBarProps) { + const { darkMode } = useReportState(); + const theme = getThemeColors(darkMode); + const selTimeSec = (((selection[1] - selection[0]) * blockWidthMs) / 1000).toFixed(1); + + return ( +
+ + Selected: blocks {selection[0]}–{selection[1]} ({selTimeSec}s, {selSamples.toLocaleString()} samples) + {searchRe && flamegraphRoot && ( + + Matches: {searchMatches.count} frames,{" "} + {((searchMatches.totalSamples / (flamegraphRoot.totalSamples || 1)) * 100).toFixed(2)}% + + )} + {zoomedNode && ( + + Zoomed: {zoomedNode.name}{" "} + setZoomedNode(null)}> + (reset zoom) + + + )} + + + + Zoom: + + + + Reset + + +
+ ); +} diff --git a/src/report_frontend/src/components/data/profile-panel/MethodsTable.tsx b/src/report_frontend/src/components/data/profile-panel/MethodsTable.tsx new file mode 100644 index 00000000..9762c840 --- /dev/null +++ b/src/report_frontend/src/components/data/profile-panel/MethodsTable.tsx @@ -0,0 +1,150 @@ +import React from "react"; +import { useReportState } from "../../ReportStateProvider"; +import { FrameType, FRAME_TYPE_COLORS, FRAME_TYPE_LABELS, diffFlamegraphColor, getThemeColors } from "./colors"; + +interface MethodEntry { + name: string; + type: FrameType; + self: number; + total: number; + selfPct: number; + totalPct: number; +} + +type SortKey = "name" | "type" | "self" | "selfPct" | "total" | "totalPct"; + +interface Props { + readonly methodsList: MethodEntry[]; + readonly searchRe: RegExp | null; + readonly baselineByFrame: Map | null; +} + +export function MethodsTable({ methodsList, searchRe, baselineByFrame }: Props) { + const { darkMode } = useReportState(); + const theme = getThemeColors(darkMode); + const [sortKey, setSortKey] = React.useState("totalPct"); + const [sortAsc, setSortAsc] = React.useState(false); + + // Precompute normalized deltas for diff coloring (sample count difference, normalized by max) + const methodDeltaMap = React.useMemo(() => { + if (!baselineByFrame) return null; + const deltas = new Map(); + let maxAbs = 0; + for (const m of methodsList) { + const base = baselineByFrame.get(m.name)?.total || 0; + const d = m.total - base; + deltas.set(m.name, d); + if (Math.abs(d) > maxAbs) maxAbs = Math.abs(d); + } + if (maxAbs === 0) return null; + const normalized = new Map(); + for (const [name, d] of deltas) normalized.set(name, d / maxAbs); + return normalized; + }, [methodsList, baselineByFrame]); + + const handleSort = (key: SortKey) => { + if (key === sortKey) { + setSortAsc(!sortAsc); + } else { + setSortKey(key); + setSortAsc(key === "name" || key === "type"); + } + }; + + const sorted = React.useMemo(() => { + const dir = sortAsc ? 1 : -1; + return [...methodsList].sort((a, b) => { + if (sortKey === "name") return dir * a.name.localeCompare(b.name); + if (sortKey === "type") return dir * a.type.localeCompare(b.type); + return dir * (a[sortKey] - b[sortKey]); + }); + }, [methodsList, sortKey, sortAsc]); + + const headerStyle = (key: SortKey, align: "left" | "right"): React.CSSProperties => ({ + textAlign: align, + padding: "4px 8px", + cursor: "pointer", + userSelect: "none", + }); + + const arrow = (key: SortKey) => (sortKey === key ? (sortAsc ? " ▲" : " ▼") : ""); + + return ( +
+ + + + + + + + + + + + + {sorted.map((m) => { + const isMatch = searchRe && searchRe.test(m.name); + const bgColor = methodDeltaMap ? diffFlamegraphColor(methodDeltaMap.get(m.name) || 0) : "transparent"; + return ( + + + + + + + + + ); + })} + +
handleSort("name")}> + Method{arrow("name")} + handleSort("type")}> + Type{arrow("type")} + handleSort("self")}> + Self{arrow("self")} + handleSort("selfPct")}> + Self %{arrow("selfPct")} + handleSort("total")}> + Total{arrow("total")} + handleSort("totalPct")}> + Total %{arrow("totalPct")} +
{m.name} + + {FRAME_TYPE_LABELS[m.type]} + {m.self.toLocaleString()}{m.selfPct.toFixed(2)}{m.total.toLocaleString()}{m.totalPct.toFixed(2)}
+
+ ); +} diff --git a/src/report_frontend/src/components/data/profile-panel/ProfilePanel.tsx b/src/report_frontend/src/components/data/profile-panel/ProfilePanel.tsx new file mode 100644 index 00000000..6c2aa2ab --- /dev/null +++ b/src/report_frontend/src/components/data/profile-panel/ProfilePanel.tsx @@ -0,0 +1,400 @@ +import React from "react"; +import { Profile } from "../../../definitions/types"; +import { useReportState } from "../../ReportStateProvider"; +import { useProfilePanelState, ViewMode } from "./ProfilePanelStateProvider"; +import { DataType, ProfilingData } from "../../../definitions/types"; +import { PROCESSED_DATA, RUNS } from "../../../definitions/data-config"; +import { Checkbox, Input, SegmentedControl, SpaceBetween } from "@cloudscape-design/components"; +import { RunHeader } from "../RunSystemInfo"; +import { ProfilePanelStateProvider } from "./ProfilePanelStateProvider"; +import { useRegex, useContainerWidth, useHeatmapLayout, useHeatmapSelection } from "./utils"; +import { FlamegraphCanvas } from "./FlamegraphCanvas"; +import { HeatmapCanvas, HeatmapInfoBar } from "./Heatmap"; +import { MethodsTable } from "./MethodsTable"; +import { FrameType, FRAME_TYPE_COLORS, FRAME_TYPE_LABELS, getThemeColors, getFrameType } from "./colors"; +import { + blockTotal, + computeNodeSelfSamples, + aggregateByFrame, + aggregateByStack, + FlamegraphNode, + buildFlamegraph, + flattenFlamegraph, +} from "./utils"; + +// Height in pixels reserved for the time axis label above the heatmap grid +const AXIS_HEIGHT = 20; + +interface ProfilePanelProps { + readonly dataType: DataType; + readonly instanceName: string; + readonly selectedProfile: string; +} + +export default function ProfilePanel({ dataType, instanceName, selectedProfile }: ProfilePanelProps) { + const widthPercent = Math.floor(100 / RUNS.length); + + // Find base run profile + const baseRunData = PROCESSED_DATA[dataType]?.runs[RUNS[0]] as ProfilingData | undefined; + const baseProfile = baseRunData?.profilers?.[instanceName]?.profiles?.[selectedProfile]; + const validBaseline = baseProfile && baseProfile.blocks?.length > 0 ? baseProfile : undefined; + + return ( + +
+ +
+
+ {RUNS.map((runName, runIdx) => { + const runData = PROCESSED_DATA[dataType]?.runs[runName] as ProfilingData | undefined; + const profiler = runData?.profilers?.[instanceName]; + const profile = profiler?.profiles?.[selectedProfile]; + if (!profiler || !profile || !profile.blocks?.length) return null; + + return ( +
+ + + 0 ? validBaseline : undefined} + isBaseRun={runIdx === 0} + startTimeMs={profiler.start_time_ms} + blockWidthMs={profiler.block_width_ms} + /> + +
+ ); + })} +
+
+ ); +} + +interface ProfilePanelViewProps { + readonly analytics: Profile; + readonly baseline?: Profile; + readonly isBaseRun?: boolean; + readonly startTimeMs: number; + readonly blockWidthMs: number; +} + +/** + * Main composition root for the heatmap + flamegraph visualization. + * Renders: info bar → heatmap canvas → flamegraph/top_functions view → tooltip overlay. + * Reads shared state (search, filter, diff, viewMode) from ProfilePanelContext. + */ +function ProfilePanelView({ analytics, baseline, isBaseRun, startTimeMs, blockWidthMs }: ProfilePanelViewProps) { + const { darkMode } = useReportState(); + const theme = getThemeColors(darkMode); + + const { searchRegex, filterRegex, reverse, showDiff, viewMode, baseRunSelection, setBaseRunSelection } = + useProfilePanelState(); + + const searchRe = useRegex(searchRegex); + const filterRe = useRegex(filterRegex); + + const containerRef = React.useRef(null); + const containerWidth = useContainerWidth(containerRef); + + const alignedStartTimeMs = startTimeMs - (startTimeMs % blockWidthMs); + const numBlocks = analytics.blocks.length; + + const layout = useHeatmapLayout(numBlocks, blockWidthMs, alignedStartTimeMs, containerWidth); + const sel = useHeatmapSelection(numBlocks, layout.groupSize, isBaseRun, setBaseRunSelection); + + const [tooltip, setTooltip] = React.useState<{ x: number; y: number; text: string } | null>(null); + const [zoomedNode, setZoomedNode] = React.useState(null); + + const blockTotals = React.useMemo(() => analytics.blocks.map(blockTotal), [analytics.blocks]); + + const cellTotals = React.useMemo(() => { + if (layout.groupSize === 1) return blockTotals; + const out = new Array(layout.numDataCells).fill(0); + for (let i = 0; i < numBlocks; i++) { + out[Math.floor(i / layout.groupSize)] += blockTotals[i]; + } + return out; + }, [blockTotals, layout.groupSize, numBlocks, layout.numDataCells]); + + const maxCellTotal = React.useMemo(() => Math.max(1, ...cellTotals), [cellTotals]); + + const flamegraphRoot = React.useMemo( + () => buildFlamegraph(analytics, sel.selection[0], sel.selection[1], filterRe, reverse), + [analytics, sel.selection, filterRe, reverse], + ); + + React.useEffect(() => { + setZoomedNode(null); + }, [flamegraphRoot]); + + // Baseline diff: compute per-frame sample aggregation for the baseline profile. + // Priority: intra-profile ctrl-drag selection > cross-profile diff (when showDiff is on). + const baselineByFrame = React.useMemo(() => { + if (sel.baselineSelection) { + const baseSelf = computeNodeSelfSamples(analytics, sel.baselineSelection[0], sel.baselineSelection[1]); + return aggregateByFrame(analytics, baseSelf); + } + if (!showDiff || !baseline || baseline === analytics) return null; + const start = baseRunSelection ? baseRunSelection[0] : 0; + const end = baseRunSelection ? baseRunSelection[1] : baseline.blocks.length; + const baseSelf = computeNodeSelfSamples(baseline, start, end); + return aggregateByFrame(baseline, baseSelf); + }, [baseline, analytics, sel.baselineSelection, showDiff, baseRunSelection]); + + // Per-stack aggregation for flamegraph diff coloring (difffolded.pl semantics) + const baselineByStack = React.useMemo(() => { + if (sel.baselineSelection) { + const baseSelf = computeNodeSelfSamples(analytics, sel.baselineSelection[0], sel.baselineSelection[1]); + return aggregateByStack(analytics, baseSelf, reverse); + } + if (!showDiff || !baseline || baseline === analytics) return null; + const start = baseRunSelection ? baseRunSelection[0] : 0; + const end = baseRunSelection ? baseRunSelection[1] : baseline.blocks.length; + const baseSelf = computeNodeSelfSamples(baseline, start, end); + return aggregateByStack(baseline, baseSelf, reverse); + }, [baseline, analytics, sel.baselineSelection, showDiff, baseRunSelection, reverse]); + + const currentByStack = React.useMemo(() => { + if (!baselineByStack) return null; + const curSelf = computeNodeSelfSamples(analytics, sel.selection[0], sel.selection[1]); + return aggregateByStack(analytics, curSelf, reverse); + }, [analytics, sel.selection, baselineByStack, reverse]); + + // Flatten the tree into a list for rendering and aggregation + const flatNodes = React.useMemo(() => (flamegraphRoot ? flattenFlamegraph(flamegraphRoot) : []), [flamegraphRoot]); + + // Total samples in the current selection (shown in the info bar) + const selSamples = React.useMemo(() => { + let sum = 0; + for (let i = sel.selection[0]; i < sel.selection[1]; i++) sum += blockTotals[i] || 0; + return sum; + }, [sel.selection, blockTotals]); + + // Count of frames matching the search regex and their total sample weight + const searchMatches = React.useMemo(() => { + if (!searchRe) return { count: 0, totalSamples: 0 }; + let count = 0; + let totalSamples = 0; + for (const n of flatNodes) { + if (n.depth > 0 && searchRe.test(n.name)) { + count++; + totalSamples += n.totalSamples; + } + } + return { count, totalSamples }; + }, [flatNodes, searchRe]); + + // Aggregate flat nodes into a per-method summary for the Methods table view. + // Deduplicates by frame name+type, avoids double-counting recursive calls in "total". + const methodsList = React.useMemo(() => { + if (!flamegraphRoot) return []; + const byKey = new Map< + string, + { name: string; type: ReturnType; self: number; total: number } + >(); + for (const n of flatNodes) { + if (n.depth === 0) continue; + const type = getFrameType(n.name); + const stripped = type === "native" ? n.name : n.name.slice(0, -4); + const key = `${stripped}\u0000${type}`; + const entry = byKey.get(key) || { name: stripped, type, self: 0, total: 0 }; + entry.self += n.selfSamples; + let ancestorHasSameFrame = false; + let cur = n.parent; + while (cur) { + if (cur.name === n.name) { + ancestorHasSameFrame = true; + break; + } + cur = cur.parent; + } + if (!ancestorHasSameFrame) entry.total += n.totalSamples; + byKey.set(key, entry); + } + const rootTotal = flamegraphRoot.totalSamples || 1; + return Array.from(byKey.values()) + .map((v) => ({ + name: v.name, + type: v.type, + self: v.self, + total: v.total, + selfPct: (v.self / rootTotal) * 100, + totalPct: (v.total / rootTotal) * 100, + })) + .sort((a, b) => b.self - a.self); + }, [flatNodes, flamegraphRoot]); + + if (numBlocks === 0) { + return
No profiler data available.
; + } + + return ( +
+ {/* Info bar: selection stats, search match count, zoom controls */} + + + {/* Time-based heatmap grid: drag to select, ctrl-drag for baseline, double-click to reset */} + + + {/* Flamegraph (click to zoom) or Methods table, toggled by provider viewMode */} + {viewMode === "flamegraph" ? ( + + ) : ( + + )} + + {/* Floating tooltip positioned near the cursor */} + {tooltip && ( +
+ {tooltip.text} +
+ )} +
+ ); +} + +function ProfilePanelToolbar() { + const { + searchRegex, + filterRegex, + reverse, + viewMode, + showDiff, + setSearchRegex, + setFilterRegex, + setReverse, + setShowDiff, + setViewMode, + } = useProfilePanelState(); + + return ( + + + setSearchRegex(detail.value)} + placeholder="Search (regex)" + type="search" + /> + setFilterRegex(detail.value)} + placeholder="Filter (regex)" + type="search" + /> + setReverse(detail.checked)}> + Reverse + + setShowDiff(detail.checked)}> + Diff + + setViewMode(detail.selectedId as ViewMode)} + options={[ + { id: "flamegraph", text: "Flamegraph" }, + { id: "top_functions", text: "Top Functions" }, + ]} + /> + + + + ); +} + +function FrameTypeLegend() { + const { darkMode } = useReportState(); + const theme = getThemeColors(darkMode); + const order: FrameType[] = ["interpreted", "jit", "inlined", "native", "kernel", "c1"]; + return ( +
+ {order.map((t) => ( + + + {FRAME_TYPE_LABELS[t]} + + ))} +
+ ); +} diff --git a/src/report_frontend/src/components/data/profile-panel/ProfilePanelStateProvider.tsx b/src/report_frontend/src/components/data/profile-panel/ProfilePanelStateProvider.tsx new file mode 100644 index 00000000..ac6183d4 --- /dev/null +++ b/src/report_frontend/src/components/data/profile-panel/ProfilePanelStateProvider.tsx @@ -0,0 +1,55 @@ +import React from "react"; + +export type ViewMode = "flamegraph" | "top_functions"; + +export interface ProfilePanelState { + searchRegex: string; + filterRegex: string; + reverse: boolean; + showDiff: boolean; + viewMode: ViewMode; + baseRunSelection: [number, number] | null; + setSearchRegex: (v: string) => void; + setFilterRegex: (v: string) => void; + setReverse: (v: boolean) => void; + setShowDiff: (v: boolean) => void; + setViewMode: (v: ViewMode) => void; + setBaseRunSelection: (v: [number, number]) => void; +} + +const ProfilePanelContext = React.createContext(undefined); + +export function ProfilePanelStateProvider({ children }: { children: React.ReactNode }) { + const [searchRegex, setSearchRegex] = React.useState(""); + const [filterRegex, setFilterRegex] = React.useState(""); + const [reverse, setReverse] = React.useState(false); + const [showDiff, setShowDiff] = React.useState(false); + const [viewMode, setViewMode] = React.useState("flamegraph"); + const [baseRunSelection, setBaseRunSelection] = React.useState<[number, number] | null>(null); + const value = React.useMemo( + () => ({ + searchRegex, + filterRegex, + reverse, + showDiff, + viewMode, + baseRunSelection, + setSearchRegex, + setFilterRegex, + setReverse, + setShowDiff, + setViewMode, + setBaseRunSelection, + }), + [searchRegex, filterRegex, reverse, showDiff, viewMode, baseRunSelection], + ); + return {children}; +} + +export function useProfilePanelState() { + const context = React.useContext(ProfilePanelContext); + if (context == undefined) { + throw new Error("useProfilePanelState must be used within ProfilePanelStateProvider"); + } + return context; +} diff --git a/src/report_frontend/src/components/data/profile-panel/colors.ts b/src/report_frontend/src/components/data/profile-panel/colors.ts new file mode 100644 index 00000000..0183948e --- /dev/null +++ b/src/report_frontend/src/components/data/profile-panel/colors.ts @@ -0,0 +1,151 @@ +// --- Color utilities --- + +// --- Theme colors (dark/light pairs) --- + +export interface ThemeColors { + textMuted: string; + textPrimary: string; + textSubtle: string; + textSet: string; + accent: string; + border: string; + borderSubtle: string; + bgSurface: string; + bgMuted: string; + bgBuffer: string; + bgRoot: string; + overlayDim: string; + overlayDimmed: string; + selectionBorder: string; + baselineBorder: string; + dragOverlay: string; + baselineDragOverlay: string; + baselineOverlay: string; + searchHighlight: string; +} + +const DARK_THEME: ThemeColors = { + textMuted: "#aaa", + textPrimary: "#eee", + textSubtle: "#ccc", + textSet: "#111", + accent: "#58a6ff", + border: "#555", + borderSubtle: "#555", + bgSurface: "#1e1e1e", + bgMuted: "#2a2a2a", + bgBuffer: "#2a2a2a", + bgRoot: "#676767", + overlayDim: "rgba(0,0,0,0.55)", + overlayDimmed: "rgba(0,0,0,0.4)", + selectionBorder: "#58a6ff", + baselineBorder: "#ff6b6b", + dragOverlay: "rgba(88,166,255,0.3)", + baselineDragOverlay: "rgba(205,50,50,0.3)", + baselineOverlay: "rgba(205,50,50,0.25)", + searchHighlight: "rgba(64,178,255,0.25)", +}; + +const LIGHT_THEME: ThemeColors = { + textMuted: "#666", + textPrimary: "#111", + textSubtle: "#333", + textSet: "#111", + accent: "#0073bb", + border: "#ccc", + borderSubtle: "#999", + bgSurface: "#fff", + bgMuted: "#f0f0f0", + bgBuffer: "#e0e0e0", + bgRoot: "#ddd", + overlayDim: "rgba(255,255,255,0.55)", + overlayDimmed: "rgba(255,255,255,0.4)", + selectionBorder: "#0073bb", + baselineBorder: "#cc0000", + dragOverlay: "rgba(0,115,187,0.25)", + baselineDragOverlay: "rgba(239,34,34,0.25)", + baselineOverlay: "rgba(139,34,34,0.2)", + searchHighlight: "rgba(64,178,255,0.2)", +}; + +export function getThemeColors(darkMode: boolean): ThemeColors { + return darkMode ? DARK_THEME : LIGHT_THEME; +} + +// Async-profiler heatmap palette: white → orange → red. +// Uses async-profiler's setH(0.4) base (dR=256, dG≈102, dB=0) and its ratio→color formula. +const HEATMAP_BASE_R = 256; +const HEATMAP_BASE_G = 102; +const HEATMAP_BASE_B = 0; + +export function heatmapColor(ratio: number): string { + // ratio is expected in [0, 1]. 0 → show transparent, increasing → orange → red. + if (ratio <= 0) return "#ffffff"; + const clamped = Math.max(0, Math.min(1, ratio)); + const ratioM = (clamped * 192 + 24) | 0; + const C = 255 - Math.abs(255 - (ratioM << 1)); + const m = 255 - ratioM - (C >> 1); + const r = ((HEATMAP_BASE_R * C) >> 8) + m; + const g = ((HEATMAP_BASE_G * C) >> 8) + m; + const b = ((HEATMAP_BASE_B * C) >> 8) + m; + return `rgb(${r},${g},${b})`; +} + +/** + * Map frame name suffix to frame type color. Suffixes follow async-profiler convention: + * _[j] JIT, _[i] Inlined, _[k] Kernel, _[0] Interpreted, _[1] C1, no suffix Native. + * Colors match async-profiler's palette. + */ +export type FrameType = "jit" | "inlined" | "kernel" | "interpreted" | "c1" | "native"; + +export function getFrameType(name: string): FrameType { + if (name.endsWith("_[j]")) return "jit"; + if (name.endsWith("_[i]")) return "inlined"; + if (name.endsWith("_[k]")) return "kernel"; + if (name.endsWith("_[0]")) return "interpreted"; + if (name.endsWith("_[1]")) return "c1"; + return "native"; +} + +export const FRAME_TYPE_COLORS: Record = { + interpreted: "#b2e1b2", + jit: "#50e150", + inlined: "#50cccc", + native: "#e15a5a", + kernel: "#e17d00", + c1: "#cce880", +}; + +export const FRAME_TYPE_LABELS: Record = { + interpreted: "Interpreted", + jit: "JIT-Compiled", + inlined: "Inlined", + native: "Native", + kernel: "Kernel", + c1: "C1-Compiled", +}; + +export function defaultFlamegraphColor(name: string): string { + return FRAME_TYPE_COLORS[getFrameType(name)]; +} + +/** + * Diff color following https://github.com/brendangregg/FlameGraph/blob/41fee1f99f9276008b7cd112fca19dc3ea84ac32/difffolded.pl: + * Takes a pre-normalized delta in [-1, 1] where the value is (samples2 - samples1) / maxAbsDelta. + * Red = grew, Blue = shrank, saturation ∝ magnitude. + */ +export function diffFlamegraphColor(delta: number): string { + if (Math.abs(delta) < 0.001) return `rgb(255,255,255)`; + const intensity = Math.min(1, Math.abs(delta)); + if (delta > 0) { + const r = 200 + Math.floor(55 * intensity); + const g = Math.floor(180 * (1 - intensity)); + const b = Math.floor(180 * (1 - intensity)); + return `rgb(${r},${g},${b})`; + } else { + const r = Math.floor(180 * (1 - intensity)); + const g = Math.floor(180 * (1 - intensity)); + const b = 200 + Math.floor(55 * intensity); + return `rgb(${r},${g},${b})`; + } +} diff --git a/src/report_frontend/src/components/data/profile-panel/utils.ts b/src/report_frontend/src/components/data/profile-panel/utils.ts new file mode 100644 index 00000000..33ccbc9e --- /dev/null +++ b/src/report_frontend/src/components/data/profile-panel/utils.ts @@ -0,0 +1,516 @@ +import React from "react"; +import { Profile } from "../../../definitions/types"; + +// --- Block/sample aggregation --- + +/** Sum all samples across all thread states in a block */ +export function blockTotal(block: { [ts: string]: { [nodeId: string]: number } }): number { + let sum = 0; + for (const ts in block) { + for (const nid in block[ts]) { + sum += block[ts][nid]; + } + } + return sum; +} + +/** Aggregate self_samples per node_id from blocks in [start,end) */ +export function computeNodeSelfSamples(analytics: Profile, blockStart: number, blockEnd: number): Map { + const nodeSamples = new Map(); + for (let bi = blockStart; bi < blockEnd; bi++) { + const block = analytics.blocks[bi]; + if (!block) continue; + for (const ts in block) { + for (const nid in block[ts]) { + const nodeId = Number(nid); + nodeSamples.set(nodeId, (nodeSamples.get(nodeId) || 0) + block[ts][nid]); + } + } + } + return nodeSamples; +} + +/** Compute per-frame aggregated samples across all nodes with the same frame_id */ +export function aggregateByFrame( + analytics: Profile, + nodeSelf: Map, +): Map { + const tree = analytics.context_tree; + const frames = analytics.frame_map.frame_id_to_frame; + + const nodeTotal = new Map(); + function total(nid: number): number { + if (nodeTotal.has(nid)) return nodeTotal.get(nid)!; + let t = nodeSelf.get(nid) || 0; + for (const cid of Object.values(tree[nid].children)) t += total(cid); + nodeTotal.set(nid, t); + return t; + } + for (let i = 0; i < tree.length; i++) total(i); + + const byFrame = new Map(); + for (let nid = 0; nid < tree.length; nid++) { + const name = frames[tree[nid].frame_id]?.name || "[unknown]"; + const entry = byFrame.get(name) || { self: 0, total: 0 }; + entry.self += nodeSelf.get(nid) || 0; + entry.total += nodeTotal.get(nid) || 0; + byFrame.set(name, entry); + } + return byFrame; +} + +/** Aggregate by full stack path (semicolon-separated) */ +export function aggregateByStack( + analytics: Profile, + nodeSelf: Map, + reverse?: boolean, +): Map { + const tree = analytics.context_tree; + const frames = analytics.frame_map.frame_id_to_frame; + + const nodeTotal = new Map(); + function total(nid: number): number { + if (nodeTotal.has(nid)) return nodeTotal.get(nid)!; + let t = nodeSelf.get(nid) || 0; + for (const cid of Object.values(tree[nid].children)) t += total(cid); + nodeTotal.set(nid, t); + return t; + } + for (let i = 0; i < tree.length; i++) total(i); + + // TODO: standard and reverse path building can be optimized further + if (reverse) { + // Build reversed paths matching buildReverseFlamegraph's merge-by-name behavior. + // The reversed flamegraph merges callers by name at each level, so we must do the same. + const byStack = new Map(); + for (let nid = 0; nid < tree.length; nid++) { + const w = nodeSelf.get(nid) || 0; + if (w === 0) continue; + const path: string[] = []; + let cur: number | null = nid; + while (cur !== null && cur !== 0) { + path.push(frames[tree[cur].frame_id]?.name || "[unknown]"); + cur = tree[cur].parent; + } + // Each prefix of the reversed path corresponds to a node in the reversed flamegraph + for (let i = 0; i < path.length; i++) { + const key = path.slice(0, i + 1).join(";"); + const entry = byStack.get(key) || { self: 0, total: 0 }; + entry.total += w; + if (i === 0) entry.self += w; + byStack.set(key, entry); + } + } + return byStack; + } + + const nodePath = new Array(tree.length); + function buildPath(nid: number): string { + if (nodePath[nid] != null) return nodePath[nid]; + const name = frames[tree[nid].frame_id]?.name || "[unknown]"; + const parent = tree[nid].parent; + nodePath[nid] = parent != null && parent !== 0 ? buildPath(parent) + ";" + name : name; + return nodePath[nid]; + } + + const byStack = new Map(); + for (let nid = 0; nid < tree.length; nid++) { + const path = buildPath(nid); + const entry = byStack.get(path) || { self: 0, total: 0 }; + entry.self += nodeSelf.get(nid) || 0; + entry.total += nodeTotal.get(nid) || 0; + byStack.set(path, entry); + } + return byStack; +} + +/** Get the full stack path for a FlamegraphNode by walking up parents */ +export function getNodeStackPath(node: FlamegraphNode): string { + const parts: string[] = []; + let cur: FlamegraphNode | null = node; + while (cur && cur.depth > 0) { + parts.push(cur.name); + cur = cur.parent; + } + return parts.reverse().join(";"); +} + +export interface FlamegraphNode { + frameId: number; + name: string; + selfSamples: number; + totalSamples: number; + children: FlamegraphNode[]; + parent: FlamegraphNode | null; + depth: number; + x: number; + w: number; +} + +/** Build a flamegraph tree. If reverse, build inverted tree (leaf → root callers). */ +export function buildFlamegraph( + analytics: Profile, + blockStart: number, + blockEnd: number, + filterRe: RegExp | null, + reverse: boolean, +): FlamegraphNode | null { + const tree = analytics.context_tree; + const frames = analytics.frame_map.frame_id_to_frame; + if (tree.length === 0) return null; + + const nodeSelf = computeNodeSelfSamples(analytics, blockStart, blockEnd); + + const stackMatched = new Array(tree.length).fill(!filterRe); + if (filterRe) { + function walk(nid: number, ancestorMatched: boolean) { + const name = frames[tree[nid].frame_id]?.name || ""; + const matched = ancestorMatched || filterRe!.test(name); + stackMatched[nid] = matched; + for (const cid of Object.values(tree[nid].children)) walk(cid, matched); + } + walk(0, false); + } + + if (reverse) return buildReverseFlamegraph(analytics, nodeSelf, stackMatched); + return buildForwardFlamegraph(analytics, nodeSelf, stackMatched); +} + +function buildForwardFlamegraph( + analytics: Profile, + nodeSelf: Map, + stackMatched: boolean[], +): FlamegraphNode | null { + const tree = analytics.context_tree; + const frames = analytics.frame_map.frame_id_to_frame; + + const totalSamples = new Map(); + function computeTotal(nodeId: number): number { + if (totalSamples.has(nodeId)) return totalSamples.get(nodeId)!; + const selfPart = stackMatched[nodeId] ? nodeSelf.get(nodeId) || 0 : 0; + let total = selfPart; + for (const cid of Object.values(tree[nodeId].children)) total += computeTotal(cid); + totalSamples.set(nodeId, total); + return total; + } + computeTotal(0); + + const rootTotal = totalSamples.get(0) || 0; + if (rootTotal === 0) return null; + + function buildNode(nodeId: number, depth: number, x: number, parent: FlamegraphNode | null): FlamegraphNode { + const node = tree[nodeId]; + const total = totalSamples.get(nodeId) || 0; + const self_ = stackMatched[nodeId] ? nodeSelf.get(nodeId) || 0 : 0; + const name = frames[node.frame_id]?.name || "[unknown]"; + + const result: FlamegraphNode = { + frameId: node.frame_id, + name, + selfSamples: self_, + totalSamples: total, + children: [], + parent, + depth, + x, + w: total, + }; + + let childX = x; + const childEntries = Object.entries(node.children) + .map(([, cid]) => ({ id: cid, total: totalSamples.get(cid) || 0 })) + .filter((c) => c.total > 0) + .sort((a, b) => b.total - a.total); + for (const child of childEntries) { + result.children.push(buildNode(child.id, depth + 1, childX, result)); + childX += child.total; + } + return result; + } + return buildNode(0, 0, 0, null); +} + +function buildReverseFlamegraph( + analytics: Profile, + nodeSelf: Map, + stackMatched: boolean[], +): FlamegraphNode | null { + const tree = analytics.context_tree; + const frames = analytics.frame_map.frame_id_to_frame; + + const stacks: { path: string[]; weight: number }[] = []; + for (let nid = 0; nid < tree.length; nid++) { + const w = nodeSelf.get(nid) || 0; + if (w === 0 || !stackMatched[nid]) continue; + const path: string[] = []; + let cur: number | null = nid; + while (cur !== null && cur !== 0) { + path.push(frames[tree[cur].frame_id]?.name || "[unknown]"); + cur = tree[cur].parent; + } + stacks.push({ path, weight: w }); + } + if (stacks.length === 0) return null; + + interface MutNode { + name: string; + self: number; + total: number; + children: Map; + } + const root: MutNode = { name: "[root]", self: 0, total: 0, children: new Map() }; + for (const { path, weight } of stacks) { + root.total += weight; + let cur = root; + for (const frame of path) { + let child = cur.children.get(frame); + if (!child) { + child = { name: frame, self: 0, total: 0, children: new Map() }; + cur.children.set(frame, child); + } + child.total += weight; + cur = child; + } + cur.self += weight; + } + + let nextFrameId = 0; + function convert(n: MutNode, depth: number, x: number, parent: FlamegraphNode | null): FlamegraphNode { + const result: FlamegraphNode = { + frameId: nextFrameId++, + name: n.name, + selfSamples: n.self, + totalSamples: n.total, + children: [], + parent, + depth, + x, + w: n.total, + }; + let childX = x; + const sorted = Array.from(n.children.values()).sort((a, b) => b.total - a.total); + for (const child of sorted) { + result.children.push(convert(child, depth + 1, childX, result)); + childX += child.total; + } + return result; + } + return convert(root, 0, 0, null); +} + +/** Flatten flamegraph tree into a list for rendering */ +export function flattenFlamegraph(root: FlamegraphNode): FlamegraphNode[] { + const result: FlamegraphNode[] = []; + const stack = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + result.push(node); + for (let i = node.children.length - 1; i >= 0; i--) stack.push(node.children[i]); + } + return result; +} + +// --- State logic hooks --- + +/** Compile a regex string into a RegExp, returning null on empty or invalid input. */ +export function useRegex(source: string): RegExp | null { + return React.useMemo(() => { + if (!source) return null; + try { + return new RegExp(source); + } catch { + return null; + } + }, [source]); +} + +/** Track the content width of a container element via ResizeObserver. */ +export function useContainerWidth(ref: React.RefObject, initial = 800): number { + const [width, setWidth] = React.useState(initial); + + React.useEffect(() => { + const el = ref.current; + if (!el) return; + const obs = new ResizeObserver((entries) => { + for (const entry of entries) setWidth(entry.contentRect.width); + }); + obs.observe(el); + return () => obs.disconnect(); + }, [ref]); + + return width; +} + +export interface ZoomLevel { + id: string; + label: string; + groupSize: number; + rows: number; + cellMs: number; + cellPx: number; +} + +export interface HeatmapLayout { + zoomLevels: ZoomLevel[]; + zoomId: string; + setZoomId: (id: string) => void; + zoom: ZoomLevel; + groupSize: number; + rowsPerCol: number; + cellSize: number; + heatmapGridHeight: number; + bufferCellsBefore: number; + totalCells: number; + numColumns: number; + numDataCells: number; + alignedStartMs: number; +} + +/** + * Compute heatmap zoom levels, UTC-aligned buffer geometry, and cell layout. + */ +export function useHeatmapLayout( + numBlocks: number, + blockWidthMs: number, + alignedStartTimeMs: number, + containerWidth: number, +): HeatmapLayout { + const zoomLevels = React.useMemo(() => { + const blocksPerSec = Math.max(1, Math.round(1000 / blockWidthMs)); + return [ + { + id: "0", + label: `1 sec : ${blockWidthMs} ms`, + groupSize: 1, + rows: blocksPerSec, + cellMs: blockWidthMs, + cellPx: 6, + }, + { id: "1", label: "1 min : 1 sec", groupSize: blocksPerSec, rows: 60, cellMs: 1000, cellPx: 5 }, + { id: "2", label: "5 min : 5 sec", groupSize: blocksPerSec * 5, rows: 60, cellMs: 5000, cellPx: 3 }, + ]; + }, [blockWidthMs]); + + const initialZoom = React.useMemo(() => { + for (let i = 0; i < zoomLevels.length; i++) { + const z = zoomLevels[i]; + const cols = Math.ceil(numBlocks / z.groupSize / z.rows); + if (cols * z.cellPx <= containerWidth || i === zoomLevels.length - 1) return z.id; + } + return zoomLevels[0].id; + }, [numBlocks, zoomLevels, containerWidth]); + + const [zoomId, setZoomId] = React.useState(initialZoom); + React.useEffect(() => { + setZoomId(initialZoom); + }, [initialZoom]); + + const zoom = zoomLevels.find((z) => z.id === zoomId) || zoomLevels[0]; + const groupSize = zoom.groupSize; + const rowsPerCol = zoom.rows; + const cellSize = zoom.cellPx; + const heatmapGridHeight = rowsPerCol * cellSize; + const numDataCells = Math.ceil(numBlocks / groupSize); + + const { bufferCellsBefore, totalCells, numColumns, alignedStartMs } = React.useMemo(() => { + const colMs = zoom.cellMs * rowsPerCol; + const colAlignedStart = Math.floor(alignedStartTimeMs / colMs) * colMs; + const extraCols = alignedStartTimeMs === colAlignedStart ? 5 : 4; + const alignedStart = colAlignedStart - extraCols * colMs; + const bufferMs = alignedStartTimeMs - alignedStart; + const bufferCells = Math.floor(bufferMs / zoom.cellMs); + const dataEndMs = alignedStartTimeMs + numBlocks * blockWidthMs; + const colAlignedEnd = Math.ceil(dataEndMs / colMs) * colMs; + const extraColsEnd = dataEndMs === colAlignedEnd ? 5 : 4; + const alignedEnd = colAlignedEnd + extraColsEnd * colMs; + const totalMs = alignedEnd - alignedStart; + const total = Math.round(totalMs / zoom.cellMs); + return { + bufferCellsBefore: bufferCells, + totalCells: total, + numColumns: Math.max(1, Math.round(total / rowsPerCol)), + alignedStartMs: alignedStart, + }; + }, [numBlocks, rowsPerCol, zoom, alignedStartTimeMs, blockWidthMs]); + + return { + zoomLevels, + zoomId, + setZoomId, + zoom, + groupSize, + rowsPerCol, + cellSize, + heatmapGridHeight, + bufferCellsBefore, + totalCells, + numColumns, + numDataCells, + alignedStartMs, + }; +} + +export interface HeatmapSelectionState { + selection: [number, number]; + setSelection: React.Dispatch>; + baselineSelection: [number, number] | null; + setBaselineSelection: (v: [number, number] | null) => void; + dragStart: number | null; + dragEnd: number | null; + dragIsBaseline: boolean; + setDragStart: (v: number | null) => void; + setDragEnd: (v: number | null) => void; + setDragIsBaseline: (v: boolean) => void; + resetSelection: () => void; +} + +/** + * Manages selection, baseline selection, and drag state for the heatmap. + * Publishes selection to shared context when this is the base run. + * Snaps selection to cell boundaries on groupSize change. + */ +export function useHeatmapSelection( + numBlocks: number, + groupSize: number, + isBaseRun: boolean | undefined, + setBaseRunSelection?: (v: [number, number]) => void, +): HeatmapSelectionState { + const [selection, setSelection] = React.useState<[number, number]>([0, numBlocks]); + const [baselineSelection, setBaselineSelection] = React.useState<[number, number] | null>(null); + const [dragStart, setDragStart] = React.useState(null); + const [dragEnd, setDragEnd] = React.useState(null); + const [dragIsBaseline, setDragIsBaseline] = React.useState(false); + + React.useEffect(() => { + if (isBaseRun && setBaseRunSelection) setBaseRunSelection(selection); + }, [isBaseRun, selection, setBaseRunSelection]); + + React.useEffect(() => { + setSelection(([s, e]) => { + const alignedS = Math.floor(s / groupSize) * groupSize; + const alignedE = Math.min(numBlocks, Math.ceil(e / groupSize) * groupSize); + if (alignedS >= numBlocks) return [0, numBlocks]; + if (alignedS === s && alignedE === e) return [s, e]; + return [alignedS, alignedE]; + }); + }, [groupSize, numBlocks]); + + const resetSelection = React.useCallback(() => { + setSelection([0, numBlocks]); + setBaselineSelection(null); + }, [numBlocks]); + + return { + selection, + setSelection, + baselineSelection, + setBaselineSelection, + dragStart, + dragEnd, + dragIsBaseline, + setDragStart, + setDragEnd, + setDragIsBaseline, + resetSelection, + }; +} diff --git a/src/report_frontend/src/components/misc/DataNavigation.tsx b/src/report_frontend/src/components/misc/DataNavigation.tsx index 567c00d1..75fc026f 100644 --- a/src/report_frontend/src/components/misc/DataNavigation.tsx +++ b/src/report_frontend/src/components/misc/DataNavigation.tsx @@ -118,7 +118,16 @@ export function DataLink(props: { dataType: DataType; dataKey: string }) { * data key (metric name or key-value key) to show the data */ export function SamePageDataLink(props: { dataKey: string }) { - const { updateFilteringText } = useReportState(); + const { updateFilteringText, setSearchKey } = useReportState(); - return updateFilteringText(props.dataKey)}>{props.dataKey}; + return ( + { + updateFilteringText(props.dataKey); + setSearchKey(props.dataKey); + }} + > + {props.dataKey} + + ); } diff --git a/src/report_frontend/src/components/pages/ProfilingDataPage.tsx b/src/report_frontend/src/components/pages/ProfilingDataPage.tsx index 96ed70a1..3dbda92b 100644 --- a/src/report_frontend/src/components/pages/ProfilingDataPage.tsx +++ b/src/report_frontend/src/components/pages/ProfilingDataPage.tsx @@ -2,141 +2,242 @@ import React from "react"; import { DataPageProps, DataType, ProfilingData } from "../../definitions/types"; import { PROCESSED_DATA, RUNS } from "../../definitions/data-config"; import { - Box, - Cards, - CardsProps, - Pagination, + Container, SegmentedControl, SegmentedControlProps, - TextFilter, + Select, + SelectProps, + SpaceBetween, + Table, } from "@cloudscape-design/components"; -import { useCollection } from "@cloudscape-design/collection-hooks"; import Header from "@cloudscape-design/components/header"; import IframeGraph from "../data/IframeGraph"; +import ProfilePanel from "../data/profile-panel/ProfilePanel"; import { DATA_DESCRIPTIONS } from "../../definitions/data-descriptions"; import { RunHeader } from "../data/RunSystemInfo"; import { ReportHelpPanelLink } from "../misc/ReportHelpPanel"; - -const NUM_PROFILERS_PER_PAGE = 10; +import { ShowFindingsPanelButton } from "../analytics/FindingsSplitPanel"; +import { buildKeyValueTable } from "../data/KeyValueTable"; +import { ProfileAnalyticalFindings } from "../analytics/AnalyticalFindings"; +import { useReportState } from "../ReportStateProvider"; /** - * Collect all profile names across all runs and profilers and transform them into the format - * required by SegmentedControl + * Collect all profiler instance names across all runs for the Select dropdown */ -function getAllProfileNames(dataType: DataType): SegmentedControlProps.Option[] { - const profileNames: string[] = []; +function getAllInstanceNames(dataType: DataType): SelectProps.Option[] { + const sizes = new Map(); for (const runName of RUNS) { const reportData = PROCESSED_DATA[dataType].runs[runName] as ProfilingData; if (reportData == undefined) continue; - for (const profiler of Object.values(reportData.profilers)) { - for (const profileName in profiler.profiles) { - if (!profileNames.includes(profileName)) { - profileNames.push(profileName); - } + for (const [name, profiler] of Object.entries(reportData.profilers)) { + if (!sizes.has(name)) sizes.set(name, 0); + for (const profile of Object.values(profiler.profiles)) { + sizes.set(name, sizes.get(name) + (profile.profile_graph?.graph_size || 0)); } } } - return profileNames - .sort((a, b) => b.localeCompare(a)) - .map((profileName) => ({ - id: profileName, - text: DATA_DESCRIPTIONS[dataType].fieldDescriptions[profileName]?.readableName || profileName, - })); + return Array.from(sizes.keys()) + .sort((a, b) => sizes.get(b) - sizes.get(a)) + .map((name) => ({ label: name, value: name })); } /** - * Compute the list of profiler instance names that have the given profile, sorted by size + * Compute the list of profile names (for the SegmentedControl) sorted descending alphabetically */ -function getProfilerNames(dataType: DataType, profileName: string): string[] { - const profilerSizes = new Map(); +function getProfileNames(dataType: DataType, instanceName: string): SegmentedControlProps.Option[] { + const profileNames = new Set(); for (const runName of RUNS) { const reportData = PROCESSED_DATA[dataType].runs[runName] as ProfilingData; - if (reportData == undefined) continue; - for (const [instanceName, profiler] of Object.entries(reportData.profilers)) { - const profile = profiler.profiles[profileName]; - if (profile == undefined) continue; - if (!profilerSizes.has(instanceName)) profilerSizes.set(instanceName, 0); - profilerSizes.set(instanceName, profilerSizes.get(instanceName) + (profile.profile_graph?.graph_size || 0)); + const instance = reportData?.profilers?.[instanceName]; + if (instance == undefined) continue; + for (const profileName in instance.profiles) { + profileNames.add(profileName); } } - return Array.from(profilerSizes.keys()).sort((a, b) => profilerSizes.get(b) - profilerSizes.get(a)); + return Array.from(profileNames) + .sort((a, b) => b.localeCompare(a)) + .map((name) => ({ + id: name, + text: DATA_DESCRIPTIONS[dataType].fieldDescriptions[name]?.readableName || name, + })); } /** * This component renders the page for ProfilingData data type, where the graphs are rendered within Iframes */ export default function (props: DataPageProps) { - const allProfileNames = React.useMemo(() => getAllProfileNames(props.dataType), [props.dataType]); + const { searchKey, setSearchKey } = useReportState(); - const [profileName, setProfileName] = React.useState(allProfileNames[0]?.id || ""); + // Select dropdown: select Profiler + const instanceOptions = React.useMemo(() => getAllInstanceNames(props.dataType), [props.dataType]); + const [selectedInstance, setSelectedInstance] = React.useState(instanceOptions[0] || null); - const graphRowPercentage = Math.floor(100 / RUNS.length); - const cardDefinition: CardsProps.CardDefinition = { - header: (instanceName: string) =>
{instanceName}
, - sections: RUNS.map((runName) => ({ - id: runName, - header: , - content: (instanceName) => ( -
- -
- ), - width: graphRowPercentage, - })), - }; - - const sortedProfilerNames = getProfilerNames(props.dataType, profileName); - const { items, filteredItemsCount, collectionProps, filterProps, paginationProps } = useCollection( - sortedProfilerNames, - { - filtering: { - filteringFunction: (item: string, filteringText: string) => - item.toLowerCase().includes(filteringText.toLowerCase()), - empty: No profilers were collected, - noMatch: No profilers found, - }, - pagination: { pageSize: Math.floor(NUM_PROFILERS_PER_PAGE / RUNS.length) }, - }, + const instanceName = selectedInstance?.value || ""; + + // SegmentedControl: profile within the selected Profiler + const profileOptions = React.useMemo( + () => getProfileNames(props.dataType, instanceName), + [props.dataType, instanceName], ); + const [selectedProfile, setSelectedProfile] = React.useState(profileOptions[0]?.id || ""); + + // Page-level mode toggle: "profile" (native profile) or "iframe" (legacy SVG/HTML) + const [pageMode, setPageMode] = React.useState<"profile" | "iframe">("profile"); + + const graphRowPercentage = Math.floor(100 / RUNS.length); + + // Reset profile selection when Profiler changes + React.useEffect(() => { + if (selectedProfile) { + const match = profileOptions.find((opt) => opt.id === selectedProfile); + setSelectedProfile(match?.id || profileOptions[0]?.id || ""); + } else { + setSelectedProfile(profileOptions[0]?.id || ""); + } + }, [instanceName]); + + // Handle searchKey from DataLink navigation + React.useEffect(() => { + if (searchKey) { + const matchingInstance = instanceOptions.find((opt) => opt.value === searchKey); + if (matchingInstance) { + setSelectedInstance(matchingInstance); + setSearchKey(""); + } + } + }, [searchKey, instanceOptions]); + + // Check if any run has profile data for the current instance/profile + const hasProfileData = React.useMemo(() => { + if (!instanceName || !selectedProfile) return false; + return RUNS.some((runName) => { + const runData = PROCESSED_DATA[props.dataType]?.runs[runName] as ProfilingData | undefined; + const profile = runData?.profilers?.[instanceName]?.profiles?.[selectedProfile]; + return profile?.blocks?.length > 0; + }); + }, [props.dataType, instanceName, selectedProfile]); + + // Metadata comes from the profiler instance + const { tableItems, tableColumnDefinitions } = React.useMemo(() => { + if (!instanceName) return { tableItems: [], tableColumnDefinitions: [] }; + const dataByRun = new Map( + RUNS.map((runName) => { + const runData = PROCESSED_DATA[props.dataType]?.runs[runName] as ProfilingData | undefined; + const metadata = runData?.profilers?.[instanceName]?.metadata; + return [runName, metadata] as const; + }), + ); + return buildKeyValueTable(dataByRun); + }, [props.dataType, instanceName]); return ( - } - stickyHeader={true} - header={ -
} - actions={ - allProfileNames.length > 1 && ( + +
} + actions={ + + + {profileOptions.length > 1 && ( setProfileName(detail.selectedId)} - options={allProfileNames} + selectedId={selectedProfile} + onChange={({ detail }) => setSelectedProfile(detail.selectedId)} + options={profileOptions} /> - ) - } - > - {DATA_DESCRIPTIONS[props.dataType].readableName} -
- } - filter={ - - } - variant={"full-page"} - items={items} - cardDefinition={cardDefinition} - /> + )} +
+ } + > + {DATA_DESCRIPTIONS[props.dataType].readableName} +
+ +