@clinic/heap-profiler
Version:
Programmable interface to Clinic.js Heap Profiler
638 lines (523 loc) • 19.7 kB
JavaScript
const d3 = require('./d3.js')
const events = require('events')
const htmlContentTypes = require('./html-content-types.js')
const debounce = require('lodash.debounce')
const DataTree = require('./data-tree.js')
const History = require('./history.js')
const spinner = require('@clinic/clinic-common/spinner')
const close = require('@clinic/clinic-common/icons/close')
const TooltipHtmlContent = require('./flame-graph-tooltip-content')
const getNoDataNode = require('./no-data-node.js')
const { button, walkthroughButton } = require('@clinic/clinic-common/base/index.js')
const wtSteps = require('./walkthrough-steps.js')
class Ui extends events.EventEmitter {
constructor (wrapperSelector) {
super()
this.flameWrapperSpinner = null
this.history = new History()
this.dataTree = null
this.highlightedNode = null
this.selectedNode = getNoDataNode()
this.zoomedNode = null
this.changedExclusions = {
toHide: new Set(),
toShow: new Set()
}
this.searchQuery = null
this.presentationMode = process.env.PRESENTATION_MODE === 'true'
this.tooltipHtmlContent = new TooltipHtmlContent(this)
this.wrapperSelector = wrapperSelector
this.exposedCSS = null
this.setExposedCSS()
this.sections = new Map()
this.createContent()
this.history.on('change', data => {
this.updateFromHistory(data)
})
}
pushHistory (opts = {}) {
this.history.push(
{
selectedNodeId: this.selectedNode && this.selectedNode.id,
zoomedNodeId: this.zoomedNode && this.zoomedNode.id,
exclude: this.dataTree.exclude,
search: this.searchQuery,
walkthroughIndex: this.helpButton.WtPlayer.currentStepIndex
},
opts
)
}
updateFromHistory (data) {
const { exclude, search, selectedNodeId, zoomedNodeId, walkthroughIndex } = data
let anyChanges = false
// Diff exclusion setting so FlameGraph can update.
exclude.forEach(name => {
if (this.dataTree.exclude.has(name)) return
this.changedExclusions.toHide.add(name)
anyChanges = true
})
this.dataTree.exclude.forEach(name => {
if (exclude.has(name)) return
this.changedExclusions.toShow.add(name)
anyChanges = true
})
this.dataTree.exclude = exclude
if (anyChanges) this.updateExclusions({ pushState: false, selectedNodeId, zoomedNodeId })
// Redraw before zooming to make sure these nodes are visible in the flame graph.
this.draw()
this.zoomNode(this.dataTree.getNodeById(zoomedNodeId), { pushState: false })
this.selectNode(this.dataTree.getNodeById(selectedNodeId), { pushState: false })
if (search !== this.searchQuery) {
this.search(search, { pushState: false })
}
if (walkthroughIndex !== undefined) {
this.helpButton.WtPlayer.skipTo(walkthroughIndex)
}
}
// Temporary e.g. on mouseover, erased on mouseout
highlightNode (node = null) {
if (node && node.id === 0) return
const changed = node !== this.highlightedNode
this.highlightedNode = node
if (changed) this.emit('highlightNode', node)
this.showNodeInfo(node || this.selectedNode)
}
// Persistent e.g. on click, then falls back to this after mouseout
selectNode (node = null, { pushState = true } = {}) {
if (!node || node.id === 0 || this.dataTree.isNodeExcluded(node)) {
if (!this.selectedNode) this.selectHottestNode({ pushState })
return
}
if (!node || node.id === 0) return
const changed = node !== this.selectedNode
this.selectedNode = node
if (changed) this.emit('selectNode', node)
this.scrollSelectedFrameIntoView()
this.showNodeInfo(node)
this.highlightNode(node)
if (pushState) this.pushHistory()
}
selectHottestNode (opts) {
const node = this.dataTree.getFrameByRank(0)
if (!node) return getNoDataNode()
// Prevent infinite loop if some future bug allows an invalid node to be returned here
const nodeInvalidMessage = ' node selected in selectHottestNode'
if (node.id === 0) throw new Error('Root' + nodeInvalidMessage)
if (this.dataTree.isNodeExcluded(node)) throw new Error('Excluded' + nodeInvalidMessage)
this.selectNode(node, opts)
}
zoomNode (node = null, { pushState = true, cb } = {}) {
if (!node && !this.zoomedNode) {
if (cb) cb()
return
}
// Zoom out if zooming in on already-zoomed node, or zoom target is excluded
if (!node || node === this.zoomedNode || this.dataTree.isNodeExcluded(node)) node = null
this.zoomedNode = node
this.emit('zoomNode', node, cb)
if (node && node !== this.selectedNode) {
this.selectNode(node, { pushState })
} else if (pushState) {
this.pushHistory()
}
this.scrollSelectedFrameIntoView()
}
clearSearch ({ pushState = true } = {}) {
this.searchQuery = null
this.emit('clearSearch')
if (pushState) {
this.pushHistory()
}
}
search (query, { pushState = true } = {}) {
if (!query) {
if (this.searchQuery) this.clearSearch({ pushState })
return
}
this.emit('search', query)
const prevQuery = this.searchQuery
this.searchQuery = query
if (pushState) {
this.pushHistory({
// If the new query is just the old one with more characters,
// the user was probably still typing. instead of pushing a new entry,
// potentially resulting in many entries for a single query, replace the previous entry.
replace: prevQuery && query.startsWith(prevQuery)
})
}
}
setPresentationMode (mode) {
this.presentationMode = mode
// switching the class on the html element
document.documentElement.classList.toggle('presentation-mode', mode)
this.setExposedCSS()
this.emit('presentationMode', mode)
}
/**
* Sections and content
**/
createContent () {
this.mainElement = document.querySelector(this.wrapperSelector)
this.uiContainer = new htmlContentTypes.HtmlContent(
null,
{
element: this.mainElement,
id: 'one-col-layout'
},
this,
this.wrapperSelector
)
// creating the tooltip instance that the Ui's components can share
const tooltip = this.uiContainer.addContent('Tooltip', {
htmlElementType: 'div',
isHoverOverridden: true,
id: 'ui-tooltip'
})
this.tooltip = tooltip
const toolbar = this.uiContainer.addContent(undefined, {
id: 'toolbar',
htmlElementType: 'section'
})
this.stackBar = toolbar.addContent('StackBar', {
id: 'stack-bar',
tooltip,
tooltipHtmlContent: this.tooltipHtmlContent
})
const toolbarTopPanel = toolbar.addContent(undefined, {
id: 'toolbar-top-panel'
})
toolbarTopPanel.addContent('SelectionControls', {
id: 'selection-controls',
customTooltip: tooltip
})
this.infoBox = toolbar.addContent('InfoBox', {
id: 'info-box',
customTooltip: tooltip
})
const getZoomFactor = () => {
// getting the zoomFactor when the viewport is larger than 600px
// and as long as the width / height proportion equals to 16/9
const minWidth = 600
if (window.innerWidth > minWidth) {
const size = Math.min(window.innerWidth, (window.innerHeight * 16) / 9)
const baseFactor = (size - minWidth) / 250
const bonus = this.presentationMode ? 1.5 : 1
return Math.round(baseFactor * bonus)
}
return 0
}
this.mainContent = this.uiContainer.addContent('HtmlContent', {
id: 'main-content'
})
const flameWrapper = this.mainContent.addContent('FlameGraph', {
id: 'flame-main',
htmlElementType: 'section',
customTooltip: tooltip,
zoomFactor: getZoomFactor(),
tooltipHtmlContent: this.tooltipHtmlContent
})
this.flameWrapper = flameWrapper
this.sideBar = this.mainContent.addContent('SideBar', {
id: 'side-bar',
animationEnd: () => {
const zoomFactor = getZoomFactor()
flameWrapper.resize(zoomFactor)
}
})
this.sideBar.addContent('FiltersContent', {
classNames: 'filters-options',
getSpinner: () => this.flameWrapperSpinner
})
this.footer = this.uiContainer.addContent(undefined, {
id: 'footer',
htmlElementType: 'section'
})
// mobile search-box
this.mSearchBoxWrapper = this.footer.addContent(undefined, {
id: 'm-search-box-wrapper',
classNames: 'before-bp-2 m-search-box-wrapper'
})
this.mSearchBoxWrapper.addContent('SearchBox', {
id: 'm-search-box',
classNames: 'inline-panel'
})
this.footer.addContent('FiltersContainer', {
id: 'filters-bar',
toggleSideBar: this.toggleSideBar,
getSpinner: () => this.flameWrapperSpinner
})
// TODO: add these ↴
// footer.addContent('FlameGraph', { id: 'flame-chronological' })
// footer.addContent('TimeFilter')
const reDrawStackBar = debounce(() => this.stackBar.draw(this.highlightedNode), 200)
let scrollContainer = null
this.scrollSelectedFrameIntoView = debounce(() => {
if (!scrollContainer) {
scrollContainer = flameWrapper.d3Element.select('.scroll-container').node()
}
let scrollAmount = scrollContainer.scrollHeight
if (this.selectedNode && this.selectedNode.category !== 'none') {
const viewportHeight = scrollContainer.clientHeight
const rect = this.flameWrapper.getNodeRect(this.selectedNode)
scrollAmount = rect.y - viewportHeight * 0.4
// scrolling only if the frame is outside the viewport
if (rect.y - rect.height > scrollContainer.scrollTop && rect.y < scrollContainer.scrollTop + viewportHeight) { return }
}
if (scrollContainer.scrollTo) {
scrollContainer.scrollTo({
top: scrollAmount,
behavior: 'smooth'
})
} else {
// Fallback for MS Edge
scrollContainer.scrollTop = scrollAmount
}
}, 200)
const setFontSize = zoomFactor => {
// increasing the font-size as the screen gets wider...
document.documentElement.style.fontSize = 0.625 + zoomFactor / 16 + 'em'
}
setFontSize(getZoomFactor())
// flameWrapper.
window.addEventListener('resize', () => {
const zoomFactor = getZoomFactor()
flameWrapper.resize(zoomFactor)
this.scrollSelectedFrameIntoView()
reDrawStackBar()
setFontSize(zoomFactor)
})
window.addEventListener('load', this.scrollSelectedFrameIntoView)
this.on('presentationMode', () => {
const zoomFactor = getZoomFactor()
flameWrapper.resize(zoomFactor)
setFontSize(zoomFactor)
this.scrollSelectedFrameIntoView()
})
}
addSection (id, options = {}) {
options.id = id
const section = this.uiContainer.addContent('HtmlContent', options)
section.ui = this
this.sections.set(id, section)
return section
}
getContentClass (className) {
const ContentClass = htmlContentTypes[className]
if (typeof ContentClass !== 'function') {
throw new Error(`HTML content class "${className}" is ${typeof ContentClass}`)
}
return ContentClass
}
getLabelFromKey (key, singular = false) {
const keysToLabels = {
app: this.dataTree.appName || 'profiled application',
deps: singular ? 'Dependency' : 'Dependencies',
core: 'Node JS',
wasm: 'WebAssembly',
'is:inlinable': 'Inlinable',
'is:init': 'Init',
'all-v8': 'V8',
'all-v8:native': 'V8 native',
'all-v8:v8': 'V8 runtime',
'all-v8:cpp': 'V8 C++',
'all-v8:regexp': 'RegExp'
}
if (keysToLabels[key]) {
return keysToLabels[key]
}
const splitKey = key.split(':')
if (splitKey.length > 1) {
const type = splitKey[1]
return keysToLabels[type] || type
}
return key
}
getDescriptionFromKey (key) {
const keysToDescriptions = {
app: '<span>Functions in the code of the application being profiled.</span>',
deps: "<span>External modules in the application's node_modules directory.</span>",
core: '<span>JS functions in core Node.js APIs.</span>',
wasm: '<span>Compiled WebAssembly code.</span>',
'all-v8': `<span>The JavaScript engine used by default in Node.js.</span> ${this.createMoreInfoLink(
'https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-v8'
)}`,
'all-v8:v8': `<span>Operations in V8's implementation of JS.</span> ${this.createMoreInfoLink(
'https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-v8-runtime'
)}`,
'all-v8:native': `<span>JS compiled into V8, such as prototype methods and eval.</span> ${this.createMoreInfoLink(
'https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-v8-native'
)}`,
'all-v8:cpp': `<span>Native C++ operations called by V8, including shared libraries.</span> ${this.createMoreInfoLink(
'https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-v8-cpp'
)}`,
'all-v8:regexp': `<span>The RegExp notation is shown as the function name.</span> ${this.createMoreInfoLink(
'https://clinicjs.org/documentation/flame/09-advanced-controls/#controls-regexp'
)}`
}
if (keysToDescriptions[key]) {
return keysToDescriptions[key]
}
if (key.startsWith('deps:')) {
// TODO use actual path, this is incorrect for
// nested dependencies
return `./node_modules/${key.slice(5)}`
}
return null
}
setCodeAreaVisibility ({ codeArea, visible, pushState = true, isRecursing = false }) {
// Apply a single possible change to dataTree.exclude, updating what's necessary
let isChanged = false
if (codeArea.children && codeArea.children.length) {
const childrenChanged = codeArea.children.forEach(child =>
this.setCodeAreaVisibility({
codeArea: child,
visible,
pushState: false,
isRecursing: true
})
)
this.updateExclusions({ pushState })
return childrenChanged
} else {
const name = codeArea.excludeKey
if (visible) {
isChanged = this.dataTree.show(name)
if (isChanged) this.changedExclusions.toShow.add(name)
} else {
isChanged = this.dataTree.hide(name)
if (isChanged) this.changedExclusions.toHide.add(name)
}
if (isChanged && !isRecursing) this.updateExclusions({ pushState })
}
return isChanged
}
updateExclusions ({ initial, pushState = true, selectedNodeId, zoomedNodeId } = {}) {
this.dataTree.update(initial)
const selectedNodeNotShown =
this.selectedNode && (this.dataTree.isNodeExcluded(this.selectedNode) || this.selectedNode.category === 'none')
if (!initial && !selectedNodeId && selectedNodeNotShown) {
this.selectHottestNode()
}
const cb = () => {
if (!initial) this.emit('updateExclusions')
if (pushState) this.pushHistory()
}
// Zoom out before updating exclusions if the user excludes the node they're zoomed in on
if (!zoomedNodeId && this.zoomedNode && this.dataTree.isNodeExcluded(this.zoomedNode)) {
this.zoomNode(null, { cb })
} else {
cb()
}
}
setData (dataTree) {
this.dataTree = new DataTree(dataTree)
this.updateExclusions({ pushState: false, initial: true })
this.emit('setData')
this.history.setData(dataTree)
}
showNodeInfo (nodeData) {
this.infoBox.showNodeInfo(nodeData)
}
toggleMobileSearchBox (show = !this.mSearchBoxWrapper.d3Element.classed('show')) {
clearTimeout(this.mSearchBoxAutoHideHnd)
this.mSearchBoxWrapper.d3Element.classed('show', show)
if (show) this.mSearchBoxWrapper.d3Element.select('input').node().focus()
}
toggleSideBar (show = !this.sideBar.d3Element.classed('expand')) {
this.sideBar.toggle(show)
this.emit('sideBar', show)
}
/**
* Initialization and draw
**/
setExposedCSS () {
// TODO: When light / dark theme switch implemented, call this after each switch, before redraw
const computedStyle = window.getComputedStyle(document.body)
this.exposedCSS = {
app: computedStyle.getPropertyValue('--area-color-app').trim(),
deps: computedStyle.getPropertyValue('--area-color-deps').trim(),
core: computedStyle.getPropertyValue('--area-color-core').trim(),
wasm: computedStyle.getPropertyValue('--area-color-core').trim(),
'all-v8': computedStyle.getPropertyValue('--area-color-core').trim(),
'opposite-contrast': computedStyle.getPropertyValue('--opposite-contrast').trim(),
'max-contrast': computedStyle.getPropertyValue('--max-contrast').trim(),
'grey-blue': computedStyle.getPropertyValue('--grey-blue').trim(),
'primary-grey': computedStyle.getPropertyValue('--primary-grey').trim()
}
}
getFrameColor (nodeData, role, reverse = nodeData.highlight) {
if ((role === 'background' && !reverse) || (role === 'foreground' && reverse)) {
return this.exposedCSS['opposite-contrast']
}
if (this.dataTree.showOptimizationStatus) {
if (nodeData.isUnoptimized) return this.exposedCSS['max-contrast']
if (nodeData.isOptimized) return this.exposedCSS['primary-grey']
return this.exposedCSS['grey-blue']
} else {
return this.exposedCSS[nodeData.category]
}
}
initializeElements () {
// Cascades down tree in addContent() append/prepend order
this.uiContainer.initializeElements()
// auto hiding mobile search-box on blur if empty
this.mSearchBoxWrapper.d3Element.select('input').on('blur', (datum, index, nodes) => {
this.mSearchBoxAutoHideHnd = setTimeout(() => {
// this little delay is to avoid clashes between the 'blur' cb and clicking on the search button
if (nodes[index].value.trim() === '') {
this.toggleMobileSearchBox(false)
}
}, 300)
})
// adding the mSearchBox close button
this.mSearchBoxWrapper.d3Element.append(() =>
button({
leftIcon: close,
onClick: () => {
clearTimeout(this.mSearchBoxAutoHideHnd)
if (this.searchQuery === null) {
// close if empty
this.toggleMobileSearchBox(false)
} else {
// clear otherwise
this.clearSearch()
}
}
})
)
// // Remove ask button - This should be removed once the functionality is gone from Clinic
const askButton = d3.select('.nc-ask-button').node()
if (askButton) {
askButton.remove()
}
// walkthrough init
this.helpButton = walkthroughButton({
steps: wtSteps,
onProgress: () => {
this.pushHistory()
},
label: '<span class="before-bp-1">Guide</span><span class="after-bp-1">Show how to use this</span>',
title: 'Click to start the step-by-step UI features guide!'
})
// Place help button top right
d3.select('.nc-header__inner').append(() => this.helpButton.button)
this.flameWrapperSpinner = spinner.attachTo(document.querySelector('#flame-main'))
}
draw () {
// Cascades down tree in addContent() append/prepend order
this.uiContainer.draw()
this.changedExclusions.toHide.clear()
this.changedExclusions.toShow.clear()
// setting Presentation Mode
this.setPresentationMode(this.presentationMode)
}
createMoreInfoLink (href) {
return `
<a target="_blank" rel="noopener noreferrer" href="${href}" class="more-info">
More info
</a>
`
}
}
module.exports = Ui