Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
- parser
- performance
- responsive-design
- search-controls
- stack-details
- theme-consistency
- visual-regression
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ function App() {
height={600}
showHottestFrames={true}
showControls={true}
showSearch={true}
showStackDetails={true}
/>
)
Expand Down
1 change: 1 addition & 0 deletions src/cli-template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ window.renderReactPprofFlameGraph = (containerId: string, options) => {
textColor={TEXT_COLOR}
showHottestFrames={true}
showControls={true}
showSearch={true}
showStackDetails={true}
hottestFramesHeight={12}
/>
Expand Down
1 change: 1 addition & 0 deletions src/components/FlameGraph.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ The FlameGraph component renders performance profiling data from Go's pprof form
| `scrollZoomSpeed` | `number` | `0.05` | Speed of scroll zoom |
| `scrollZoomInverted` | `boolean` | `false` | Invert scroll zoom direction |
| `selectedFrameId` | `string \| null` | - | ID of the currently selected frame |
| `highlightedFrameIds` | `string[] \| null` | - | IDs of frames to highlight (e.g. search matches); matches render at full opacity, other frames are dimmed. `null` clears the highlight |
| `onFrameClick` | `function` | - | Callback when a frame is clicked |
| `onZoomChange` | `function` | - | Callback when zoom level changes |
| `onAnimationComplete` | `function` | - | Callback when animations complete |
Expand Down
15 changes: 15 additions & 0 deletions src/components/FlameGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface FlameGraphProps {
scrollZoomSpeed?: number
scrollZoomInverted?: boolean
selectedFrameId?: string | null
highlightedFrameIds?: string[] | null
showAppCodeOnly?: boolean
onFrameClick?: (frame: FrameData | null, stackTrace: FlameNode[], children: FlameNode[]) => void
onZoomChange?: (zoomLevel: number) => void
Expand All @@ -45,6 +46,7 @@ export const FlameGraph = forwardRef<{ rendererRef: React.RefObject<FlameGraphRe
scrollZoomSpeed = 0.05,
scrollZoomInverted = false,
selectedFrameId,
highlightedFrameIds,
showAppCodeOnly = false,
onFrameClick,
onZoomChange: _onZoomChange,
Expand Down Expand Up @@ -133,6 +135,12 @@ export const FlameGraph = forwardRef<{ rendererRef: React.RefObject<FlameGraphRe

renderer.render()

// Re-apply any active highlight (e.g. search matches) since the
// renderer was just recreated
if (highlightedFrameIds !== undefined) {
renderer.setHighlightedFrames(highlightedFrameIds)
}

// Update scrollable and pannable state when renderer changes
setCanPan(renderer.canPan())

Expand Down Expand Up @@ -179,6 +187,13 @@ export const FlameGraph = forwardRef<{ rendererRef: React.RefObject<FlameGraphRe
}
}, [selectedFrameId, hoveredFrame])

// Handle external frame highlighting (e.g. search matches)
useEffect(() => {
if (rendererRef.current && highlightedFrameIds !== undefined) {
rendererRef.current.setHighlightedFrames(highlightedFrameIds)
}
}, [highlightedFrameIds])

useEffect(() => {
if (rendererRef.current && canvasRef.current && containerRef.current) {
rendererRef.current.setFramePadding(framePadding)
Expand Down
77 changes: 54 additions & 23 deletions src/components/FullFlameGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { FrameData, FlameNode, detectProfileMetadata, FlameGraphRenderer } from
import { HottestFramesBar, type FrameWithSelfTime } from './HottestFramesBar.js'
import { HottestFramesControls } from './HottestFramesControls.js'
import { FilterControls } from './FilterControls.js'
import { SearchControls } from './SearchControls.js'
import { FrameDetails } from './FrameDetails.js'
import { FlameGraph } from './FlameGraph.js'
import { StackDetails } from './StackDetails.js'
Expand All @@ -18,6 +19,7 @@ export interface FullFlameGraphProps {
fontFamily?: string
showHottestFrames?: boolean
showControls?: boolean
showSearch?: boolean
showFrameDetails?: boolean
showStackDetails?: boolean
hottestFramesHeight?: number
Expand All @@ -34,6 +36,7 @@ export const FullFlameGraph: React.FC<FullFlameGraphProps> = ({
fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif',
showHottestFrames = true,
showControls = true,
showSearch = true,
showFrameDetails = false,
showStackDetails = true,
hottestFramesHeight = 10,
Expand All @@ -45,6 +48,7 @@ export const FullFlameGraph: React.FC<FullFlameGraphProps> = ({
const [stackTrace, setStackTrace] = useState<any[]>([])
const [frameChildren, setFrameChildren] = useState<any[]>([])
const [showAppCodeOnly, setShowAppCodeOnly] = useState(showAppCodeOnlyProp)
const [highlightedFrameIds, setHighlightedFrameIds] = useState<string[] | null>(null)

// Reference to FlameGraph's renderer
const flameGraphRef = useRef<{ rendererRef: React.RefObject<FlameGraphRenderer> }>(null)
Expand Down Expand Up @@ -195,6 +199,10 @@ export const FullFlameGraph: React.FC<FullFlameGraphProps> = ({
// Use the correct value index from profile metadata
const valueIndex = profileMetadata?.sampleTypeIndex ?? 0

// pprof-format wraps the string table in a StringTable object; fall
// back to treating it as a plain array for pre-decoded profiles
const stringTable: string[] = (profile?.stringTable as any)?.strings ?? (profile?.stringTable as any) ?? []

// Process each sample in the profile
if (profile && profile.sample) {
profile.sample.forEach((sample: any) => {
Expand All @@ -207,26 +215,33 @@ export const FullFlameGraph: React.FC<FullFlameGraphProps> = ({
root.sampleCount += 1 // Count each sample

// Build the stack for this sample
const stack: string[] = []
const stack: Array<{ name: string, fileName?: string, lineNumber?: number }> = []
sample.locationId.forEach((locationId: any) => {
const location = profile.location?.find((loc: any) => loc.id === locationId)
if (location && location.line && location.line.length > 0) {
const func = profile.function?.find((f: any) => f.id === location.line?.[0]?.functionId)
const line = location.line[0]
const func = profile.function?.find((f: any) => f.id === line?.functionId)
if (func) {
const nameIndex = Number(func.name)
const funcName = (profile.stringTable as any)?.[nameIndex] || `func_${func.id}`
stack.push(funcName)
const funcName = stringTable[nameIndex] || `func_${func.id}`
const fileIndex = Number(func.filename)
stack.push({
name: funcName,
fileName: stringTable[fileIndex],
lineNumber: line?.line ? Number(line.line) : undefined
})
}
}
})

// Traverse/create the tree based on the stack
stack.reverse().forEach((funcName, depth) => {
stack.reverse().forEach((frame, depth) => {
const funcName = frame.name
let child = currentNode.children.find(c => c.name === funcName)
if (!child) {
const nodeId = currentNode.id === 'root'
? funcName
: `${currentNode.id}/${funcName}`
// Match the renderer's node ID scheme (root/parent/child) so
// selections resolve against frameMap and the flame graph
const nodeId = `${currentNode.id}/${funcName}`
child = {
id: nodeId,
name: funcName,
Expand All @@ -238,7 +253,9 @@ export const FullFlameGraph: React.FC<FullFlameGraphProps> = ({
depth: depth + 1,
x: 0,
width: 0,
selfWidth: 0
selfWidth: 0,
fileName: frame.fileName,
lineNumber: frame.lineNumber
}
currentNode.children.push(child)
}
Expand Down Expand Up @@ -327,7 +344,7 @@ export const FullFlameGraph: React.FC<FullFlameGraphProps> = ({
</div>
)}

{(showControls || showFrameDetails) && (
{(showControls || showSearch || showFrameDetails) && (
<div style={{
display: 'flex',
justifyContent: 'space-between',
Expand All @@ -336,19 +353,32 @@ export const FullFlameGraph: React.FC<FullFlameGraphProps> = ({
minHeight: '40px',
gap: '16px',
}}>
{showControls && (
{(showControls || showSearch) && (
<div style={{ flex: '0 0 auto', display: 'flex', gap: '16px', alignItems: 'center' }}>
<HottestFramesControls
profile={profile}
selectedFrame={selectedFrame}
onFrameSelect={handleFrameSelection}
textColor={textColor}
/>
<FilterControls
showAppCodeOnly={showAppCodeOnly}
onToggle={setShowAppCodeOnly}
textColor={textColor}
/>
{showControls && (
<>
<HottestFramesControls
profile={profile}
selectedFrame={selectedFrame}
onFrameSelect={handleFrameSelection}
textColor={textColor}
/>
<FilterControls
showAppCodeOnly={showAppCodeOnly}
onToggle={setShowAppCodeOnly}
textColor={textColor}
/>
</>
)}
{showSearch && (
<SearchControls
frames={allFramesFlat}
selectedFrame={selectedFrame}
onFrameSelect={handleFrameSelection}
onMatchesChange={setHighlightedFrameIds}
textColor={textColor}
/>
)}
</div>
)}

Expand Down Expand Up @@ -377,6 +407,7 @@ export const FullFlameGraph: React.FC<FullFlameGraphProps> = ({
textColor={textColor}
fontFamily={fontFamily}
selectedFrameId={selectedFrameId}
highlightedFrameIds={highlightedFrameIds}
showAppCodeOnly={showAppCodeOnly}
onFrameClick={handleFrameSelection}
/>
Expand Down
64 changes: 64 additions & 0 deletions src/components/SearchControls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# SearchControls

A search box for finding frames in the flame graph by function name or file name, with previous/next navigation through the matches.

## Purpose

The SearchControls component lets users locate specific functions in large profiles. Typing a query shows how many frames match, and the navigation buttons (or Enter / Shift+Enter) cycle through the matches hottest-first, selecting each frame in the flame graph so it is highlighted and its stack details are shown.

## Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `frames` | `FlameNode[]` | **required** | All frames in the flame graph to search through |
| `selectedFrame` | `FrameData \| null` | `undefined` | The currently selected frame, used to track the match position |
| `onFrameSelect` | `function` | `undefined` | Callback when a matching frame is selected |
| `onMatchesChange` | `function` | `undefined` | Callback with the IDs of all matching frames whenever the query changes (`null` when the query is empty); used to highlight matches in the flame graph |
| `textColor` | `string` | `'#ffffff'` | Text color for the input, buttons, and match count |
| `fontSize` | `string` | `'14px'` | Font size for text |
| `fontFamily` | `string` | System font stack | Font family for text rendering |
| `placeholder` | `string` | `'Search frames'` | Placeholder text for the search input |

## Behavior

- Matching is a case-insensitive substring test against each frame's function name and file name.
- While a query is active, all matching frames are highlighted in the flame graph: matches render at full opacity and non-matching frames are dimmed.
- Matches are ordered by total value (hottest first), so the first match is the most significant frame.
- **Enter** selects the next match, **Shift+Enter** the previous one, wrapping around at either end.
- **Escape** clears the query.
- The match counter shows `N of M` when the current selection is one of the matches, or the total match count otherwise.

## Usage Examples

### Basic Usage

```tsx
import React, { useState } from 'react'
import { SearchControls } from './SearchControls'

function FlameGraphViewer({ allFrames }) {
const [selectedFrame, setSelectedFrame] = useState(null)

return (
<div>
<SearchControls
frames={allFrames}
selectedFrame={selectedFrame}
onFrameSelect={setSelectedFrame}
/>
<FlameGraph
profile={profile}
selectedFrameId={selectedFrame ? selectedFrame.id : null}
/>
</div>
)
}
```

### Within FullFlameGraph

SearchControls is rendered automatically by `FullFlameGraph` unless disabled:

```tsx
<FullFlameGraph profile={profile} showSearch={false} />
```
87 changes: 87 additions & 0 deletions src/components/SearchControls.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useState } from 'react'
import { SearchControls } from './SearchControls.js'
import { FrameData, FlameNode } from '../renderer/index.js'

const makeNode = (id: string, name: string, value: number, depth: number, fileName?: string): FlameNode => ({
id,
name,
value,
selfValue: value / 2,
sampleCount: value,
selfSampleCount: value / 2,
children: [],
depth,
x: 0,
width: 0.5,
selfWidth: 0.25,
fileName,
})

const sampleFrames: FlameNode[] = [
makeNode('root', 'root', 1000, 0),
makeNode('root/main', 'main', 1000, 1, 'main.go'),
makeNode('root/main/serveHTTP', 'serveHTTP', 600, 2, 'server.go'),
makeNode('root/main/serveHTTP/handleRequest', 'handleRequest', 450, 3, 'server.go'),
makeNode('root/main/serveHTTP/handleRequest/parseJSON', 'parseJSON', 200, 4, 'parser.go'),
makeNode('root/main/runWorker', 'runWorker', 400, 2, 'worker.go'),
makeNode('root/main/runWorker/processJob', 'processJob', 350, 3, 'worker.go'),
]

const meta = {
title: 'SearchControls',
component: SearchControls,
parameters: {
layout: 'centered',
},
argTypes: {
onFrameSelect: { control: false },
},
decorators: [
(Story) => (
<div style={{ backgroundColor: '#1e1e1e', padding: '40px', minWidth: '500px' }}>
<Story />
</div>
),
],
} satisfies Meta<typeof SearchControls>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
args: {
frames: sampleFrames,
onFrameSelect: (frame) => console.log('Frame selected:', frame),
textColor: '#ffffff',
},
}

export const CustomColors: Story = {
args: {
frames: sampleFrames,
onFrameSelect: (frame) => console.log('Frame selected:', frame),
textColor: '#00ff00',
fontSize: '16px',
},
}

export const Interactive: Story = {
render: () => {
const [selectedFrame, setSelectedFrame] = useState<FrameData | null>(null)

return (
<div>
<SearchControls
frames={sampleFrames}
selectedFrame={selectedFrame}
onFrameSelect={setSelectedFrame}
textColor="#ffffff"
/>
<div style={{ marginTop: '20px', color: '#ffffff', textAlign: 'center' }}>
Selected: {selectedFrame ? selectedFrame.name : 'none'}
</div>
</div>
)
},
}
Loading
Loading