diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5257f37474..afc7cc4009 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,9 +1,9 @@ { "name": "Firefox Profiler", - "image": "mcr.microsoft.com/devcontainers/javascript-node:22", + "image": "mcr.microsoft.com/devcontainers/javascript-node:24", "features": { "ghcr.io/devcontainers/features/node:1": { - "version": "22" + "version": "24" } }, "forwardPorts": [4242], diff --git a/.gitattributes b/.gitattributes index 02a029af7f..2db04380d0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,6 @@ +# Normalize line endings to LF on all platforms. +* text=auto eol=lf + # The following are not technically binary, but this makes it so that git does not try # and treat them like normal text. *.min.js binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 51e2f8ea6c..d51d70b842 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,5 +4,5 @@ /locales/en-US/**/*.ftl @firefox-devtools/performance-l10n # Automatically request reviews for the dependency updates -/package.json @canova -/yarn.lock @canova +/package.json @canova @fatadel +/yarn.lock @canova @fatadel diff --git a/.github/actions/setup-node-and-install/action.yml b/.github/actions/setup-node-and-install/action.yml index 85a3a8ea8e..ca5192e5d4 100644 --- a/.github/actions/setup-node-and-install/action.yml +++ b/.github/actions/setup-node-and-install/action.yml @@ -6,7 +6,7 @@ runs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '22.14' + node-version: '24.14' cache: 'yarn' - name: Install dependencies diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0b60c7eec..682b6c8d4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,8 +61,8 @@ jobs: - name: Build production run: yarn build-prod:quiet - - name: Build symbolicator CLI - run: yarn build-symbolicator-cli:quiet + - name: Build node tools + run: yarn build-node-tools licence-check: runs-on: ${{ matrix.os }} diff --git a/.github/workflows/l10n-sync.yml b/.github/workflows/l10n-sync.yml index b38183aea0..f59a296eae 100644 --- a/.github/workflows/l10n-sync.yml +++ b/.github/workflows/l10n-sync.yml @@ -35,7 +35,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '22.14' + node-version: '24.14' cache: 'yarn' - name: Configure git diff --git a/.gitignore b/.gitignore index 6d621273ed..a3bd91ed88 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ yarn-error.log node_modules/ .DS_Store dist/ +node-tools-dist/ public_html/ build-meta/ flow-coverage diff --git a/.nvmrc b/.nvmrc index 744ca17ec0..fd655f8a35 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.14 +24.14 diff --git a/.prettierrc.js b/.prettierrc.js index a425d3f761..ed20b91246 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -1,4 +1,5 @@ module.exports = { singleQuote: true, trailingComma: 'es5', + endOfLine: 'lf', }; diff --git a/bin/output-fixing-commands.js b/bin/output-fixing-commands.js index 262437fd81..22204d3dce 100644 --- a/bin/output-fixing-commands.js +++ b/bin/output-fixing-commands.js @@ -5,7 +5,7 @@ // This file runs before linting commands and intercept errors so that more // friendly errors can be output. -const cp = require('child_process'); +const spawn = require('cross-spawn'); const fixingCommands = { lint: 'lint-fix', @@ -21,14 +21,22 @@ const currentScriptName = process.env.npm_lifecycle_event; // Redirect the main lint command, but not individual commands. if (currentScriptName === 'lint' && command.includes('--fix')) { console.log(`🔧 Detected --fix flag, running: yarn lint-fix`); - const result = cp.spawnSync('yarn', ['lint-fix'], { stdio: 'inherit' }); - process.exitCode = result.status; + const result = spawn.sync('yarn', ['lint-fix'], { stdio: 'inherit' }); + if (result.error) { + console.error(`❌ Failed to spawn command: ${result.error.message}`); + process.exitCode = 1; + } else { + process.exitCode = result.status; + } process.exit(); } -const result = cp.spawnSync(command[0], command.slice(1), { stdio: 'inherit' }); +const result = spawn.sync(command[0], command.slice(1), { stdio: 'inherit' }); -if (result.status !== 0) { +if (result.error) { + console.error(`❌ Failed to spawn command: ${result.error.message}`); + process.exitCode = 1; +} else if (result.status !== 0) { process.exitCode = result.status; if (currentScriptName && currentScriptName in fixingCommands) { console.log( diff --git a/docs-developer/CHANGELOG-formats.md b/docs-developer/CHANGELOG-formats.md index b1be78b6bb..50da5c96d4 100644 --- a/docs-developer/CHANGELOG-formats.md +++ b/docs-developer/CHANGELOG-formats.md @@ -6,6 +6,12 @@ Note that this is not an exhaustive list. Processed profile format upgraders can ## Processed profile format +### Version 62 + +A new `display` field of type `CounterDisplayConfig` was added to `RawCounter`. +This metadata makes counters self-describing in terms of how they are rendered in the UI. +For existing profiles, the display config is derived from the counter's `category` and `name`. + ### Version 61 The `SourceTable` in `profile.shared.sources` was updated: diff --git a/eslint.config.mjs b/eslint.config.mjs index 6706b48b92..5bb495b649 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,6 +20,7 @@ export default defineConfig( 'src/types/libdef/npm/**', 'res/**', 'dist/**', + 'node-tools-dist/**', 'docs-user/**', 'coverage/**', ], @@ -34,6 +35,11 @@ export default defineConfig( // React config reactPlugin.configs.flat.recommended, + // Prettier config must be placed here to disable formatting rules from the + // base configs above, while allowing our custom rules below to take + // precedence. + prettierConfig, + // Custom configuration for all files { files: ['**/*.js', '**/*.mjs', '**/*.cjs', '**/*.ts', '**/*.tsx'], @@ -311,8 +317,5 @@ export default defineConfig( ...globals.jest, }, }, - }, - - // Prettier config (must be last to override other formatting rules) - prettierConfig + } ); diff --git a/locales/de/app.ftl b/locales/de/app.ftl index 36adcf71ef..2105e61eb7 100644 --- a/locales/de/app.ftl +++ b/locales/de/app.ftl @@ -799,6 +799,8 @@ StackSettings--call-tree-strategy-native-deallocations-sites = Freigegebene Webs .title = Zusammenfassung erstellen mit freigegebenen Speicherbytes, von der Website, auf der der Speicher alloziert wurde StackSettings--invert-call-stack = Aufrufstack umkehren .title = Sortieren nach in einem Aufrufknoten, die Unterpunkte werden ignoriert. +StackSettings--include-idle-samples = Untätige Samples einschließen + .title = Deaktivieren Sie dieses Kontrollkästchen, um Samples auszublenden, deren Leaf-Frame der Kategorie „untätig“ angehört. StackSettings--show-user-timing = Nutzer-Zeitrechnung anzeigen StackSettings--use-stack-chart-same-widths = Für jeden Stapel die gleiche Breite verwenden StackSettings--panel-search = @@ -1125,6 +1127,12 @@ BottomBox--assembly-code-not-available-title = Assembly-Code nicht verfügbar # Elements: # link text - A link to the github issue about supported scenarios. BottomBox--assembly-code-not-available-text = Bericht #4520 beschreibt unterstützte Szenarien und geplante Verbesserungen. +# The toggle button for making the bottom box fullscreen. +BottomBox--hide-fullscreen = + .title = Vollbild beenden +# The toggle button for making the bottom box fullscreen. +BottomBox--show-fullscreen = + .title = Vollbild SourceView--close-button = .title = Quelltextansicht schließen diff --git a/locales/el/app.ftl b/locales/el/app.ftl index 871a503a7f..80e5033b98 100644 --- a/locales/el/app.ftl +++ b/locales/el/app.ftl @@ -818,6 +818,8 @@ StackSettings--call-tree-strategy-native-deallocations-sites = Ιστότοπο .title = Περίληψη με τα bytes της μνήμης που απελευθερώθηκαν, κατά τον ιστότοπο όπου αποδεσμεύθηκε η μνήμη StackSettings--invert-call-stack = Αναστροφή στοίβας κλήσεων .title = Ταξινόμηση κατά χρόνο που χρησιμοποιήθηκε σε κόμβο κλήσεων, αγνοώντας τους θυγατρικούς του. +StackSettings--include-idle-samples = Συμπερίληψη αδρανών δειγμάτων + .title = Απενεργοποιήστε την επιλογή για να αποκρύψετε τα δείγματα των οποίων το πλαίσιο φύλλου είναι στην κατηγορία «Αδρανή». StackSettings--show-user-timing = Εμφάνιση χρονισμού χρήστη StackSettings--use-stack-chart-same-widths = Χρήση του ίδιου πλάτους για κάθε στοίβα StackSettings--panel-search = @@ -1144,6 +1146,12 @@ BottomBox--assembly-code-not-available-title = Ο κώδικας assembly δεν # Elements: # link text - A link to the github issue about supported scenarios. BottomBox--assembly-code-not-available-text = Δείτε το ζήτημα #4520 για υποστηριζόμενα σενάρια και προγραμματισμένες βελτιώσεις. +# The toggle button for making the bottom box fullscreen. +BottomBox--hide-fullscreen = + .title = Έξοδος από πλήρη οθόνη +# The toggle button for making the bottom box fullscreen. +BottomBox--show-fullscreen = + .title = Πλήρης οθόνη SourceView--close-button = .title = Κλείσιμο προβολής πηγής diff --git a/locales/en-GB/app.ftl b/locales/en-GB/app.ftl index fa8671bd65..17fe0cb44b 100644 --- a/locales/en-GB/app.ftl +++ b/locales/en-GB/app.ftl @@ -1149,6 +1149,12 @@ BottomBox--assembly-code-not-available-title = Assembly code not available # Elements: # link text - A link to the github issue about supported scenarios. BottomBox--assembly-code-not-available-text = See issue #4520 for supported scenarios and planned improvements. +# The toggle button for making the bottom box fullscreen. +BottomBox--hide-fullscreen = + .title = Exit fullscreen +# The toggle button for making the bottom box fullscreen. +BottomBox--show-fullscreen = + .title = Fullscreen SourceView--close-button = .title = Close the source view diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 9475a20d4c..be577ee3a9 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -888,6 +888,8 @@ StackSettings--call-tree-strategy-native-deallocations-sites = Deallocation Site StackSettings--invert-call-stack = Invert call stack .title = Sort by the time spent in a call node, ignoring its children. +StackSettings--include-idle-samples = Include idle samples + .title = Uncheck to hide samples whose leaf frame is in the Idle category. StackSettings--show-user-timing = Show user timing StackSettings--use-stack-chart-same-widths = Use the same width for each stack @@ -1260,6 +1262,14 @@ BottomBox--assembly-code-not-available-title = Assembly code not available BottomBox--assembly-code-not-available-text = See issue #4520 for supported scenarios and planned improvements. +# The toggle button for making the bottom box fullscreen. +BottomBox--hide-fullscreen = + .title = Exit fullscreen + +# The toggle button for making the bottom box fullscreen. +BottomBox--show-fullscreen = + .title = Fullscreen + SourceView--close-button = .title = Close the source view diff --git a/locales/es-CL/app.ftl b/locales/es-CL/app.ftl index 2fb531cc02..8d83f50f2b 100644 --- a/locales/es-CL/app.ftl +++ b/locales/es-CL/app.ftl @@ -1068,6 +1068,12 @@ BottomBox--assembly-code-not-available-title = Código de ensamblaje no disponib # Elements: # link text - A link to the github issue about supported scenarios. BottomBox--assembly-code-not-available-text = Consulta el problema #4520 para conocer los escenarios compatibles y las mejoras planificadas. +# The toggle button for making the bottom box fullscreen. +BottomBox--hide-fullscreen = + .title = Salir de pantalla completa +# The toggle button for making the bottom box fullscreen. +BottomBox--show-fullscreen = + .title = Pantalla completa SourceView--close-button = .title = Cerrar la vista de fuente diff --git a/locales/fr/app.ftl b/locales/fr/app.ftl index ddb5b9711e..7dffa35e7a 100644 --- a/locales/fr/app.ftl +++ b/locales/fr/app.ftl @@ -815,6 +815,11 @@ TrackPower--tooltip-power-watt = { $value } W # $value (String) - the power value at this location TrackPower--tooltip-power-milliwatt = { $value } mW .label = Puissance +# This is used in the tooltip when the instant power value uses the microwatt unit. +# Variables: +# $value (String) - the power value at this location +TrackPower--tooltip-power-microwatt = { $value } μW + .label = Puissance # This is used in the tooltip when the power value uses the kilowatt unit. # Variables: # $value (String) - the power value at this location @@ -1035,6 +1040,12 @@ BottomBox--assembly-code-not-available-title = Code assembleur non disponible # Elements: # link text - A link to the github issue about supported scenarios. BottomBox--assembly-code-not-available-text = Consultez le ticket n°4520 pour les scénarios pris en charge et les améliorations prévues. +# The toggle button for making the bottom box fullscreen. +BottomBox--hide-fullscreen = + .title = Quitter le mode plein écran +# The toggle button for making the bottom box fullscreen. +BottomBox--show-fullscreen = + .title = Plein écran SourceView--close-button = .title = Fermer la vue du code source diff --git a/locales/ia/app.ftl b/locales/ia/app.ftl index fd857a0850..5e1092593e 100644 --- a/locales/ia/app.ftl +++ b/locales/ia/app.ftl @@ -1133,6 +1133,12 @@ BottomBox--assembly-code-not-available-title = Codice assembly non disponibile # Elements: # link text - A link to the github issue about supported scenarios. BottomBox--assembly-code-not-available-text = Vide issue #4520 pro scenarios supportate e meliorationes planate. +# The toggle button for making the bottom box fullscreen. +BottomBox--hide-fullscreen = + .title = Exir del plen schermo +# The toggle button for making the bottom box fullscreen. +BottomBox--show-fullscreen = + .title = Plen schermo SourceView--close-button = .title = Clauder le vista fonte diff --git a/locales/it/app.ftl b/locales/it/app.ftl index 51ee27a18c..ca335aa98a 100644 --- a/locales/it/app.ftl +++ b/locales/it/app.ftl @@ -729,6 +729,8 @@ StackSettings--call-tree-strategy-native-deallocations-sites = Deallocazione sit .title = Sintetizza usando i byte di memoria deallocati, in base dal sito in cui la memoria è stata deallocata StackSettings--invert-call-stack = Inverti stack di chiamata .title = Ordina in base al tempo trascorso in un nodo di chiamata, ignorando i nodi figlio. +StackSettings--include-idle-samples = Includi campioni inattivi + .title = Deseleziona per nascondere i campioni il cui frame foglia è nella categoria inattivi. StackSettings--show-user-timing = Mostra tempo utente StackSettings--use-stack-chart-same-widths = Utilizza la stessa larghezza per ogni stack StackSettings--panel-search = @@ -1055,6 +1057,12 @@ BottomBox--assembly-code-not-available-title = Codice assembly non disponibile # Elements: # link text - A link to the github issue about supported scenarios. BottomBox--assembly-code-not-available-text = Vedi issue #4520 per gli scenari supportati e i miglioramenti in programma. +# The toggle button for making the bottom box fullscreen. +BottomBox--hide-fullscreen = + .title = Esci da schermo intero +# The toggle button for making the bottom box fullscreen. +BottomBox--show-fullscreen = + .title = Schermo intero SourceView--close-button = .title = Chiudi la vista sorgente diff --git a/locales/nl/app.ftl b/locales/nl/app.ftl index 9a4cf236b6..432cab77d7 100644 --- a/locales/nl/app.ftl +++ b/locales/nl/app.ftl @@ -1149,6 +1149,12 @@ BottomBox--assembly-code-not-available-title = Samenstellingscode niet beschikba # Elements: # link text - A link to the github issue about supported scenarios. BottomBox--assembly-code-not-available-text = Zie issue #4520 voor ondersteunde scenario’s en geplande verbeteringen. +# The toggle button for making the bottom box fullscreen. +BottomBox--hide-fullscreen = + .title = Volledig scherm verlaten +# The toggle button for making the bottom box fullscreen. +BottomBox--show-fullscreen = + .title = Volledig scherm SourceView--close-button = .title = Bronweergave sluiten diff --git a/locales/pt-BR/app.ftl b/locales/pt-BR/app.ftl index 10e9ff686a..cb0ad49bad 100644 --- a/locales/pt-BR/app.ftl +++ b/locales/pt-BR/app.ftl @@ -284,6 +284,13 @@ Home--enable-button-unavailable = # This message can be seen on https://main--perf-html.netlify.app/ . Home--web-channel-unavailable = Esta instância do profiler não conseguiu se conectar ao WebChannel. Isso geralmente significa que está sendo executado em um host diferente daquele especificado na preferência devtools.performance.recording.ui-base-url. Se você quiser capturar novos profiles com esta instância e dar a ela controle programático do botão de menu do profiler, pode ir em about: config e alterar a preferência. Home--record-instructions = Para iniciar a gravação de um profile, clique no botão de gravação de profile ou use os atalhos de teclado. O ícone fica azul quando um profile está sendo gravado. Use Capturar para carregar os dados no profiler.firefox.com. +Home--instructions-content2 = + A gravação de profiles de desempenho requer o { -firefox-brand-name } de computador. + No entanto, profiles existentes podem ser vistos em qualquer navegador moderno. +Home--fenix-instructions-directly = É possível criar profiles do { -firefox-android-brand-name } diretamente neste dispositivo. Para mais informações, consulte Como criar profiles do { -firefox-android-brand-name } diretamente no dispositivo. +Home--fenix-instructions-remotely = + Você também pode criar profiles do { -firefox-android-brand-name } remotamente a partir do { -firefox-brand-name } de computador. Para mais informações, consulte esta documentação: + Como criar profiles do { -firefox-android-brand-name } remotamente. Home--record-instructions-start-stop = Interrompa e inicie a gravação de profiles Home--record-instructions-capture-load = Capture e carregue um profile Home--profiler-motto = Capture um profile de desempenho. Analise. Compartilhe. Torne a web mais rápida. diff --git a/locales/ru/app.ftl b/locales/ru/app.ftl index c89f90da4d..89f2aa8859 100644 --- a/locales/ru/app.ftl +++ b/locales/ru/app.ftl @@ -146,7 +146,7 @@ CallNodeContextMenu--expand-all = Развернуть всё # See: https://searchfox.org/ CallNodeContextMenu--searchfox = Найти название функции на Searchfox CallNodeContextMenu--copy-function-name = Скопировать имя функции -CallNodeContextMenu--copy-script-url = Скопировать URL сценария +CallNodeContextMenu--copy-script-url = Копировать URL скрипта CallNodeContextMenu--copy-stack = Скопировать стек CallNodeContextMenu--show-the-function-in-devtools = Показать функцию в DevTools @@ -154,11 +154,7 @@ CallNodeContextMenu--show-the-function-in-devtools = Показать функц ## This is the component for Call Tree panel. CallTree--tracing-ms-total = Время работы (мс) - .title = - «Общее» время выполнения включает в себя сводку всего времени, в течение которого наблюдалось нахождение этой - функции в стеке. Это включает в себя время, когда - функция фактически была запущена, и время, проведенное в вызывающих из - этой функции. + .title = «Общее» время выполнения включает в себя сводку всего времени, в течение которого наблюдалось нахождение этой функции в стеке. Это включает в себя время, когда функция фактически была запущена, и время, проведённое в вызывающих из этой функции. CallTree--tracing-ms-self = Собственное (мс) .title = «Собственное» время включает в себя только то время, когда функция была @@ -166,11 +162,7 @@ CallTree--tracing-ms-self = Собственное (мс) то время работы «других» функций не учитывается. «Собственное» время полезно для понимания того, на что на самом деле было потрачено время в программе. CallTree--samples-total = Общее (семплы) - .title = - «Общее» количество семплов включает в себя сводку по каждому семплу, в котором - было обнаружено наличие этой функции в стеке. Оно включает в себя время, когда - функция фактически была запущена, и время, проведенное в вызывающих из этой - функции. + .title = «Общее» количество семплов включает в себя сводку по каждому семплу, в котором было обнаружено наличие этой функции в стеке. Оно включает в себя время, когда функция фактически была запущена, и время, проведённое в вызывающих из этой функции. CallTree--samples-self = Собственные .title = Количество «собственных» семплов включает только те семплы, в которых функция была @@ -396,7 +388,7 @@ IdleSearchField--search-input = ## JSTracer is an experimental feature and it's currently disabled. See Bug 1565788. JsTracerSettings--show-only-self-time = Показывать только собственное время - .title = Показывать только время, проведенное в узле вызова, игнорируя его дочерние элементы. + .title = Показывать только время, проведённое в узле вызова, игнорируя его дочерние элементы. ## ListOfPublishedProfiles ## This is the component that displays all the profiles the user has uploaded. @@ -1158,6 +1150,12 @@ BottomBox--assembly-code-not-available-title = Ассемблерный код # Elements: # link text - A link to the github issue about supported scenarios. BottomBox--assembly-code-not-available-text = См. проблему #4520, чтобы узнать о поддерживаемых сценариях и запланированных улучшениях. +# The toggle button for making the bottom box fullscreen. +BottomBox--hide-fullscreen = + .title = Выйти из полноэкранного режима +# The toggle button for making the bottom box fullscreen. +BottomBox--show-fullscreen = + .title = Полноэкранный режим SourceView--close-button = .title = Закрыть исходный вид diff --git a/locales/sv-SE/app.ftl b/locales/sv-SE/app.ftl index f328810f24..b45537da3a 100644 --- a/locales/sv-SE/app.ftl +++ b/locales/sv-SE/app.ftl @@ -1144,6 +1144,12 @@ BottomBox--assembly-code-not-available-title = Assembly-koden inte tillgänglig # Elements: # link text - A link to the github issue about supported scenarios. BottomBox--assembly-code-not-available-text = Se problem #4520 för scenarier som stöds och planerade förbättringar. +# The toggle button for making the bottom box fullscreen. +BottomBox--hide-fullscreen = + .title = Avsluta helskärm +# The toggle button for making the bottom box fullscreen. +BottomBox--show-fullscreen = + .title = Helskärm SourceView--close-button = .title = Stäng källvyn diff --git a/locales/tr/app.ftl b/locales/tr/app.ftl index 82988e1eca..9940814c48 100644 --- a/locales/tr/app.ftl +++ b/locales/tr/app.ftl @@ -421,6 +421,7 @@ MenuButtons--metaInfo--profile-not-symbolicated = Profil sembolleştirilmemiş MenuButtons--metaInfo--resymbolicate-profile = Profili yeniden sembolleştir MenuButtons--metaInfo--symbolicate-profile = Profili sembolleştir MenuButtons--metaInfo--attempting-resymbolicate = Profil yeniden sembolleştirilmeye çalışılıyor +MenuButtons--metaInfo--currently-symbolicating = Şu anda profil sembolleştiriliyor MenuButtons--metaInfo--cpu-model = İşlemci modeli: MenuButtons--metaInfo--cpu-cores = İşlemci çekirdekleri: MenuButtons--metaInfo--main-memory = Ana bellek: @@ -1009,6 +1010,12 @@ BottomBox--assembly-code-not-available-title = Assembly kodu mevcut değil # Elements: # link text - A link to the github issue about supported scenarios. BottomBox--assembly-code-not-available-text = Desteklenen senaryolar ve planlanan iyileştirmeler için sorun #4520’ye bakabilirsiniz. +# The toggle button for making the bottom box fullscreen. +BottomBox--hide-fullscreen = + .title = Tam ekrandan çık +# The toggle button for making the bottom box fullscreen. +BottomBox--show-fullscreen = + .title = Tam ekran SourceView--close-button = .title = Kaynak görünümünü kapat diff --git a/locales/zh-CN/app.ftl b/locales/zh-CN/app.ftl index 8efb28dbc9..249ec052f3 100644 --- a/locales/zh-CN/app.ftl +++ b/locales/zh-CN/app.ftl @@ -76,6 +76,14 @@ CallNodeContextMenu--transform-focus-function = 聚焦于函数 CallNodeContextMenu--transform-focus-function-inverted = 聚焦于函数(反向) .title = { CallNodeContextMenu--transform-focus-function-title } +## The translation for "self" in these strings should match the translation used +## in CallTree--samples-self and CallTree--bytes-self. Alternatively it can be +## translated as "self values" or "self time" (though "self time" is less desirable +## because this menu item is also shown in "bytes" mode). + +CallNodeContextMenu--transform-focus-self = 只聚焦于自身 + .title = { CallNodeContextMenu--transform-focus-self-title } + ## CallNodeContextMenu--transform-focus-subtree = 只聚焦于子树 @@ -112,16 +120,16 @@ CallNodeContextMenu--show-the-function-in-devtools = 在开发者工具中显示 CallTree--tracing-ms-total = 总运行时间(ms) .title = 此函数在栈上被观察到出现的“总计”时长汇总。包含函数实际运行的时长,以及此函数中所调用的时长。 -CallTree--tracing-ms-self = Self(ms) - .title = “Self”时间只包含函数在栈底结束时的时间。若此函数是通过其他函数调用的,则不包含“该函数”的时间。“self”时间适合用于了解程序中实际用了多长时间在哪些函数上。 +CallTree--tracing-ms-self = 自身(ms) + .title = “自身”时间只包含函数在栈底结束时的时间。若此函数是通过其他函数调用的,则不包含“该函数”的时间。“自身”时间适合用于了解程序中实际用了多长时间在哪些函数上。 CallTree--samples-total = 总计(样本数) .title = 此函数在栈上被观察到出现的“总计”次数汇总。包含实际运行的的次数,以及此函数中所调用的次数。 -CallTree--samples-self = Self - .title = “Self”样本数只包含函数在栈底结束时的次数。若此函数是通过其他函数调用的,则不包含“该函数”的次数。“self”次数适合用于了解程序中实际用了多长时间在哪些函数上。 +CallTree--samples-self = 自身 + .title = “自身”样本数只包含函数在栈底结束时的次数。若此函数是通过其他函数调用的,则不包含“该函数”的次数。“自身”次数适合用于了解程序中实际用了多长时间在哪些函数上。 CallTree--bytes-total = 总大小(字节) .title = 此函数在栈上被观察到分配或释放的“总计”字节汇总。包含函数实际运行时使用的大小,以及此函数中所调用其他函数所用的内存大小。 -CallTree--bytes-self = Self(字节) - .title = “Self”字节数只包含函数在栈底分配或释放的内存用量。若此函数是通过其他函数调用的,则不包含“该函数”的用量。“Self”字节数适合用于了解程序中实际用了多少内存在哪些函数上。 +CallTree--bytes-self = 自身(字节) + .title = “自身”字节数只包含函数在栈底分配或释放的内存用量。若此函数是通过其他函数调用的,则不包含“该函数”的用量。“自身”字节数适合用于了解程序中实际用了多少内存在哪些函数上。 ## Call tree "badges" (icons) with tooltips ## @@ -267,6 +275,11 @@ Home--enable-button-unavailable = # This message can be seen on https://main--perf-html.netlify.app/ . Home--web-channel-unavailable = 此分析器无法连接至 WebChannel。通常是因为运行分析器的主机与 devtools.performance.recording.ui-base-url 首选项中指定的主机不同。若您想要使用此分析器捕捉新的性能分析记录,并可程序化控制分析器菜单按钮,可到 about:config 调整该首选项。 Home--record-instructions = 要进行分析,请点击“分析”按钮,或使用键盘快捷键。在性能记录时,此图标将会变为蓝色。按下捕捉即可将数据加载至 profiler.firefox.com。 +Home--instructions-content2 = 记录性能分析数据需要使用桌面版 { -firefox-brand-name },但已有的分析记录可使用任意现代浏览器查看。 +Home--fenix-instructions-directly = 可直接在此设备上对 { -firefox-android-brand-name } 进行性能分析。有关更多信息,请阅读直接在设备上对 { -firefox-android-brand-name } 进行性能分析。 +Home--fenix-instructions-remotely = + 您也可以通过桌面版 { -firefox-brand-name } 对 { -firefox-android-brand-name } 进行远程性能分析。有关更多信息,请参阅文档: + 对 { -firefox-android-brand-name } 进行远程性能分析。 Home--record-instructions-start-stop = 停止并开始分析 Home--record-instructions-capture-load = 捕捉并加载分析记录 Home--profiler-motto = 捕捉性能分析记录。剖析、分享、让网站速度更快。 @@ -274,6 +287,7 @@ Home--additional-content-title = 加载现有分析记录 Home--additional-content-content = 您可以将分析记录拖放至此处,或: Home--compare-recordings-info = 您也可以比较记录内容。打开比较界面。 Home--your-recent-uploaded-recordings-title = 您最近上传的记录 +Home--dark-mode-title = 深色模式 # We replace the elements such as and with links to the # documentation to use these tools. Home--load-files-from-other-tools2 = { -profiler-brand-name } 也可以从其他分析器导入记录,例如 Linux perfAndroid SimplePerf、Chrome 性能面板、Android Studio,支持直接导入 dhatGoogle 的 Trace Event 格式保存的分析记录。点此了解如何编写您自己的导入程序。 @@ -290,8 +304,8 @@ IdleSearchField--search-input = ## JsTracerSettings ## JSTracer is an experimental feature and it's currently disabled. See Bug 1565788. -JsTracerSettings--show-only-self-time = 只显示 self 时间 - .title = 只显示调用节点所用的时间,而忽略其 children。 +JsTracerSettings--show-only-self-time = 只显示自身时间 + .title = 只显示调用节点所用的时间,而忽略其子节点。 ## ListOfPublishedProfiles ## This is the component that displays all the profiles the user has uploaded. @@ -954,6 +968,12 @@ TransformNavigator--focus-subtree = 聚焦节点:{ $item } # Variables: # $item (String) - Name of the function that transform applied to. TransformNavigator--focus-function = 聚焦:{ $item } +# "Focus self" transform. +# See: https://profiler.firefox.com/docs/#/./guide-filtering-call-trees?id=focus-on-function-self +# Also see the translation note above CallNodeContextMenu--transform-focus-self. +# Variables: +# $item (String) - Name of the function that transform applied to. +TransformNavigator--focus-self = 聚焦于自身:{ $item } # "Focus category" transform. The word "Focus" has the meaning of an adjective here. # See: https://profiler.firefox.com/docs/#/./guide-filtering-call-trees?id=focus-category # Variables: diff --git a/locales/zh-TW/app.ftl b/locales/zh-TW/app.ftl index 7c73dd81b5..9dcb93831c 100644 --- a/locales/zh-TW/app.ftl +++ b/locales/zh-TW/app.ftl @@ -70,7 +70,7 @@ CallNodeContextMenu--transform-merge-call-node = 只合併節點 .title = 合併節點後會將其從效能檢測檔移除,並將時間歸入呼叫該節點的函數節點。只會移除效能樹當中特定部分的函數,其他對該函數呼叫的部分將保留在檢測檔中。 # This is used as the context menu item title for "Focus on function" and "Focus # on function (inverted)" transforms. -CallNodeContextMenu--transform-focus-function-title = 聚焦於函數,將移除該函數之外所有紀錄到的項目。除此之外,還會重新將呼叫樹的根指定為該函數。此功能可以將檢測檔中的多個函數呼叫點合併為單一呼叫節點。 +CallNodeContextMenu--transform-focus-function-title = 聚焦於函數,將移除該函數之外所有紀錄到的樣本。除此之外,還會重新將呼叫樹的根指定為該函數。此功能可以將檢測檔中的多個函數呼叫點合併為單一呼叫節點。 CallNodeContextMenu--transform-focus-function = 聚焦於函數 .title = { CallNodeContextMenu--transform-focus-function-title } CallNodeContextMenu--transform-focus-function-inverted = 聚焦於函數(反向) @@ -81,14 +81,14 @@ CallNodeContextMenu--transform-focus-function-inverted = 聚焦於函數(反 ## translated as "self values" or "self time" (though "self time" is less desirable ## because this menu item is also shown in "bytes" mode). -CallNodeContextMenu--transform-focus-self-title = 聚焦於 self 與聚焦於函數類似,但只保留與函數的 self 時間有關的取樣。將捨棄被呼叫者的取樣,並將呼叫樹重新放置於聚焦的函數根上。 +CallNodeContextMenu--transform-focus-self-title = 聚焦於 self 與聚焦於函數類似,但只保留與函數的 self 時間有關的樣本。將捨棄被呼叫者的樣本,並將呼叫樹重新放置於聚焦的函數根上。 CallNodeContextMenu--transform-focus-self = 只聚焦於 self .title = { CallNodeContextMenu--transform-focus-self-title } ## CallNodeContextMenu--transform-focus-subtree = 只聚焦於子樹 - .title = 聚焦於子樹,將從呼叫樹中拉出分支,並移除不屬於該分支的內容。然而此功能只對單一呼叫節點有效,將忽略其他呼叫該函數的部分。 + .title = 聚焦於子樹,將從呼叫樹中拉出分支,並移除不屬於該分支的樣本。然而此功能只對單一呼叫節點有效,將忽略其他呼叫該函數的部分。 # This is used as the context menu item to apply the "Focus on category" transform. # Variables: # $categoryName (String) - Name of the category to focus on. @@ -177,9 +177,9 @@ CallTreeSidebar--running-time = CallTreeSidebar--self-time = .label = Self 時間 CallTreeSidebar--running-samples = - .label = 執行取樣 + .label = 執行中的樣本 CallTreeSidebar--self-samples = - .label = Self 取樣 + .label = Self 樣本 CallTreeSidebar--running-size = .label = 執行大小 CallTreeSidebar--self-size = @@ -187,10 +187,10 @@ CallTreeSidebar--self-size = CallTreeSidebar--categories = 分類 CallTreeSidebar--implementation = 實作 CallTreeSidebar--running-milliseconds = 執行時間(ms) -CallTreeSidebar--running-sample-count = 執行取樣數 +CallTreeSidebar--running-sample-count = 執行中的樣本數 CallTreeSidebar--running-bytes = 執行位元組 CallTreeSidebar--self-milliseconds = Self 時間(ms) -CallTreeSidebar--self-sample-count = Self 取樣數 +CallTreeSidebar--self-sample-count = Self 樣本數 CallTreeSidebar--self-bytes = Self 位元組 ## CompareHome @@ -364,7 +364,7 @@ MarkerContextMenu--select-the-sender-thread = 選擇傳送執行緒「{ # This string is used on the marker filters menu item when clicked on the filter icon. # Variables: # $filter (String) - Search string that will be used to filter the markers. -MarkerFiltersContextMenu--drop-samples-outside-of-markers-matching = 丟棄不符合「{ $filter }」標記的取樣 +MarkerFiltersContextMenu--drop-samples-outside-of-markers-matching = 丟棄不符合「{ $filter }」標記的樣本 ## MarkerCopyTableContextMenu ## This is the menu when the copy icon is clicked in Marker Chart and Marker @@ -541,7 +541,7 @@ MenuButtons--metaOverheadStatistics-statkeys-cleaning = 清理 MenuButtons--metaOverheadStatistics-statkeys-counter = 計數 .title = 用來取得所有計數器的時間。 MenuButtons--metaOverheadStatistics-statkeys-interval = 間隔 - .title = 兩次計量間的間隔。 + .title = 兩次取樣的間隔。 MenuButtons--metaOverheadStatistics-statkeys-lockings = 鎖定 .title = 進行計量前取得鎖定所需的時間。 MenuButtons--metaOverheadStatistics-overhead-duration = 額外負荷持續時間: @@ -717,6 +717,8 @@ StackSettings--call-tree-strategy-native-deallocations-sites = 取消分配的 .title = 依照取消分配記憶體的位置,根據取消分配的記憶體位元組數進行摘要 StackSettings--invert-call-stack = 反轉呼叫堆疊 .title = 依照呼叫節點當中花費的時間排序,並忽略其 children。 +StackSettings--include-idle-samples = 包含閒置的樣本 + .title = 取消勾選即可隱藏 leaf frame 屬於閒置分類的樣本。 StackSettings--show-user-timing = 顯示使用者計時 StackSettings--use-stack-chart-same-widths = 將每個堆疊以相同寬度顯示 StackSettings--panel-search = @@ -1010,7 +1012,7 @@ TransformNavigator--collapse-function-subtree = 摺疊子樹:{ $item } # "Drop samples outside of markers matching ..." transform. # Variables: # $item (String) - Search filter of the markers that transform will apply to. -TransformNavigator--drop-samples-outside-of-markers-matching = 丟棄不符合「{ $item }」標記的取樣 +TransformNavigator--drop-samples-outside-of-markers-matching = 丟棄不符合「{ $item }」標記的樣本 ## "Bottom box" - a view which contains the source view and the assembly view, ## at the bottom of the profiler UI @@ -1043,6 +1045,12 @@ BottomBox--assembly-code-not-available-title = 無法取得機器碼 # Elements: # link text - A link to the github issue about supported scenarios. BottomBox--assembly-code-not-available-text = 關於支援的使用情境與規劃中的改進,請參考issue #4520。 +# The toggle button for making the bottom box fullscreen. +BottomBox--hide-fullscreen = + .title = 離開全螢幕模式 +# The toggle button for making the bottom box fullscreen. +BottomBox--show-fullscreen = + .title = 全螢幕 SourceView--close-button = .title = 關閉原始碼畫面 diff --git a/package.json b/package.json index 7d417a3186..a183afb8d2 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "private": true, "engines": { - "node": ">= 22 < 23" + "node": ">= 24 < 25" }, "devEngines": { "runtime": { "name": "node", - "version": ">= 22 < 23" + "version": ">= 24 < 25" } }, "browser": { @@ -19,9 +19,8 @@ "build-l10n-prod": "cross-env NODE_ENV=production L10N=1 node scripts/build.mjs && yarn build-sw && yarn build-photon", "build-photon": "cross-env NODE_ENV=production node scripts/build-photon.mjs", "build-sw": "workbox generateSW workbox-config.js", - "build-symbolicator-cli": "cross-env NODE_ENV=production node scripts/build-symbolicator.mjs", "build-prod:quiet": "yarn build-prod", - "build-symbolicator-cli:quiet": "yarn build-symbolicator-cli", + "build-node-tools": "cross-env NODE_ENV=production node scripts/build-node-tools.mjs", "lint": "node bin/output-fixing-commands.js run-p lint-js lint-css prettier-run", "lint-fix": "run-p lint-fix-js lint-fix-css prettier-fix", "lint-js": "node bin/output-fixing-commands.js eslint . --report-unused-disable-directives --cache --cache-strategy content", @@ -63,9 +62,9 @@ "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-rust": "^6.0.2", - "@codemirror/language": "^6.12.2", - "@codemirror/state": "^6.5.4", - "@codemirror/view": "^6.39.14", + "@codemirror/language": "^6.12.3", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.41.0", "@firefox-devtools/react-contextmenu": "^5.2.3", "@fluent/bundle": "^0.19.1", "@fluent/langneg": "^0.7.0", @@ -78,8 +77,8 @@ "classnames": "^2.5.1", "common-tags": "^1.8.2", "copy-to-clipboard": "^3.3.3", - "core-js": "^3.48.0", - "devtools-reps": "^0.27.4", + "core-js": "^3.49.0", + "devtools-reps": "^0.27.6", "escape-string-regexp": "^4.0.0", "gecko-profiler-demangle": "^0.4.0", "idb": "^8.0.3", @@ -92,20 +91,19 @@ "mixedtuplemap": "^1.0.0", "namedtuplemap": "^1.0.0", "photon-colors": "^3.3.2", - "protobufjs": "^8.0.0", + "protobufjs": "^8.0.1", "query-string": "^9.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-intersection-observer": "^10.0.3", "react-redux": "^9.2.0", "react-splitter-layout": "^4.0.0", - "react-transition-group": "^4.4.5", "redux": "^5.0.1", "redux-logger": "^3.0.6", "redux-thunk": "^3.1.0", "reselect": "^4.1.8", "url": "^0.11.4", - "valibot": "^1.2.0", + "valibot": "^1.3.1", "weaktuplemap": "^1.0.0", "workbox-window": "^7.4.0" }, @@ -115,7 +113,7 @@ "@babel/eslint-parser": "^7.28.6", "@babel/eslint-plugin": "^7.27.1", "@babel/plugin-proposal-class-properties": "^7.18.6", - "@babel/preset-env": "^7.29.0", + "@babel/preset-env": "^7.29.2", "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", "@eslint/js": "^9.39.4", @@ -126,42 +124,42 @@ "@types/common-tags": "^1.8.4", "@types/jest": "^30.0.0", "@types/minimist": "^1.2.5", - "@types/node": "^22.19.15", + "@types/node": "^22.19.17", "@types/query-string": "^6.3.0", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.1", "@types/react-splitter-layout": "^4.0.0", - "@types/react-transition-group": "^4.4.5", "@types/redux-logger": "^3.0.6", "@types/tgwf__co2": "^0.14.2", - "@typescript-eslint/eslint-plugin": "^8.56.0", - "@typescript-eslint/parser": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^8.59.0", + "@typescript-eslint/parser": "^8.59.0", "alex": "^11.0.1", - "babel-jest": "^30.2.0", - "babel-plugin-module-resolver": "^5.0.2", - "browserslist": "^4.28.1", + "babel-jest": "^30.3.0", + "babel-plugin-module-resolver": "^5.0.3", + "browserslist": "^4.28.2", "browserslist-to-esbuild": "^2.1.1", - "caniuse-lite": "^1.0.30001770", + "caniuse-lite": "^1.0.30001788", "cross-env": "^10.1.0", + "cross-spawn": "^7.0.6", "devtools-license-check": "^0.9.0", - "esbuild": "^0.27.0", + "esbuild": "^0.28.0", "esbuild-plugin-copy": "^2.1.1", "esbuild-plugin-wasm": "^1.1.0", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jest": "^29.15.0", + "eslint-plugin-jest": "^29.15.2", "eslint-plugin-jest-dom": "^5.5.0", "eslint-plugin-jest-formatting": "^3.1.0", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-testing-library": "^7.16.0", + "eslint-plugin-testing-library": "^7.16.2", "fake-indexeddb": "^6.2.5", "fetch-mock": "^12.6.0", - "globals": "^17.4.0", + "globals": "^17.5.0", "husky": "^4.3.8", - "jest": "^30.2.0", - "jest-environment-jsdom": "^30.2.0", + "jest": "^30.3.0", + "jest-environment-jsdom": "^30.3.0", "jest-extended": "^7.0.0", "local-web-server": "^5.4.0", "lockfile-lint": "^5.0.0", @@ -169,15 +167,15 @@ "npm-run-all2": "^8.0.4", "open": "^11.0.0", "patch-package": "^8.0.1", - "postcss": "^8.5.8", + "postcss": "^8.5.10", "postinstall-postinstall": "^2.1.0", - "prettier": "^3.8.1", + "prettier": "^3.8.3", "rimraf": "^6.1.3", - "stylelint": "^17.3.0", + "stylelint": "^17.6.0", "stylelint-config-idiomatic-order": "^10.0.0", "stylelint-config-standard": "^40.0.0", "typescript": "^6.0.2", - "typescript-eslint": "^8.56.0", + "typescript-eslint": "^8.59.0", "workbox-cli": "^7.4.0", "yargs": "^18.0.0" }, diff --git a/res/css/global.css b/res/css/global.css index 9c47eb63df..5353f161f0 100644 --- a/res/css/global.css +++ b/res/css/global.css @@ -8,7 +8,7 @@ * ===================== */ --z-tab-selected: 1; --z-warning: 4; - --z-root: 0; + --z-tooltip: 4; --z-bottom-box: 1; --z-serviceworker-notice: 4; --z-arrow-panel: 5; diff --git a/res/img/svg/fullscreen-exit-light.svg b/res/img/svg/fullscreen-exit-light.svg new file mode 100644 index 0000000000..f19a0303a8 --- /dev/null +++ b/res/img/svg/fullscreen-exit-light.svg @@ -0,0 +1,6 @@ + + + + diff --git a/res/img/svg/fullscreen-exit.svg b/res/img/svg/fullscreen-exit.svg new file mode 100644 index 0000000000..2843b49fae --- /dev/null +++ b/res/img/svg/fullscreen-exit.svg @@ -0,0 +1,6 @@ + + + + diff --git a/res/img/svg/fullscreen-light.svg b/res/img/svg/fullscreen-light.svg new file mode 100644 index 0000000000..e5f3130602 --- /dev/null +++ b/res/img/svg/fullscreen-light.svg @@ -0,0 +1,6 @@ + + + + diff --git a/res/img/svg/fullscreen.svg b/res/img/svg/fullscreen.svg new file mode 100644 index 0000000000..ce460de24e --- /dev/null +++ b/res/img/svg/fullscreen.svg @@ -0,0 +1,6 @@ + + + + diff --git a/scripts/build-symbolicator.mjs b/scripts/build-node-tools.mjs similarity index 61% rename from scripts/build-symbolicator.mjs rename to scripts/build-node-tools.mjs index 3068727f24..72aa260e96 100644 --- a/scripts/build-symbolicator.mjs +++ b/scripts/build-node-tools.mjs @@ -4,16 +4,15 @@ import esbuild from 'esbuild'; import { nodeBaseConfig } from './lib/esbuild-configs.mjs'; -const symbolicatorConfig = { +const profilerEditConfig = { ...nodeBaseConfig, - metafile: true, - entryPoints: ['src/symbolicator-cli/index.ts'], - outfile: 'dist/symbolicator-cli.js', + entryPoints: ['src/node-tools/profiler-edit.ts'], + outfile: 'node-tools-dist/profiler-edit.js', }; async function build() { - await esbuild.build(symbolicatorConfig); - console.log('✅ Symbolicator-cli build completed'); + await esbuild.build(profilerEditConfig); + console.log('✅ profiler-edit build completed'); } build().catch(console.error); diff --git a/scripts/lib/dev-server.mjs b/scripts/lib/dev-server.mjs index d813bdfc6a..471924d58e 100644 --- a/scripts/lib/dev-server.mjs +++ b/scripts/lib/dev-server.mjs @@ -66,6 +66,7 @@ export async function startDevServer(buildConfig, options = {}) { // Create build context for watching const buildContext = await esbuild.context(buildConfig); const { hosts, port: esbuildServerPort } = await buildContext.serve({ + port: 0, host: '127.0.0.1', servedir: distDir, fallback: fallback ? path.join(distDir, fallback) : undefined, @@ -128,7 +129,9 @@ export async function startDevServer(buildConfig, options = {}) { // Graceful shutdown let isShuttingDown = false; process.on('SIGINT', async () => { - if (isShuttingDown) return; + if (isShuttingDown) { + return; + } isShuttingDown = true; console.log('\nShutting down...'); diff --git a/scripts/lib/esbuild-plugins.mjs b/scripts/lib/esbuild-plugins.mjs index 40d62d0741..c3cd6eac3b 100644 --- a/scripts/lib/esbuild-plugins.mjs +++ b/scripts/lib/esbuild-plugins.mjs @@ -24,7 +24,9 @@ export function circularDependencyPlugin() { setup(build) { build.initialOptions.metafile = true; build.onEnd((result) => { - if (!result.metafile?.inputs) return; + if (!result.metafile?.inputs) { + return; + } const { inputs } = result.metafile; const recursionStack = new Set(); diff --git a/src/actions/app.ts b/src/actions/app.ts index aabb1706b5..5792312927 100644 --- a/src/actions/app.ts +++ b/src/actions/app.ts @@ -109,10 +109,6 @@ export function changeSidebarOpenState(tab: TabSlug, isOpen: boolean): Action { return { type: 'CHANGE_SIDEBAR_OPEN_STATE', tab, isOpen }; } -export function invalidatePanelLayout(): Action { - return { type: 'INCREMENT_PANEL_LAYOUT_GENERATION' as const }; -} - /** * The viewport component provides a hint to use shift to zoom scroll. The first * time a user does this, the hint goes away. diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index a7bf7ddc39..7584c1f7be 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -347,10 +347,7 @@ function getInformationFromTrackReference( relatedThreadIndex: localTrack.threadIndex, relatedTab: null, }; - case 'memory': - case 'bandwidth': - case 'process-cpu': - case 'power': { + case 'counter': { const counterSelectors = getCounterSelectors(localTrack.counterIndex); const counter = counterSelectors.getCounter(state); return { @@ -1711,6 +1708,17 @@ export function changeShowUserTimings( }; } +export function changeIncludeIdleSamples( + includeIdleSamples: boolean +): ThunkAction { + return (dispatch) => { + dispatch({ + type: 'CHANGE_INCLUDE_IDLE_SAMPLES', + includeIdleSamples, + }); + }; +} + export function changeStackChartSameWidths( stackChartSameWidths: boolean ): ThunkAction { @@ -1987,6 +1995,14 @@ export function closeBottomBox(): ThunkAction { }; } +export function toggleBottomBoxFullscreen(): ThunkAction { + return (dispatch) => { + dispatch({ + type: 'TOGGLE_BOTTOM_BOX_FULLSCREEN', + }); + }; +} + export function handleCallNodeTransformShortcut( event: React.KeyboardEvent, threadsKey: ThreadsKey, diff --git a/src/actions/receive-profile.ts b/src/actions/receive-profile.ts index faa49d33d4..7eb4182cdd 100644 --- a/src/actions/receive-profile.ts +++ b/src/actions/receive-profile.ts @@ -2,6 +2,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { oneLine } from 'common-tags'; +import { + fetchProfile, + getProfileUrlForHash, + type ProfileOrZip, + deduceContentType, + extractJsonFromArrayBuffer, +} from 'firefox-profiler/utils/profile-fetch'; import queryString from 'query-string'; import type JSZip from 'jszip'; import { @@ -20,10 +27,8 @@ import { } from 'firefox-profiler/profile-logic/symbolication'; import * as MozillaSymbolicationAPI from 'firefox-profiler/profile-logic/mozilla-symbolication-api'; import { mergeProfilesForDiffing } from 'firefox-profiler/profile-logic/merge-compare'; -import { decompress, isGzip } from 'firefox-profiler/utils/gz'; import { expandUrl } from 'firefox-profiler/utils/shorten-url'; import { TemporaryError } from 'firefox-profiler/utils/errors'; -import { isLocalURL } from 'firefox-profiler/utils/url'; import { getSelectedThreadIndexesOrNull, getGlobalTrackOrder, @@ -67,7 +72,6 @@ import { import { setDataSource } from './profile-view'; import { fatalError } from './errors'; import { batchLoadDataUrlIcons } from './icons'; -import { GOOGLE_STORAGE_BUCKET } from 'firefox-profiler/app-logic/constants'; import { determineTimelineType, hasUsefulSamples, @@ -547,7 +551,7 @@ async function _unpackGeckoProfileFromBrowser( // global. This happens especially with tests but could happen in the future // in Firefox too. if (Object.prototype.toString.call(profile) === '[object ArrayBuffer]') { - return _extractJsonFromArrayBuffer(profile as ArrayBuffer); + return extractJsonFromArrayBuffer(profile as ArrayBuffer); } return profile; } @@ -557,9 +561,9 @@ function getSymbolStore( symbolServerUrl: string, browserConnection: BrowserConnection | null ): SymbolStore | null { - if (!window.indexedDB) { - // We could be running in a test environment with no indexedDB support. Do not - // return a symbol store in this case. + if (typeof window === 'undefined' || !window.indexedDB) { + // We could be running in a test environment or Node.js with no indexedDB support. + // Do not return a symbol store in this case. return null; } @@ -983,265 +987,6 @@ export function temporaryError(error: TemporaryError): Action { }; } -function _wait(delayMs: number): Promise { - return new Promise((resolve) => setTimeout(resolve, delayMs)); -} - -function _loadProbablyFailedDueToSafariLocalhostHTTPRestriction( - url: string, - error: Error -): boolean { - if (!navigator.userAgent.match(/Safari\/\d+\.\d+/)) { - return false; - } - // Check if Safari considers this mixed content. - const parsedUrl = new URL(url); - return ( - error.name === 'TypeError' && - parsedUrl.protocol === 'http:' && - isLocalURL(parsedUrl) && - location.protocol === 'https:' - ); -} - -class SafariLocalhostHTTPLoadError extends Error { - override name = 'SafariLocalhostHTTPLoadError'; -} - -type FetchProfileArgs = { - url: string; - onTemporaryError: (param: TemporaryError) => void; - // Allow tests to capture the reported error, but normally use console.error. - reportError?: (...data: Array) => void; -}; - -type ProfileOrZip = - | { responseType: 'PROFILE'; profile: unknown } - | { responseType: 'ZIP'; zip: JSZip }; - -/** - * Tries to fetch a profile on `url`. If the profile is not found, - * `onTemporaryError` is called with an appropriate error, we wait 1 second, and - * then tries again. If we still can't find the profile after 11 tries, the - * returned promise is rejected with a fatal error. - * If we can retrieve the profile properly, the returned promise is resolved - * with the JSON.parsed profile. - */ -export async function _fetchProfile( - args: FetchProfileArgs -): Promise { - const MAX_WAIT_SECONDS = 10; - let i = 0; - const { url, onTemporaryError } = args; - // Allow tests to capture the reported error, but normally use console.error. - const reportError = args.reportError || console.error; - - while (true) { - let response; - try { - response = await fetch(url); - } catch (e) { - // Case 1: Exception. - if (_loadProbablyFailedDueToSafariLocalhostHTTPRestriction(url, e)) { - throw new SafariLocalhostHTTPLoadError(); - } - throw e; - } - - // Case 2: successful answer. - if (response.ok) { - return _extractProfileOrZipFromResponse(url, response, reportError); - } - - // case 3: unrecoverable error. - if (response.status !== 403) { - throw new Error(oneLine` - Could not fetch the profile on remote server. - Response was: ${response.status} ${response.statusText}. - `); - } - - // case 4: 403 errors can be transient while a profile is uploaded. - - if (i++ === MAX_WAIT_SECONDS) { - // In the last iteration we don't send a temporary error because we'll - // throw an error right after the while loop. - break; - } - - onTemporaryError( - new TemporaryError( - 'Profile not found on remote server.', - { count: i, total: MAX_WAIT_SECONDS + 1 } // 11 tries during 10 seconds - ) - ); - - await _wait(1000); - } - - throw new Error(oneLine` - Could not fetch the profile on remote server: - still not found after ${MAX_WAIT_SECONDS} seconds. - `); -} - -/** - * Deduce the file type from a url and content type. Third parties can give us - * arbitrary information, so make sure that we try out best to extract the proper - * information about it. - */ -function _deduceContentType( - url: string, - contentType: string | null -): 'application/json' | 'application/zip' | null { - if (contentType === 'application/zip' || contentType === 'application/json') { - return contentType; - } - if (url.match(/\.zip$/)) { - return 'application/zip'; - } - if (url.match(/\.json/)) { - return 'application/json'; - } - return null; -} - -/** - * This function guesses the correct content-type (even if one isn't sent) and then - * attempts to use the proper method to extract the response. - */ -async function _extractProfileOrZipFromResponse( - url: string, - response: Response, - reportError: (...data: Array) => void -): Promise { - const contentType = _deduceContentType( - url, - response.headers.get('content-type') - ); - switch (contentType) { - case 'application/zip': - return { - responseType: 'ZIP', - zip: await _extractZipFromResponse(response, reportError), - }; - case 'application/json': - case null: - // The content type is null if it is unknown, or an unsupported type. Go ahead - // and try to process it as a profile. - return { - responseType: 'PROFILE', - profile: await _extractJsonFromResponse( - response, - reportError, - contentType - ), - }; - default: - throw assertExhaustiveCheck(contentType); - } -} - -/** - * Attempt to load a zip file from a third party. This process can fail, so make sure - * to handle and report the error if it does. - */ -async function _extractZipFromResponse( - response: Response, - reportError: (...data: Array) => void -): Promise { - const buffer = await response.arrayBuffer(); - // Workaround for https://github.com/Stuk/jszip/issues/941 - // When running this code in tests, `buffer` doesn't inherits from _this_ - // realm's ArrayBuffer object, and this breaks JSZip which doesn't account for - // this case. We workaround the issue by wrapping the buffer in an Uint8Array - // that comes from this realm. - const typedBuffer = new Uint8Array(buffer); - try { - const { default: JSZip } = await import('jszip'); - const zip = await JSZip.loadAsync(typedBuffer); - // Catch the error if unable to load the zip. - return zip; - } catch (error) { - const message = 'Unable to open the archive file.'; - reportError(message); - reportError('Error:', error); - reportError('Fetch response:', response); - throw new Error( - `${message} The full error information has been printed out to the DevTool’s console.` - ); - } -} - -/** - * Parse JSON from an optionally gzipped array buffer. - */ -async function _extractJsonFromArrayBuffer( - arrayBuffer: ArrayBuffer -): Promise { - let profileBytes = new Uint8Array(arrayBuffer); - // Check for the gzip magic number in the header. - if (isGzip(profileBytes)) { - profileBytes = await decompress(profileBytes); - } - - const textDecoder = new TextDecoder(); - return JSON.parse(textDecoder.decode(profileBytes)); -} - -/** - * Don't trust third party responses, try and handle a variety of responses gracefully. - */ -async function _extractJsonFromResponse( - response: Response, - reportError: (...data: Array) => void, - fileType: 'application/json' | null -): Promise { - let arrayBuffer: ArrayBuffer | null = null; - try { - // await before returning so that we can catch JSON parse errors. - arrayBuffer = await response.arrayBuffer(); - return await _extractJsonFromArrayBuffer(arrayBuffer); - } catch (error) { - // Change the error message depending on the circumstance: - let message; - if (error && typeof error === 'object' && error.name === 'AbortError') { - message = 'The network request to load the profile was aborted.'; - } else if (fileType === 'application/json') { - message = 'The profile’s JSON could not be decoded.'; - } else if (fileType === null && arrayBuffer !== null) { - // If the content type is not specified, use a raw array buffer - // to fallback to other supported profile formats. - return arrayBuffer; - } else { - message = oneLine` - The profile could not be downloaded and decoded. This does not look like a supported file - type. - `; - } - - // Provide helpful debugging information to the console. - reportError(message); - reportError('JSON parsing error:', error); - reportError('Fetch response:', response); - - throw new Error( - `${message} The full error information has been printed out to the DevTool’s console.` - ); - } -} - -export function getProfileUrlForHash(hash: string): string { - // See https://cloud.google.com/storage/docs/access-public-data - // The URL is https://storage.googleapis.com//. - // https://.storage.googleapis.com/ seems to also work but - // is not documented nowadays. - - // By convention, "profile-store" is the name of our bucket, and the file path - // is the hash we receive in the URL. - return `https://storage.googleapis.com/${GOOGLE_STORAGE_BUCKET}/${hash}`; -} - export function retrieveProfileFromStore( hash: string, initialLoad: boolean = false @@ -1262,7 +1007,7 @@ export function retrieveProfileOrZipFromUrl( dispatch(waitingForProfileFromUrl(profileUrl)); try { - const response: ProfileOrZip = await _fetchProfile({ + const response: ProfileOrZip = await fetchProfile({ url: profileUrl, onTemporaryError: (e: TemporaryError) => { dispatch(temporaryError(e)); @@ -1293,7 +1038,7 @@ export function retrieveProfileOrZipFromUrl( default: throw assertExhaustiveCheck( response as never, - 'Expected to receive an archive or profile from _fetchProfile.' + 'Expected to receive an archive or profile from fetchProfile.' ); } } catch (error) { @@ -1349,7 +1094,7 @@ export function retrieveProfileFromFile( dispatch(waitingForProfileFromFile()); try { - if (_deduceContentType(file.name, file.type) === 'application/zip') { + if (deduceContentType(file.name, file.type) === 'application/zip') { // Open a zip file in the zip file viewer const buffer = await fileReader(file).asArrayBuffer(); const { default: JSZip } = await import('jszip'); @@ -1446,14 +1191,14 @@ export function retrieveProfilesToCompare( const profileUrl = getProfileFetchUrl(url); - const response: ProfileOrZip = await _fetchProfile({ + const response: ProfileOrZip = await fetchProfile({ url: profileUrl, onTemporaryError: (e: TemporaryError) => { dispatch(temporaryError(e)); }, }); if (response.responseType !== 'PROFILE') { - throw new Error('Expected to receive a profile from _fetchProfile'); + throw new Error('Expected to receive a profile from fetchProfile'); } const upgradeInfo: ProfileUpgradeInfo = {}; diff --git a/src/actions/zipped-profiles.ts b/src/actions/zipped-profiles.ts index e51d660ae8..acde01eee1 100644 --- a/src/actions/zipped-profiles.ts +++ b/src/actions/zipped-profiles.ts @@ -57,7 +57,7 @@ export function viewProfileFromZip( try { // Attempt to unserialize the profile. const profile = await unserializeProfileOfArbitraryFormat( - await file.async('string'), + await file.async('uint8array'), pathInZipFile ); diff --git a/src/app-logic/constants.ts b/src/app-logic/constants.ts index f70f544144..a8bdb0e23c 100644 --- a/src/app-logic/constants.ts +++ b/src/app-logic/constants.ts @@ -12,7 +12,7 @@ export const GECKO_PROFILE_VERSION = 34; // The current version of the "processed" profile format. // Please don't forget to update the processed profile format changelog in // `docs-developer/CHANGELOG-formats.md`. -export const PROCESSED_PROFILE_VERSION = 61; +export const PROCESSED_PROFILE_VERSION = 62; // The following are the margin sizes for the left and right of the timeline. Independent // components need to share these values. @@ -29,18 +29,10 @@ export const TRACK_NETWORK_ROW_REPEAT = 7; export const TRACK_NETWORK_HEIGHT = TRACK_NETWORK_ROW_HEIGHT * TRACK_NETWORK_ROW_REPEAT; -// The following values are for memory track. -export const TRACK_MEMORY_GRAPH_HEIGHT = 25; -export const TRACK_MEMORY_MARKERS_HEIGHT = 15; -export const TRACK_MEMORY_HEIGHT = - TRACK_MEMORY_GRAPH_HEIGHT + TRACK_MEMORY_MARKERS_HEIGHT; -export const TRACK_MEMORY_LINE_WIDTH = 2; -export const TRACK_MEMORY_DEFAULT_COLOR = 'orange'; - -// The following values are for the bandwidth track. -export const TRACK_BANDWIDTH_HEIGHT = 25; -export const TRACK_BANDWIDTH_LINE_WIDTH = 2; -export const TRACK_BANDWIDTH_DEFAULT_COLOR = 'blue'; +// The following values are for counter tracks (Memory, Power, Bandwidth, etc.). +export const TRACK_COUNTER_GRAPH_HEIGHT = 25; +export const TRACK_COUNTER_MARKERS_HEIGHT = 15; +export const TRACK_COUNTER_LINE_WIDTH = 2; // The following values are for experimental event delay track. export const TRACK_EVENT_DELAY_HEIGHT = 40; @@ -61,15 +53,6 @@ export const TRACK_PROCESS_BLANK_HEIGHT = 30; // Height of timeline ruler. export const TIMELINE_RULER_HEIGHT = 20; -// Height of the power track. -export const TRACK_POWER_HEIGHT = 25; -export const TRACK_POWER_LINE_WIDTH = 2; -export const TRACK_POWER_DEFAULT_COLOR = 'grey'; - -// Height of the process cpu track. -export const TRACK_PROCESS_CPU_HEIGHT = 25; -export const TRACK_PROCESS_CPU_LINE_WIDTH = 2; - // JS Tracer has very high fidelity information, and needs a more fine-grained zoom. export const JS_TRACER_MAXIMUM_CHART_ZOOM = 0.001; diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index f2b1196339..02b2907421 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -53,7 +53,7 @@ import { StringTable } from 'firefox-profiler/utils/string-table'; import type { ProfileUpgradeInfo } from 'firefox-profiler/profile-logic/processed-profile-versioning'; import type { ProfileAndProfileUpgradeInfo } from 'firefox-profiler/actions/receive-profile'; -export const CURRENT_URL_VERSION = 15; +export const CURRENT_URL_VERSION = 16; /** * This static piece of state might look like an anti-pattern, but it's a relatively @@ -183,6 +183,7 @@ type BaseQuery = { type CallTreeQuery = BaseQuery & { search: string; // "js::RunScript" invertCallstack: null | undefined; + hideIdleSamples: null | undefined; ctSummary: string; }; @@ -198,6 +199,7 @@ type NetworkQuery = BaseQuery & { type StackChartQuery = BaseQuery & { search: string; // "js::RunScript" invertCallstack: null | undefined; + hideIdleSamples: null | undefined; showUserTimings: null | undefined; sameWidths: null | undefined; ctSummary: string; @@ -212,10 +214,12 @@ type Query = BaseQuery & { // CallTree/StackChart specific search?: string; invertCallstack?: null | undefined; + hideIdleSamples?: null | undefined; ctSummary?: string; transforms?: string; sourceViewIndex?: number; assemblyView?: string; + bottomFullscreen?: boolean; // StackChart specific showUserTimings?: null | undefined; @@ -309,7 +313,7 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { : urlState.profileSpecific.implementation, timelineType: // The default is the cpu-category view, so only add it to the URL if it's - // the stack or category view. + // the stack view. urlState.profileSpecific.timelineType === 'cpu-category' ? undefined : urlState.profileSpecific.timelineType, @@ -338,6 +342,11 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query.invertCallstack = urlState.profileSpecific.invertCallstack ? null : undefined; + // The URL param is inverted (`hideIdleSamples`) so the default-on state + // doesn't clutter the URL; only the non-default "hide" case is encoded. + query.hideIdleSamples = urlState.profileSpecific.includeIdleSamples + ? undefined + : null; if ( selectedThreadsKey !== null && urlState.profileSpecific.transforms[selectedThreadsKey] @@ -352,8 +361,12 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { 'timing' ? undefined : urlState.profileSpecific.lastSelectedCallTreeSummaryStrategy; - const { sourceView, assemblyView, isBottomBoxOpenPerPanel } = - urlState.profileSpecific; + const { + sourceView, + assemblyView, + isBottomBoxOpenPerPanel, + isBottomBoxFullscreen, + } = urlState.profileSpecific; if (isBottomBoxOpenPerPanel[selectedTab]) { if (sourceView.sourceIndex !== null) { @@ -365,6 +378,9 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { nativeSymbols[currentNativeSymbol] ); } + if (isBottomBoxFullscreen) { + query.bottomFullscreen = true; + } } break; } @@ -582,6 +598,7 @@ export function stateFromLocation( query.ctSummary || undefined ), invertCallstack: query.invertCallstack === undefined ? false : true, + includeIdleSamples: query.hideIdleSamples === undefined, showUserTimings: query.showUserTimings === undefined ? false : true, stackChartSameWidths: query.sameWidths === undefined ? false : true, committedRanges: query.range ? parseCommittedRanges(query.range) : [], @@ -593,6 +610,7 @@ export function stateFromLocation( sourceView, assemblyView, isBottomBoxOpenPerPanel, + isBottomBoxFullscreen: query.bottomFullscreen || false, timelineType: validateTimelineType(query.timelineType), showJsTracerSummary: query.summary === undefined ? false : true, globalTrackOrder: convertGlobalTrackOrderFromString( @@ -1351,8 +1369,260 @@ const _upgraders: { .map(mapIndexesInTransform) .join('~'); }, + [16]: ( + processedLocation: ProcessedLocationBeforeUpgrade, + profile?: Profile + ) => { + // The 'memory', 'power', 'bandwidth', and 'process-cpu' LocalTrack types + // have been collapsed into a single 'counter' type. This moves counter + // tracks into a single group inside each PID's local track array, which + // shifts the track indexes that older URLs recorded. Remap + // localTrackOrderByPid and hiddenLocalTracksByPid so existing URLs keep + // pointing at the same tracks. + const { query } = processedLocation; + if (!profile || !profile.counters || profile.counters.length === 0) { + return; + } + if (!query.localTrackOrderByPid && !query.hiddenLocalTracksByPid) { + return; + } + + const oldToNewIndexByPid = _computeV16LocalTrackIndexRemap(profile); + + if (query.localTrackOrderByPid) { + query.localTrackOrderByPid = (query.localTrackOrderByPid as string) + .split('~') + .map((pidAndTracks) => { + const dash = pidAndTracks.indexOf('-'); + if (dash === -1) { + return pidAndTracks; + } + const pid = pidAndTracks.slice(0, dash); + const encoded = pidAndTracks.slice(dash + 1); + const remap = oldToNewIndexByPid.get(pid); + if (!remap) { + return pidAndTracks; + } + const oldOrder = decodeUintArrayFromUrlComponent(encoded); + const newOrder = []; + for (const oldIndex of oldOrder) { + const newIndex = remap[oldIndex]; + if (newIndex !== undefined && newIndex !== null) { + newOrder.push(newIndex); + } + } + return `${pid}-${encodeUintArrayForUrlComponent(newOrder)}`; + }) + .join('~'); + } + + if (query.hiddenLocalTracksByPid) { + query.hiddenLocalTracksByPid = (query.hiddenLocalTracksByPid as string) + .split('~') + .map((pidAndTracks) => { + const dash = pidAndTracks.indexOf('-'); + if (dash === -1) { + return pidAndTracks; + } + const pid = pidAndTracks.slice(0, dash); + const encoded = pidAndTracks.slice(dash + 1); + const remap = oldToNewIndexByPid.get(pid); + if (!remap) { + return pidAndTracks; + } + const oldHidden = decodeUintArrayFromUrlComponent(encoded); + const newHidden = new Set(); + for (const oldIndex of oldHidden) { + const newIndex = remap[oldIndex]; + if (newIndex !== undefined && newIndex !== null) { + newHidden.add(newIndex); + } + } + return `${pid}-${encodeUintSetForUrlComponent(newHidden)}`; + }) + .join('~'); + } + }, }; +/** + * Produce a per-PID mapping from old local-track indexes (using the pre-v16 + * LOCAL_TRACK_INDEX_ORDER) to new local-track indexes (post-v16). Used by the + * v16 URL upgrader. + * + * The two layouts share the same set of tracks; only the relative positions of + * counter tracks differ. The helper reconstructs both layouts by simulating + * what computeLocalTracksByPid would have produced under each. + */ +function _computeV16LocalTrackIndexRemap( + profile: Profile +): Map> { + // Pre-v16 LOCAL_TRACK_INDEX_ORDER. + // Note: we don't preserve 'event-delay' and 'process-cpu' in URLs, + // as they are experimental. + const OLD_SLOT = { + thread: 0, + network: 1, + memory: 2, + ipc: 3, + marker: 7, + power: 6, + bandwidth: 8, + }; + // Post-v16 LOCAL_TRACK_INDEX_ORDER. + const NEW_SLOT = { + thread: 0, + network: 1, + counter: 2, + ipc: 3, + marker: 5, + }; + + type Entry = { + id: string; + oldSlot: number | null; + newSlot: number | null; + }; + + const entriesByPid = new Map(); + const ensurePid = (pid: Pid): Entry[] => { + let entries = entriesByPid.get(pid); + if (entries === undefined) { + entries = []; + entriesByPid.set(pid, entries); + } + return entries; + }; + + const markerSchemasWithGraphs = (profile.meta.markerSchema || []).filter( + (schema) => Array.isArray(schema.graphs) && schema.graphs.length > 0 + ); + + for ( + let threadIndex = 0; + threadIndex < profile.threads.length; + threadIndex++ + ) { + const thread = profile.threads[threadIndex]; + const { pid, markers } = thread; + + if (!thread.isMainThread) { + ensurePid(pid).push({ + id: `t:${threadIndex}`, + oldSlot: OLD_SLOT.thread, + newSlot: NEW_SLOT.thread, + }); + } + if (markers.data.some((datum) => datum && datum.type === 'Network')) { + ensurePid(pid).push({ + id: `n:${threadIndex}`, + oldSlot: OLD_SLOT.network, + newSlot: NEW_SLOT.network, + }); + } + if (markers.data.some((datum) => datum && datum.type === 'IPC')) { + ensurePid(pid).push({ + id: `i:${threadIndex}`, + oldSlot: OLD_SLOT.ipc, + newSlot: NEW_SLOT.ipc, + }); + } + + if (markerSchemasWithGraphs.length > 0) { + const markerTracksBySchemaName: Map< + string, + { keys: string[]; markerNames: Set } + > = new Map(); + for (const markerSchema of markerSchemasWithGraphs) { + markerTracksBySchemaName.set(markerSchema.name, { + keys: (markerSchema.graphs || []).map((graph) => graph.key), + markerNames: new Set(), + }); + } + for (let i = 0; i < markers.length; ++i) { + const markerNameIndex = markers.name[i]; + const markerData = markers.data[i]; + if (markerData && markerData.type) { + const mapEntry = markerTracksBySchemaName.get(markerData.type); + if (mapEntry && mapEntry.keys.every((k) => k in markerData)) { + mapEntry.markerNames.add(markerNameIndex); + } + } + } + for (const [schemaName, { markerNames }] of markerTracksBySchemaName) { + for (const markerName of markerNames) { + ensurePid(pid).push({ + id: `m:${threadIndex}:${schemaName}:${markerName}`, + oldSlot: OLD_SLOT.marker, + newSlot: NEW_SLOT.marker, + }); + } + } + } + } + + const { counters } = profile; + if (counters) { + for (let counterIndex = 0; counterIndex < counters.length; counterIndex++) { + const counter = counters[counterIndex]; + const { pid, category, name, samples } = counter; + + // OLD behavior: only Memory / Bandwidth / Power produced tracks. + let oldSlot: number | null; + if (category === 'Memory') { + oldSlot = OLD_SLOT.memory; + } else if (category === 'Bandwidth') { + oldSlot = OLD_SLOT.bandwidth; + } + // We assumed there was no data when <= 2 samples. + else if (category === 'power' && samples.length > 2) { + oldSlot = OLD_SLOT.power; + } else { + oldSlot = null; + } + + // NEW behavior: mirror computeLocalTracksByPid. processCPU counters are + // added later by addProcessCPUTracksForProcess when the experimental + // toggle fires, every other counter becomes a track. + const newSlot: number | null = + category === 'CPU' && name === 'processCPU' ? null : NEW_SLOT.counter; + + if (oldSlot === null && newSlot === null) { + continue; + } + + ensurePid(pid).push({ + id: `c:${counterIndex}`, + oldSlot, + newSlot, + }); + } + } + + const remapByPid = new Map>(); + for (const [pid, entries] of entriesByPid) { + const oldList = entries + .filter((e) => e.oldSlot !== null) + .slice() + .sort((a, b) => (a.oldSlot as number) - (b.oldSlot as number)); + const newList = entries + .filter((e) => e.newSlot !== null) + .slice() + .sort((a, b) => (a.newSlot as number) - (b.newSlot as number)); + + const newIdToIndex = new Map(); + newList.forEach((entry, i) => newIdToIndex.set(entry.id, i)); + + const remap: Array = oldList.map((entry) => { + const newIndex = newIdToIndex.get(entry.id); + return newIndex === undefined ? null : newIndex; + }); + remapByPid.set(pid, remap); + } + + return remapByPid; +} + for (let destVersion = 1; destVersion <= CURRENT_URL_VERSION; destVersion++) { if (!_upgraders[destVersion]) { throw new Error(`There is no upgrader for version ${destVersion}.`); @@ -1445,14 +1715,10 @@ function getVersion4JSCallNodePathFromStackIndex( function validateTimelineType( timelineType: string | null | undefined ): TimelineType { - const VALID_TIMELINE_TYPES: Record = { - stack: true, - category: true, - 'cpu-category': true, - }; - if (timelineType && timelineType in VALID_TIMELINE_TYPES) { - return timelineType as TimelineType; + if (timelineType === 'stack') { + return 'stack'; } + // 'category' is an old value that is treated as 'cpu-category'. return 'cpu-category'; } diff --git a/src/components/app/BottomBox.css b/src/components/app/BottomBox.css index dc80d0345b..a35d14361d 100644 --- a/src/components/app/BottomBox.css +++ b/src/components/app/BottomBox.css @@ -2,10 +2,21 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +.bottom-box-fullscreen { + position: fixed; + z-index: 100; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + .bottom-box-pane { --internal-sourceview-background-color: var(--grey-20); --internal-close-icon: url(../../../res/img/svg/close-dark.svg); --internal-assembly-icon: url(../../../res/img/svg/asm-icon.svg); + --internal-fullscreen-icon: url(../../../res/img/svg/fullscreen.svg); + --internal-fullscreen-exit-icon: url(../../../res/img/svg/fullscreen-exit.svg); display: flex; height: 100%; /* direct child of SplitterLayout */ @@ -73,6 +84,8 @@ .bottom-close-button, .bottom-assembly-button, +.bottom-fullscreen-hide-button, +.bottom-fullscreen-show-button, .bottom-prev-button, .bottom-next-button { width: 24px; @@ -101,6 +114,14 @@ background-image: var(--internal-assembly-icon); } +.bottom-fullscreen-show-button { + background-image: var(--internal-fullscreen-icon); +} + +.bottom-fullscreen-hide-button { + background-image: var(--internal-fullscreen-exit-icon); +} + .codeLoadingOverlay, .sourceCodeErrorOverlay, .assemblyCodeErrorOverlay { @@ -162,6 +183,8 @@ --internal-sourceview-background-color: var(--grey-80); --internal-close-icon: url(../../../res/img/svg/close-light.svg); --internal-assembly-icon: url(../../../res/img/svg/asm-icon-light.svg); + --internal-fullscreen-icon: url(../../../res/img/svg/fullscreen-light.svg); + --internal-fullscreen-exit-icon: url(../../../res/img/svg/fullscreen-exit-light.svg); } .codeLoadingOverlay, diff --git a/src/components/app/BottomBox.tsx b/src/components/app/BottomBox.tsx index 96558f3b9c..8e103ce6d3 100644 --- a/src/components/app/BottomBox.tsx +++ b/src/components/app/BottomBox.tsx @@ -8,6 +8,7 @@ import classNames from 'classnames'; import { SourceView } from '../shared/SourceView'; import { AssemblyView } from '../shared/AssemblyView'; +import { FullscreenToggleButton } from './FullscreenToggleButton'; import { AssemblyViewToggleButton } from './AssemblyViewToggleButton'; import { AssemblyViewNativeSymbolNavigator } from './AssemblyViewNativeSymbolNavigator'; import { IonGraphView } from '../shared/IonGraphView'; @@ -22,9 +23,13 @@ import { getAssemblyViewScrollGeneration, getAssemblyViewScrollToInstructionAddress, getAssemblyViewHighlightedInstruction, + getIsBottomBoxFullscreen, } from 'firefox-profiler/selectors/url-state'; +import { + closeBottomBox, + toggleBottomBoxFullscreen, +} from 'firefox-profiler/actions/profile-view'; import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; -import { closeBottomBox } from 'firefox-profiler/actions/profile-view'; import { parseFileNameFromSymbolication } from 'firefox-profiler/utils/special-paths'; import { getSourceViewCode, @@ -53,6 +58,7 @@ import { Localized } from '@fluent/react'; import './BottomBox.css'; type StateProps = { + readonly isFullscreen: boolean; readonly sourceViewFile: string | null; readonly sourceViewCode: SourceCodeStatus | void; readonly sourceViewScrollGeneration: number; @@ -71,6 +77,7 @@ type StateProps = { type DispatchProps = { readonly closeBottomBox: typeof closeBottomBox; + readonly toggleBottomBoxFullscreen: typeof toggleBottomBoxFullscreen; }; type Props = ConnectedProps<{}, StateProps, DispatchProps>; @@ -153,12 +160,31 @@ class BottomBoxImpl extends React.PureComponent { _sourceView = React.createRef(); _assemblyView = React.createRef(); + override componentDidMount() { + document.addEventListener('keydown', this._onKeyDown); + } + + override componentWillUnmount() { + document.removeEventListener('keydown', this._onKeyDown); + } + + _onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && this.props.isFullscreen) { + this.props.toggleBottomBoxFullscreen(); + } + }; + _onClickCloseButton = () => { this.props.closeBottomBox(); + // Close the fullscreen if we're closing the bottom box + if (this.props.isFullscreen) { + this.props.toggleBottomBoxFullscreen(); + } }; override render() { const { + isFullscreen, sourceViewFile, sourceViewCode, globalLineTimings, @@ -200,6 +226,7 @@ class BottomBoxImpl extends React.PureComponent {
{assemblyViewIsOpen ? : null} + - - - {webChannelAvailable ? ( - -

- Enable the profiler menu button to start recording a - performance profile in Firefox, then analyze it and share it - with profiler.firefox.com. -

-
- ) : ( - , - }} - > -

- This profiler instance was unable to connect to the - WebChannel. This usually means that it’s running on a - different host from the one that is specified in the - preference{' '} - devtools.performance.recording.ui-base-url. If - you would like to capture new profiles with this instance, and - give it programmatic control of the profiler menu button, you - can go to about:config - and change the preference. -

+ + + + Enable Profiler Menu Button - )} + +
+ + {webChannelAvailable ? ( + +

+ Enable the profiler menu button to start recording a performance + profile in Firefox, then analyze it and share it with + profiler.firefox.com. +

+
+ ) : ( - ), + code: , }} >

- You can also profile Firefox for Android. For more information, - please consult this documentation:{' '} - Profiling Firefox for Android directly on device. + This profiler instance was unable to connect to the WebChannel. + This usually means that it’s running on a different host from + the one that is specified in the preference{' '} + devtools.performance.recording.ui-base-url. If you + would like to capture new profiles with this instance, and give + it programmatic control of the profiler menu button, you can go + to about:config + and change the preference.

-
- {/* end of grid container */} + )} + + ), + }} + > +

+ You can also profile Firefox for Android. For more information, + please consult this documentation:{' '} + Profiling Firefox for Android directly on device. +

+
- + {/* end of grid container */} + ); } _renderFenixInstructions() { return ( - -
- {/* Grid container: homeInstructions */} - {/* Left column: img */} - screenshot of profiler.firefox.com - {/* Right column: instructions */} -
- - - ), - }} - > -

- Firefox for Android can be profiled directly on this device. For - more information, read{' '} - Profiling Firefox for Android directly on device. -

-
- - ), - }} - > -

- You can also profile Firefox for Android remotely from Firefox - for desktop. For more information, please consult this - documentation: Profiling Firefox for Android remotely. -

-
-
- {/* end of grid container */} +
+ {/* Grid container: homeInstructions */} + {/* Left column: img */} + screenshot of profiler.firefox.com + {/* Right column: instructions */} +
+ + + ), + }} + > +

+ Firefox for Android can be profiled directly on this device. For + more information, read{' '} + Profiling Firefox for Android directly on device. +

+
+ + ), + }} + > +

+ You can also profile Firefox for Android remotely from Firefox for + desktop. For more information, please consult this documentation:{' '} + Profiling Firefox for Android remotely. +

+
- + {/* end of grid container */} +
); } @@ -440,164 +440,155 @@ class HomeImpl extends React.PureComponent { const chromeExtensionUrl = 'https://chromewebstore.google.com/detail/firefox-profiler/ljmahpnflmbkgaipnfbpgjipcnahlghn'; return ( - -
- {/* Grid container: homeInstructions */} - {/* Left column: img */} - screenshot of profiler.firefox.com - {/* Right column: instructions */} -
- - + - - Install the Chrome extension - - - - - ), - }} - > -

- Use the Firefox Profiler extension for Chrome to capture - performance profiles in Chrome and analyze them in the Firefox - Profiler. Install the extension from the Chrome Web Store. -

-
- -

- Once installed, use the extension’s toolbar icon or the - shortcuts to start and stop profiling. You can also export - profiles and load them here for detailed analysis. -

+
+ {/* Grid container: homeInstructions */} + {/* Left column: img */} + screenshot of profiler.firefox.com + {/* Right column: instructions */} + - {/* end of grid container */} + + + + ), + }} + > +

+ Use the Firefox Profiler extension for Chrome to capture + performance profiles in Chrome and analyze them in the Firefox + Profiler. Install the extension from the Chrome Web Store. +

+
+ +

+ Once installed, use the extension’s toolbar icon or the shortcuts + to start and stop profiling. You can also export profiles and load + them here for detailed analysis. +

+
+ {this._renderShortcuts()}
- + {/* end of grid container */} +
); } _renderRecordInstructions(screenshotSrc: string) { return ( - -
- {/* Grid container: homeInstructions */} - {/* Left column: img */} - Screenshot of the profiler settings from the Firefox menu. - {/* Right column: instructions */} -
- - , - }} - > -

- To start profiling, click on the profiling button, or use the - keyboard shortcuts. The icon is blue when a profile is - recording. Hit Capture to load the data into - profiler.firefox.com. -

-
- {this._renderShortcuts()} - - ), - }} - > -

- You can also profile Firefox for Android. For more information, - please consult this documentation:{' '} - Profiling Firefox for Android directly on device. -

-
-
- {/* end of grid container */} +
+ {/* Grid container: homeInstructions */} + {/* Left column: img */} + Screenshot of the profiler settings from the Firefox menu. + {/* Right column: instructions */} +
+ + , + }} + > +

+ To start profiling, click on the profiling button, or use the + keyboard shortcuts. The icon is blue when a profile is recording. + Hit Capture to load the data into profiler.firefox.com. +

+
+ {this._renderShortcuts()} + + ), + }} + > +

+ You can also profile Firefox for Android. For more information, + please consult this documentation:{' '} + Profiling Firefox for Android directly on device. +

+
- + {/* end of grid container */} +
); } _renderOtherBrowserInstructions() { return ( - -
- {/* Grid container: homeInstructions */} - {/* Left column: img */} - screenshot of profiler.firefox.com - {/* Right column: instructions */} -
- - , - }} - > -

- Recording performance profiles requires{' '} - Firefox for desktop. However, existing profiles can be - viewed in any modern browser. -

-
- - ), - }} - > -

- You can also profile Firefox for Android. For more information, - please consult this documentation:{' '} - Profiling Firefox for Android directly on device. -

-
-
- {/* end of grid container */} +
+ {/* Grid container: homeInstructions */} + {/* Left column: img */} + screenshot of profiler.firefox.com + {/* Right column: instructions */} +
+ + , + }} + > +

+ Recording performance profiles requires Firefox for desktop + . However, existing profiles can be viewed in any modern browser. +

+
+ + ), + }} + > +

+ You can also profile Firefox for Android. For more information, + please consult this documentation:{' '} + Profiling Firefox for Android directly on device. +

+
- + {/* end of grid container */} +
); } @@ -644,9 +635,9 @@ class HomeImpl extends React.PureComponent { faster.

- +
{this._renderInstructions()} - +
{/* Grid container: homeAdditionalContent */}

diff --git a/src/components/app/ProfileViewer.tsx b/src/components/app/ProfileViewer.tsx index e00a87117d..bc140e3af4 100644 --- a/src/components/app/ProfileViewer.tsx +++ b/src/components/app/ProfileViewer.tsx @@ -21,7 +21,6 @@ import { returnToZipFileList } from 'firefox-profiler/actions/zipped-profiles'; import { Timeline } from 'firefox-profiler/components/timeline'; import { getHasZipFile } from 'firefox-profiler/selectors/zipped-profiles'; import SplitterLayout from 'react-splitter-layout'; -import { invalidatePanelLayout } from 'firefox-profiler/actions/app'; import { getTimelineHeight } from 'firefox-profiler/selectors/app'; import { getIsBottomBoxOpen } from 'firefox-profiler/selectors/url-state'; import { @@ -54,7 +53,6 @@ type StateProps = { type DispatchProps = { readonly returnToZipFileList: typeof returnToZipFileList; - readonly invalidatePanelLayout: typeof invalidatePanelLayout; }; type Props = ConnectedProps<{}, StateProps, DispatchProps>; @@ -64,7 +62,6 @@ class ProfileViewerImpl extends PureComponent { const { hasZipFile, returnToZipFileList, - invalidatePanelLayout, timelineHeight, isUploading, uploadProgress, @@ -136,7 +133,6 @@ class ProfileViewerImpl extends PureComponent { primaryIndex={1} // The Timeline is secondary. secondaryInitialSize={270} - onDragEnd={invalidatePanelLayout} > { primaryIndex={0} // The BottomBox is secondary. secondaryInitialSize={40} - onDragEnd={invalidatePanelLayout} > {isBottomBoxOpen ? : null} @@ -178,7 +173,6 @@ export const ProfileViewer = explicitConnect<{}, StateProps, DispatchProps>({ }), mapDispatchToProps: { returnToZipFileList, - invalidatePanelLayout, }, component: ProfileViewerImpl, }); diff --git a/src/components/app/Root.css b/src/components/app/Root.css index f19f764684..c9519c981e 100644 --- a/src/components/app/Root.css +++ b/src/components/app/Root.css @@ -3,7 +3,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ #root { - z-index: var(--z-root); display: flex; min-width: 0; /* This allows Flexible Layout to shrink this further than its min-content */ flex: 1; diff --git a/src/components/calltree/CallTree.tsx b/src/components/calltree/CallTree.tsx index 02e6b7333d..e593b01644 100644 --- a/src/components/calltree/CallTree.tsx +++ b/src/components/calltree/CallTree.tsx @@ -17,7 +17,7 @@ import { getScrollToSelectionGeneration, getFocusCallTreeGeneration, getPreviewSelectionIsBeingModified, - getCategories, + getIdleCategoryIndex, getCurrentTableViewOptions, } from 'firefox-profiler/selectors/profile'; import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; @@ -36,7 +36,7 @@ import type { State, ImplementationFilter, ThreadsKey, - CategoryList, + IndexIntoCategoryList, IndexIntoCallNodeTable, CallNodeDisplayData, WeightType, @@ -60,7 +60,7 @@ type StateProps = { readonly focusCallTreeGeneration: number; readonly tree: CallTreeType; readonly callNodeInfo: CallNodeInfo; - readonly categories: CategoryList; + readonly idleCategoryIndex: IndexIntoCategoryList | null; readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null; readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; readonly expandedCallNodeIndexes: Array; @@ -300,7 +300,7 @@ class CallTreeImpl extends PureComponent { expandedCallNodeIndexes, selectedCallNodeIndex, callNodeInfo, - categories, + idleCategoryIndex, } = this.props; if (selectedCallNodeIndex !== null || expandedCallNodeIndexes.length > 0) { @@ -308,10 +308,6 @@ class CallTreeImpl extends PureComponent { return; } - const idleCategoryIndex = categories.findIndex( - (category) => category.name === 'Idle' - ); - const newExpandedCallNodeIndexes = expandedCallNodeIndexes.slice(); const maxInterestingDepth = 17; // scientifically determined let currentCallNodeIndex = tree.getRoots()[0]; @@ -402,7 +398,7 @@ export const CallTree = explicitConnect<{}, StateProps, DispatchProps>({ focusCallTreeGeneration: getFocusCallTreeGeneration(state), tree: selectedThreadSelectors.getCallTree(state), callNodeInfo: selectedThreadSelectors.getCallNodeInfo(state), - categories: getCategories(state), + idleCategoryIndex: getIdleCategoryIndex(state), selectedCallNodeIndex: selectedThreadSelectors.getSelectedCallNodeIndex(state), rightClickedCallNodeIndex: diff --git a/src/components/calltree/CallTreeEmptyReasons.tsx b/src/components/calltree/CallTreeEmptyReasons.tsx index 859225bf36..aa24a462e9 100644 --- a/src/components/calltree/CallTreeEmptyReasons.tsx +++ b/src/components/calltree/CallTreeEmptyReasons.tsx @@ -5,6 +5,7 @@ import { PureComponent } from 'react'; import { EmptyReasons } from '../shared/EmptyReasons'; import { selectedThreadSelectors } from '../../selectors/per-thread'; +import { getIncludeIdleSamples } from 'firefox-profiler/selectors/url-state'; import { oneLine } from 'common-tags'; import explicitConnect, { type ConnectedProps, @@ -16,6 +17,7 @@ type StateProps = { threadName: string; rangeFilteredThread: Thread; thread: Thread; + includeIdleSamples: boolean; }; type Props = ConnectedProps<{}, StateProps, {}>; @@ -26,7 +28,9 @@ type Props = ConnectedProps<{}, StateProps, {}>; */ class CallTreeEmptyReasonsImpl extends PureComponent { override render() { - const { thread, rangeFilteredThread, threadName } = this.props; + const { thread, rangeFilteredThread, threadName, includeIdleSamples } = + this.props; + // FIXME: These strings should be localized. let reason; if (thread.samples.length === 0) { @@ -34,9 +38,12 @@ class CallTreeEmptyReasonsImpl extends PureComponent { } else if (rangeFilteredThread.samples.length === 0) { reason = 'Broaden the selected range to view samples.'; } else { + const idleHint = includeIdleSamples + ? '' + : ', checking "Include idle samples"'; reason = oneLine` - Try broadening the selected range, removing search terms, or call tree transforms - to view samples. + Try broadening the selected range, removing search terms${idleHint}, + or call tree transforms to view samples. `; } @@ -55,6 +62,7 @@ export const CallTreeEmptyReasons = explicitConnect<{}, StateProps, {}>({ threadName: selectedThreadSelectors.getFriendlyThreadName(state), thread: selectedThreadSelectors.getThread(state), rangeFilteredThread: selectedThreadSelectors.getRangeFilteredThread(state), + includeIdleSamples: getIncludeIdleSamples(state), }), component: CallTreeEmptyReasonsImpl, }); diff --git a/src/components/flame-graph/FlameGraphEmptyReasons.tsx b/src/components/flame-graph/FlameGraphEmptyReasons.tsx index 7b61a235fd..fe51451472 100644 --- a/src/components/flame-graph/FlameGraphEmptyReasons.tsx +++ b/src/components/flame-graph/FlameGraphEmptyReasons.tsx @@ -5,6 +5,7 @@ import { PureComponent } from 'react'; import { EmptyReasons } from '../shared/EmptyReasons'; import { selectedThreadSelectors } from '../../selectors/per-thread'; +import { getIncludeIdleSamples } from 'firefox-profiler/selectors/url-state'; import { oneLine } from 'common-tags'; import explicitConnect, { type ConnectedProps, @@ -16,6 +17,7 @@ type StateProps = { threadName: string; rangeFilteredThread: Thread; thread: Thread; + includeIdleSamples: boolean; }; type Props = ConnectedProps<{}, StateProps, {}>; @@ -26,7 +28,9 @@ type Props = ConnectedProps<{}, StateProps, {}>; */ class FlameGraphEmptyReasonsImpl extends PureComponent { override render() { - const { thread, rangeFilteredThread, threadName } = this.props; + const { thread, rangeFilteredThread, threadName, includeIdleSamples } = + this.props; + // FIXME: These strings should be localized. let reason; if (thread.samples.length === 0) { @@ -34,9 +38,12 @@ class FlameGraphEmptyReasonsImpl extends PureComponent { } else if (rangeFilteredThread.samples.length === 0) { reason = 'Broaden the selected range to view samples.'; } else { + const idleHint = includeIdleSamples + ? '' + : ', checking "Include idle samples"'; reason = oneLine` - Try broadening the selected range, removing search terms, or call tree transforms - to view samples. + Try broadening the selected range, removing search terms${idleHint}, + or call tree transforms to view samples. `; } @@ -55,6 +62,7 @@ export const FlameGraphEmptyReasons = explicitConnect<{}, StateProps, {}>({ threadName: selectedThreadSelectors.getFriendlyThreadName(state), thread: selectedThreadSelectors.getThread(state), rangeFilteredThread: selectedThreadSelectors.getRangeFilteredThread(state), + includeIdleSamples: getIncludeIdleSamples(state), }), component: FlameGraphEmptyReasonsImpl, }); diff --git a/src/components/shared/SourceView-codemirror.ts b/src/components/shared/SourceView-codemirror.ts index 5365efbe54..ea61ab2357 100644 --- a/src/components/shared/SourceView-codemirror.ts +++ b/src/components/shared/SourceView-codemirror.ts @@ -20,7 +20,7 @@ */ import { EditorView, lineNumbers } from '@codemirror/view'; import { EditorState, Compartment } from '@codemirror/state'; -import { syntaxHighlighting } from '@codemirror/language'; +import { type LanguageSupport, syntaxHighlighting } from '@codemirror/language'; import { classHighlighter } from '@lezer/highlight'; import { cpp } from '@codemirror/lang-cpp'; import { rust } from '@codemirror/lang-rust'; @@ -46,9 +46,7 @@ const highlightedLineConf = new Compartment(); const lineNumbersConf = new Compartment(); // Detect the right language based on the file extension. -function _languageExtForPath( - path: string | null -): any /* LanguageSupport | [] */ { +function _languageExtForPath(path: string | null): LanguageSupport | [] { if (path === null) { return []; } @@ -77,7 +75,11 @@ function _languageExtForPath( ) { return cpp(); } - return []; + + // Fallback to JavaScript highlighting. Inline scripts share the page URL, so + // their path won't have a .js extension. This may be incorrect for + // unknown/unsupported file types, but is the best guess for the common case. + return javascript(); } // Adjustments to make a CodeMirror editor work as a non-editable code viewer. diff --git a/src/components/shared/StackSettings.tsx b/src/components/shared/StackSettings.tsx index 2a502293c9..f0b558d375 100644 --- a/src/components/shared/StackSettings.tsx +++ b/src/components/shared/StackSettings.tsx @@ -7,18 +7,23 @@ import { Localized } from '@fluent/react'; import { changeInvertCallstack, + changeIncludeIdleSamples, changeCallTreeSearchString, changeShowUserTimings, changeStackChartSameWidths, } from 'firefox-profiler/actions/profile-view'; import { getInvertCallstack, + getIncludeIdleSamples, getSelectedTab, getShowUserTimings, getStackChartSameWidths, getCurrentSearchString, } from 'firefox-profiler/selectors/url-state'; -import { getProfileUsesMultipleStackTypes } from 'firefox-profiler/selectors/profile'; +import { + getIdleCategoryIndex, + getProfileUsesMultipleStackTypes, +} from 'firefox-profiler/selectors/profile'; import { PanelSearch } from './PanelSearch'; import { StackImplementationSetting } from './StackImplementationSetting'; import { CallTreeStrategySetting } from './CallTreeStrategySetting'; @@ -39,6 +44,8 @@ type StateProps = { readonly selectedTab: string; readonly allowSwitchingStackType: boolean; readonly invertCallstack: boolean; + readonly includeIdleSamples: boolean; + readonly hasIdleCategory: boolean; readonly showUserTimings: boolean; readonly stackChartSameWidths: boolean; readonly currentSearchString: string; @@ -48,6 +55,7 @@ type StateProps = { type DispatchProps = { readonly changeInvertCallstack: typeof changeInvertCallstack; + readonly changeIncludeIdleSamples: typeof changeIncludeIdleSamples; readonly changeShowUserTimings: typeof changeShowUserTimings; readonly changeCallTreeSearchString: typeof changeCallTreeSearchString; readonly changeStackChartSameWidths: typeof changeStackChartSameWidths; @@ -60,6 +68,10 @@ class StackSettingsImpl extends PureComponent { this.props.changeInvertCallstack(e.currentTarget.checked); }; + _onIncludeIdleSamplesClick = (e: React.ChangeEvent) => { + this.props.changeIncludeIdleSamples(e.currentTarget.checked); + }; + _onShowUserTimingsClick = (e: React.ChangeEvent) => { this.props.changeShowUserTimings(e.currentTarget.checked); }; @@ -76,6 +88,8 @@ class StackSettingsImpl extends PureComponent { const { allowSwitchingStackType, invertCallstack, + includeIdleSamples, + hasIdleCategory, selectedTab, showUserTimings, stackChartSameWidths, @@ -86,6 +100,10 @@ class StackSettingsImpl extends PureComponent { } = this.props; const hasAllocations = hasUsefulJsAllocations || hasUsefulNativeAllocations; + const showInvertCallstack = !hideInvertCallstack; + const showStackChartOptions = selectedTab === 'stack-chart'; + const showSettingsItem = + showInvertCallstack || showStackChartOptions || hasIdleCategory; return (
@@ -100,9 +118,9 @@ class StackSettingsImpl extends PureComponent { ) : null} - {hideInvertCallstack && selectedTab !== 'stack-chart' ? null : ( + {showSettingsItem ? (
  • - {hideInvertCallstack ? null : ( + {showInvertCallstack ? ( - )} - {selectedTab !== 'stack-chart' ? null : ( + ) : null} + {hasIdleCategory ? ( + + ) : null} + {showStackChartOptions ? ( <> - )} + ) : null}
  • - )} + ) : null} ({ allowSwitchingStackType: getProfileUsesMultipleStackTypes(state), invertCallstack: getInvertCallstack(state), + includeIdleSamples: getIncludeIdleSamples(state), + hasIdleCategory: getIdleCategoryIndex(state) !== null, selectedTab: getSelectedTab(state), showUserTimings: getShowUserTimings(state), stackChartSameWidths: getStackChartSameWidths(state), @@ -183,6 +219,7 @@ export const StackSettings = explicitConnect< }), mapDispatchToProps: { changeInvertCallstack, + changeIncludeIdleSamples, changeCallTreeSearchString, changeShowUserTimings, changeStackChartSameWidths, diff --git a/src/components/shared/chart/Canvas.tsx b/src/components/shared/chart/Canvas.tsx index b2dc0380e7..4f12710e52 100644 --- a/src/components/shared/chart/Canvas.tsx +++ b/src/components/shared/chart/Canvas.tsx @@ -85,10 +85,9 @@ export class ChartCanvas extends React.Component< State > { _devicePixelRatio: number = 1; - // The current mouse position. Needs to be stored for tooltip - // hit-test if props update. - _offsetX: CssPixels = 0; - _offsetY: CssPixels = 0; + // The current mouse position inside the canvas, or null if the mouse + // is outside. Needs to be stored for tooltip hit-test if props update. + _mousePosition: { x: CssPixels; y: CssPixels } | null = null; // The position of the most recent mouse down event. Needed for // comparison with the current mouse position in order to // distinguish between clicks and drags. @@ -267,8 +266,9 @@ export class ChartCanvas extends React.Component< this.props.onMouseMove(event); } - this._offsetX = event.nativeEvent.offsetX; - this._offsetY = event.nativeEvent.offsetY; + const offsetX = event.nativeEvent.offsetX; + const offsetY = event.nativeEvent.offsetY; + this._mousePosition = { x: offsetX, y: offsetY }; // event.buttons is a bitfield representing which buttons are pressed at the // time of the mousemove event. The first bit is for the left click. // This operation checks if the left button is clicked, but this will also @@ -281,15 +281,15 @@ export class ChartCanvas extends React.Component< if ( !this._mouseMovedWhileClicked && hasLeftClick && - (Math.abs(this._offsetX - this._mouseDownOffsetX) > + (Math.abs(offsetX - this._mouseDownOffsetX) > MOUSE_CLICK_MAX_MOVEMENT_DELTA || - Math.abs(this._offsetY - this._mouseDownOffsetY) > + Math.abs(offsetY - this._mouseDownOffsetY) > MOUSE_CLICK_MAX_MOVEMENT_DELTA) ) { this._mouseMovedWhileClicked = true; } - const maybeHoveredItem = this.props.hitTest(this._offsetX, this._offsetY); + const maybeHoveredItem = this.props.hitTest(offsetX, offsetY); if (maybeHoveredItem !== null) { if (this.state.selectedItem === null) { // Update both the hovered item and the pageX and pageY values. The @@ -323,6 +323,7 @@ export class ChartCanvas extends React.Component< }; _onMouseOut = () => { + this._mousePosition = null; if ( this.state.hoveredItem !== null && // This persistTooltips property is part of the web console API. It helps @@ -390,18 +391,16 @@ export class ChartCanvas extends React.Component< }; override UNSAFE_componentWillReceiveProps() { - // It is possible that the data backing the chart has been - // changed, for instance after symbolication. Clear the - // hoveredItem if the mouse no longer hovers over it. - const { hoveredItem } = this.state; - if ( - hoveredItem !== null && - !hoveredItemsAreEqual( - this.props.hitTest(this._offsetX, this._offsetY), - hoveredItem - ) - ) { - this.setState({ hoveredItem: null }); + // Update the hovered item if the rendered data has changed or if + // the chart has been scrolled so that a new element is under the + // mouse cursor. + if (!this._mousePosition) { + return; + } + const { x, y } = this._mousePosition; + const newHoveredItem = this.props.hitTest(x, y); + if (!hoveredItemsAreEqual(newHoveredItem, this.state.hoveredItem)) { + this.setState({ hoveredItem: newHoveredItem }); } } diff --git a/src/components/shared/chart/Viewport.tsx b/src/components/shared/chart/Viewport.tsx index 4471702639..8d732deeda 100644 --- a/src/components/shared/chart/Viewport.tsx +++ b/src/components/shared/chart/Viewport.tsx @@ -6,10 +6,7 @@ import * as React from 'react'; import classNames from 'classnames'; import explicitConnect from 'firefox-profiler/utils/connect'; import { getResizeObserverWrapper } from 'firefox-profiler/utils/resize-observer-wrapper'; -import { - getHasZoomedViaMousewheel, - getPanelLayoutGeneration, -} from 'firefox-profiler/selectors/app'; +import { getHasZoomedViaMousewheel } from 'firefox-profiler/selectors/app'; import { setHasZoomedViaMousewheel } from 'firefox-profiler/actions/app'; import { updatePreviewSelection } from 'firefox-profiler/actions/profile-view'; @@ -132,7 +129,6 @@ export type Viewport = { }; type ChartViewportImplStateProps = { - readonly panelLayoutGeneration: number; readonly hasZoomedViaMousewheel?: boolean; }; @@ -316,10 +312,6 @@ class ChartViewportImpl extends React.PureComponent< this.setState({ horizontalViewport, }); - } else if ( - this.props.panelLayoutGeneration !== newProps.panelLayoutGeneration - ) { - this._setSizeNextFrame(); } } @@ -871,7 +863,6 @@ export function withChartViewport( ChartViewportImplDispatchProps >({ mapStateToProps: (state) => ({ - panelLayoutGeneration: getPanelLayoutGeneration(state), hasZoomedViaMousewheel: getHasZoomedViaMousewheel(state), }), mapDispatchToProps: { setHasZoomedViaMousewheel, updatePreviewSelection }, diff --git a/src/components/shared/thread/ActivityGraph.tsx b/src/components/shared/thread/ActivityGraph.tsx index 60dcefc65b..5310970e54 100644 --- a/src/components/shared/thread/ActivityGraph.tsx +++ b/src/components/shared/thread/ActivityGraph.tsx @@ -23,7 +23,6 @@ import type { IndexIntoSamplesTable, Milliseconds, CssPixels, - TimelineType, } from 'firefox-profiler/types'; import type { ActivityFillGraphQuerier, @@ -49,9 +48,7 @@ export type Props = { a: IndexIntoSamplesTable, b: IndexIntoSamplesTable ) => number; - readonly enableCPUUsage: boolean; readonly implementationFilter: ImplementationFilter; - readonly timelineType: TimelineType; readonly zeroAt: Milliseconds; readonly profileTimelineUnit: string; } & SizeProps; @@ -136,11 +133,9 @@ class ThreadActivityGraphImpl extends React.PureComponent { sampleIndexOffset, sampleSelectedStates, treeOrderSampleComparator, - enableCPUUsage, implementationFilter, width, height, - timelineType, zeroAt, profileTimelineUnit, } = this.props; @@ -168,7 +163,6 @@ class ThreadActivityGraphImpl extends React.PureComponent { categories={categories} passFillsQuerier={this._setFillsQuerier} onClick={this._onClick} - enableCPUUsage={enableCPUUsage} width={width} height={height} /> @@ -176,11 +170,7 @@ class ThreadActivityGraphImpl extends React.PureComponent { void; readonly onClick: (param: React.MouseEvent) => void; - readonly enableCPUUsage: boolean; } & SizeProps; export class ActivityGraphCanvas extends React.PureComponent { @@ -132,7 +131,6 @@ export class ActivityGraphCanvas extends React.PureComponent { sampleIndexOffset, sampleSelectedStates, treeOrderSampleComparator, - enableCPUUsage, width, height, } = this.props; @@ -153,7 +151,6 @@ export class ActivityGraphCanvas extends React.PureComponent { rangeEnd, sampleIndexOffset, sampleSelectedStates, - enableCPUUsage, xPixelsPerMs: canvasPixelWidth / (rangeEnd - rangeStart), treeOrderSampleComparator, categoryDrawStyles: this._getCategoryDrawStyles(ctx!), diff --git a/src/components/shared/thread/ActivityGraphFills.tsx b/src/components/shared/thread/ActivityGraphFills.tsx index 9a4f88df79..0b01e803dd 100644 --- a/src/components/shared/thread/ActivityGraphFills.tsx +++ b/src/components/shared/thread/ActivityGraphFills.tsx @@ -36,7 +36,6 @@ type RenderedComponentSettings = { readonly rangeEnd: Milliseconds; readonly sampleIndexOffset: number; readonly xPixelsPerMs: number; - readonly enableCPUUsage: boolean; readonly treeOrderSampleComparator: | ((a: IndexIntoSamplesTable, b: IndexIntoSamplesTable) => number) | null; @@ -215,7 +214,6 @@ export class ActivityGraphFillComputer { fullThread, rangeFilteredThread: { samples }, interval, - enableCPUUsage, sampleIndexOffset, rangeStart, sampleSelectedStates, @@ -243,12 +241,8 @@ export class ActivityGraphFillComputer { const nextSampleTime = samples.time[i + 1]; const category = samples.category[i]; - let beforeSampleCpuPercent = 100; - let afterSampleCpuPercent = 100; - if (enableCPUUsage) { - beforeSampleCpuPercent = threadCPUPercent[i]; - afterSampleCpuPercent = threadCPUPercent[i + 1]; - } + const beforeSampleCpuPercent = threadCPUPercent[i]; + const afterSampleCpuPercent = threadCPUPercent[i + 1]; const percentageBuffers = this.mutablePercentageBuffers[category]; const selectedState = sampleSelectedStates[i]; @@ -273,12 +267,8 @@ export class ActivityGraphFillComputer { const lastIdx = samples.length - 1; const lastSampleCategory = samples.category[lastIdx]; - let beforeSampleCpuPercent = 100; - let afterSampleCpuPercent = 100; - if (enableCPUUsage) { - beforeSampleCpuPercent = threadCPUPercent[lastIdx]; - afterSampleCpuPercent = threadCPUPercent[lastIdx + 1]; // guaranteed to exist - } + const beforeSampleCpuPercent = threadCPUPercent[lastIdx]; + const afterSampleCpuPercent = threadCPUPercent[lastIdx + 1]; // guaranteed to exist const nextSampleTime = sampleTime + interval; const percentageBuffers = this.mutablePercentageBuffers[lastSampleCategory]; @@ -596,7 +586,6 @@ export class ActivityFillGraphQuerier { ): number { const { rangeFilteredThread: { samples }, - enableCPUUsage, interval, sampleIndexOffset, fullThread, @@ -617,13 +606,9 @@ export class ActivityFillGraphQuerier { ? fullThread.samples.time[fullThreadSample + 1] : sampleTime + interval; - let beforeSampleCpuPercent = 100; - let afterSampleCpuPercent = 100; const { threadCPUPercent } = samples; - if (enableCPUUsage) { - beforeSampleCpuPercent = threadCPUPercent[sample]; - afterSampleCpuPercent = threadCPUPercent[sample + 1]; // guaranteed to exist - } + const beforeSampleCpuPercent = threadCPUPercent[sample]; + const afterSampleCpuPercent = threadCPUPercent[sample + 1]; // guaranteed to exist const kernelRangeStartTime = rangeStart + kernelPos / xPixelsPerMs; diff --git a/src/components/shared/thread/SampleGraph.tsx b/src/components/shared/thread/SampleGraph.tsx index a478a75285..7790794440 100644 --- a/src/components/shared/thread/SampleGraph.tsx +++ b/src/components/shared/thread/SampleGraph.tsx @@ -25,17 +25,14 @@ import type { IndexIntoSamplesTable, Milliseconds, CssPixels, - TimelineType, ImplementationFilter, } from 'firefox-profiler/types'; import { SelectedState } from 'firefox-profiler/types'; import type { SizeProps } from 'firefox-profiler/components/shared/WithSize'; -import type { CpuRatioInTimeRange } from './ActivityGraphFills'; import { lightDark } from 'firefox-profiler/utils/dark-mode'; export type HoveredPixelState = { readonly sample: IndexIntoSamplesTable | null; - readonly cpuRatioInTimeRange: CpuRatioInTimeRange | null; }; type Props = { @@ -51,7 +48,6 @@ type Props = { sampleIndex: IndexIntoSamplesTable | null ) => void; readonly trackName: string; - readonly timelineType: TimelineType; readonly implementationFilter: ImplementationFilter; readonly zeroAt: Milliseconds; readonly profileTimelineUnit: string; @@ -252,7 +248,7 @@ class ThreadSampleGraphCanvas extends React.PureComponent { } export class ThreadSampleGraphImpl extends PureComponent { - override state = { + override state: State = { hoveredPixelState: null, mouseX: 0, mouseY: 0, @@ -333,7 +329,6 @@ export class ThreadSampleGraphImpl extends PureComponent { return { sample: sampleIndex, - cpuRatioInTimeRange: null, }; } @@ -341,7 +336,6 @@ export class ThreadSampleGraphImpl extends PureComponent { const { className, trackName, - timelineType, categories, implementationFilter, thread, @@ -379,12 +373,8 @@ export class ThreadSampleGraphImpl extends PureComponent { {hoveredPixelState === null ? null : ( { getMarker, marginLeft, useStackChartSameWidths, + searchFilteredFuncMatchesBitSet, viewport: { containerWidth, containerHeight, @@ -480,9 +485,11 @@ class StackChartCanvasImpl extends React.PureComponent { // Look up information about this stack frame. let text, category, isSelected; + let currentFuncIndex: number | null = null; if ('callNode' in stackTiming && stackTiming.callNode) { const callNodeIndex = stackTiming.callNode[i]; const funcIndex = callNodeTable.func[callNodeIndex]; + currentFuncIndex = funcIndex; const funcNameIndex = thread.funcTable.name[funcIndex]; text = thread.stringTable.getString(funcNameIndex); const categoryIndex = callNodeTable.category[callNodeIndex]; @@ -507,9 +514,19 @@ class StackChartCanvasImpl extends React.PureComponent { depth === hoveredItem.depth && i === hoveredItem.stackTimingIndex; - const colorStyles = mapCategoryColorNameToStackChartStyles( - category.color - ); + // When a search is active, use the dimmed style for non-matching nodes + // so that matching nodes stand out with their category color. + // Hovered or selected nodes always use their real category color. + const isDimmed = + searchFilteredFuncMatchesBitSet !== null && + currentFuncIndex !== null && + !checkBit(searchFilteredFuncMatchesBitSet, currentFuncIndex) && + !isHovered && + !isSelected; + const colorStyles = isDimmed + ? getDimmedStyles() + : mapCategoryColorNameToStackChartStyles(category.color); + // Draw the box. fastFillStyle.set( isHovered || isSelected @@ -542,11 +559,11 @@ class StackChartCanvasImpl extends React.PureComponent { if (textW > textMeasurement.minWidth) { const fittedText = textMeasurement.getFittedText(text, textW); if (fittedText) { - fastFillStyle.set( - isHovered || isSelected - ? colorStyles.getSelectedTextColor() - : getForegroundColor() - ); + if (isHovered || isSelected || isDimmed) { + fastFillStyle.set(colorStyles.getSelectedTextColor()); + } else { + fastFillStyle.set(getForegroundColor()); + } ctx.fillText(fittedText, textX, intY + textDevicePixelsOffsetTop); } } @@ -665,7 +682,9 @@ class StackChartCanvasImpl extends React.PureComponent { }; _onDoubleClickStack = (hoveredItem: HoveredStackTiming | null) => { - if (!hoveredItem) return; + if (!hoveredItem) { + return; + } const result = this._getCallNodeIndexOrMarkerIndexFromHoveredItem(hoveredItem); diff --git a/src/components/stack-chart/StackChartEmptyReasons.tsx b/src/components/stack-chart/StackChartEmptyReasons.tsx index d08ff26ec9..6f6a3be387 100644 --- a/src/components/stack-chart/StackChartEmptyReasons.tsx +++ b/src/components/stack-chart/StackChartEmptyReasons.tsx @@ -5,6 +5,7 @@ import { PureComponent } from 'react'; import { EmptyReasons } from '../shared/EmptyReasons'; import { selectedThreadSelectors } from '../../selectors/per-thread'; +import { getIncludeIdleSamples } from 'firefox-profiler/selectors/url-state'; import { oneLine } from 'common-tags'; import explicitConnect, { type ConnectedProps } from '../../utils/connect'; @@ -14,6 +15,7 @@ type StateProps = { threadName: string; rangeFilteredThread: Thread; thread: Thread; + includeIdleSamples: boolean; }; type Props = ConnectedProps<{}, StateProps, {}>; @@ -24,7 +26,9 @@ type Props = ConnectedProps<{}, StateProps, {}>; */ class StackChartEmptyReasonsImpl extends PureComponent { override render() { - const { thread, rangeFilteredThread, threadName } = this.props; + const { thread, rangeFilteredThread, threadName, includeIdleSamples } = + this.props; + // FIXME: These strings should be localized. let reason; if (thread.samples.length === 0) { @@ -32,9 +36,12 @@ class StackChartEmptyReasonsImpl extends PureComponent { } else if (rangeFilteredThread.samples.length === 0) { reason = 'Broaden the selected range to view samples.'; } else { + const idleHint = includeIdleSamples + ? '' + : ', checking "Include idle samples"'; reason = oneLine` - Try broadening the selected range, removing search terms, or call tree transforms - to view samples. + Try broadening the selected range, removing search terms${idleHint}, + or call tree transforms to view samples. `; } @@ -53,6 +60,7 @@ export const StackChartEmptyReasons = explicitConnect<{}, StateProps, {}>({ threadName: selectedThreadSelectors.getFriendlyThreadName(state), thread: selectedThreadSelectors.getThread(state), rangeFilteredThread: selectedThreadSelectors.getRangeFilteredThread(state), + includeIdleSamples: getIncludeIdleSamples(state), }), component: StackChartEmptyReasonsImpl, }); diff --git a/src/components/stack-chart/index.tsx b/src/components/stack-chart/index.tsx index ba98f87a9b..ea20211a15 100644 --- a/src/components/stack-chart/index.tsx +++ b/src/components/stack-chart/index.tsx @@ -58,6 +58,7 @@ import type { Page, TimelineUnit, } from 'firefox-profiler/types'; +import type { BitSet } from 'firefox-profiler/utils/bitset'; import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; import type { ConnectedProps } from '../../utils/connect'; @@ -87,6 +88,7 @@ type StateProps = { readonly hasFilteredCtssSamples: boolean; readonly useStackChartSameWidths: boolean; readonly timelineUnit: TimelineUnit; + readonly searchFilteredFuncMatchesBitSet: BitSet | null; }; type DispatchProps = { @@ -244,6 +246,7 @@ class StackChartImpl extends React.PureComponent { hasFilteredCtssSamples, useStackChartSameWidths, timelineUnit, + searchFilteredFuncMatchesBitSet, } = this.props; const maxViewportHeight = combinedTimingRows.length * STACK_FRAME_HEIGHT; @@ -304,6 +307,7 @@ class StackChartImpl extends React.PureComponent { displayStackType: displayStackType, useStackChartSameWidths, timelineUnit, + searchFilteredFuncMatchesBitSet, }} />
    @@ -347,6 +351,8 @@ export const StackChart = explicitConnect<{}, StateProps, DispatchProps>({ selectedThreadSelectors.getHasFilteredCtssSamples(state), useStackChartSameWidths: getStackChartSameWidths(state), timelineUnit: getProfileTimelineUnit(state), + searchFilteredFuncMatchesBitSet: + selectedThreadSelectors.getSearchFilteredFuncMatchesBitSet(state), }; }, mapDispatchToProps: { diff --git a/src/components/timeline/FullTimeline.tsx b/src/components/timeline/FullTimeline.tsx index 97eb3b65d3..cf1729f8e6 100644 --- a/src/components/timeline/FullTimeline.tsx +++ b/src/components/timeline/FullTimeline.tsx @@ -21,7 +21,6 @@ import { getGlobalTrackReferences, getTrackCount, getGlobalTrackOrder, - getPanelLayoutGeneration, } from 'firefox-profiler/selectors'; import { TimelineTrackContextMenu } from './TrackContextMenu'; @@ -57,7 +56,6 @@ type StateProps = { readonly globalTracks: GlobalTrack[]; readonly globalTrackOrder: TrackIndex[]; readonly globalTrackReferences: GlobalTrackReference[]; - readonly panelLayoutGeneration: number; readonly zeroAt: Milliseconds; readonly profileTimelineUnit: TimelineUnit; readonly trackCount: TrackCount; @@ -145,7 +143,6 @@ class FullTimelineImpl extends React.PureComponent { profileTimelineUnit, width, globalTrackReferences, - panelLayoutGeneration, trackCount, changeRightClickedTrack, innerElementRef, @@ -173,7 +170,6 @@ class FullTimelineImpl extends React.PureComponent {

    { ); case 'network': return ; - case 'memory': - return ; - case 'bandwidth': - return ; + case 'counter': + return ; case 'ipc': return ; case 'event-delay': return ; - case 'process-cpu': - return ; - case 'power': - return ; case 'marker': return ( +) { + const { markerSchemaLocation: _unused, ...rest } = props; + return <_SizedTimelineMarkers {...rest} />; +} + +export const TimelineMarkersCounter = explicitConnect< + TimelineMarkersCounterOwnProps, + StateProps, + DispatchProps +>({ + mapStateToProps: (state, props) => { + const { threadsKey, markerSchemaLocation } = props; + const selectors = getThreadSelectorsFromThreadsKey(threadsKey); + const selectedThreads = getSelectedThreadIndexes(state); + + return { + getMarker: selectors.getMarkerGetter(state), + markerIndexes: + selectors.getTimelineMarkerIndexesBySchemaLocation( + markerSchemaLocation + )(state), + isSelected: _getTimelineMarkersIsSelected(selectedThreads, threadsKey), + isModifyingSelection: getPreviewSelectionIsBeingModified(state), + additionalClassName: 'timelineMarkersCounter', + testId: 'TimelineMarkersCounter', + rightClickedMarker: selectors.getRightClickedMarker(state), + }; + }, + mapDispatchToProps: { changeRightClickedMarker }, + component: _TimelineMarkersCounterInner, +}); diff --git a/src/components/timeline/OverflowEdgeIndicator.tsx b/src/components/timeline/OverflowEdgeIndicator.tsx index b9c5bc0072..b73d562f21 100644 --- a/src/components/timeline/OverflowEdgeIndicator.tsx +++ b/src/components/timeline/OverflowEdgeIndicator.tsx @@ -11,7 +11,6 @@ import './OverflowEdgeIndicator.css'; type Props = { className: string; children: React.ReactNode; - panelLayoutGeneration: number; initialSelected: InitialSelectedTrackReference | null; forceLayoutGeneration?: number; }; diff --git a/src/components/timeline/TrackBandwidth.css b/src/components/timeline/TrackBandwidth.css deleted file mode 100644 index 8d2d520faf..0000000000 --- a/src/components/timeline/TrackBandwidth.css +++ /dev/null @@ -1,25 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -.timelineTrackBandwidthGraph { - position: relative; - width: 100%; - height: var(--graph-height); -} - -.timelineTrackBandwidthCanvas { - position: absolute; - width: 100%; - height: 100%; -} - -.timelineTrackBandwidthGraphDot { - position: absolute; - width: 6px; - height: 6px; - border-radius: 3px; - margin-top: -3px; - margin-left: -3px; - pointer-events: none; -} diff --git a/src/components/timeline/TrackBandwidth.tsx b/src/components/timeline/TrackBandwidth.tsx deleted file mode 100644 index 3935008d35..0000000000 --- a/src/components/timeline/TrackBandwidth.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import * as React from 'react'; -import explicitConnect from 'firefox-profiler/utils/connect'; -import { - getCommittedRange, - getCounterSelectors, -} from 'firefox-profiler/selectors/profile'; -import { TrackBandwidthGraph } from './TrackBandwidthGraph'; -import { - TRACK_BANDWIDTH_HEIGHT, - TRACK_BANDWIDTH_LINE_WIDTH, -} from 'firefox-profiler/app-logic/constants'; - -import type { - CounterIndex, - ThreadIndex, - Milliseconds, -} from 'firefox-profiler/types'; - -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - -import './TrackBandwidth.css'; - -type OwnProps = { - readonly counterIndex: CounterIndex; -}; - -type StateProps = { - readonly threadIndex: ThreadIndex; - readonly rangeStart: Milliseconds; - readonly rangeEnd: Milliseconds; -}; - -type DispatchProps = {}; - -type Props = ConnectedProps; - -type State = {}; - -export class TrackBandwidthImpl extends React.PureComponent { - override render() { - const { counterIndex } = this.props; - return ( -
    - -
    - ); - } -} - -export const TrackBandwidth = explicitConnect< - OwnProps, - StateProps, - DispatchProps ->({ - mapStateToProps: (state, ownProps) => { - const { counterIndex } = ownProps; - const counterSelectors = getCounterSelectors(counterIndex); - const counter = counterSelectors.getCounter(state); - const { start, end } = getCommittedRange(state); - return { - threadIndex: counter.mainThreadIndex, - rangeStart: start, - rangeEnd: end, - }; - }, - component: TrackBandwidthImpl, -}); diff --git a/src/components/timeline/TrackBandwidthGraph.tsx b/src/components/timeline/TrackBandwidthGraph.tsx deleted file mode 100644 index 6f05c80bd2..0000000000 --- a/src/components/timeline/TrackBandwidthGraph.tsx +++ /dev/null @@ -1,712 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import * as React from 'react'; -import { InView } from 'react-intersection-observer'; -import { Localized } from '@fluent/react'; -import { withSize } from 'firefox-profiler/components/shared/WithSize'; -import { - getStrokeColor, - getFillColor, - getDotColor, -} from 'firefox-profiler/profile-logic/graph-color'; -import explicitConnect from 'firefox-profiler/utils/connect'; -import { - formatBytes, - formatNumber, -} from 'firefox-profiler/utils/format-numbers'; -import { bisectionRight } from 'firefox-profiler/utils/bisect'; -import { - getCommittedRange, - getCounterSelectors, - getPreviewSelection, - getProfileInterval, -} from 'firefox-profiler/selectors/profile'; -import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; -import { Tooltip } from 'firefox-profiler/components/tooltip/Tooltip'; -import { - TooltipDetails, - TooltipDetail, - TooltipDetailSeparator, -} from 'firefox-profiler/components/tooltip/TooltipDetails'; -import { EmptyThreadIndicator } from './EmptyThreadIndicator'; -import { TRACK_BANDWIDTH_DEFAULT_COLOR } from 'firefox-profiler/app-logic/constants'; -import { getSampleIndexRangeForSelection } from 'firefox-profiler/profile-logic/profile-data'; -import { co2 } from '@tgwf/co2'; - -import type { - CounterIndex, - Counter, - Thread, - ThreadIndex, - AccumulatedCounterSamples, - Milliseconds, - PreviewSelection, - CssPixels, - StartEndRange, - IndexIntoSamplesTable, -} from 'firefox-profiler/types'; - -import type { SizeProps } from 'firefox-profiler/components/shared/WithSize'; -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - -import './TrackBandwidth.css'; - -/** - * When adding properties to these props, please consider the comment above the component. - */ -type CanvasProps = { - readonly rangeStart: Milliseconds; - readonly rangeEnd: Milliseconds; - readonly counter: Counter; - readonly counterSampleRange: [IndexIntoSamplesTable, IndexIntoSamplesTable]; - readonly accumulatedSamples: AccumulatedCounterSamples; - readonly maxCounterSampleCountPerMs: number; - readonly interval: Milliseconds; - readonly width: CssPixels; - readonly height: CssPixels; - readonly lineWidth: CssPixels; -}; - -/** - * This component controls the rendering of the canvas. Every render call through - * React triggers a new canvas render. Because of this, it's important to only pass - * in the props that are needed for the canvas draw call. - */ -class TrackBandwidthCanvas extends React.PureComponent { - _canvas: null | HTMLCanvasElement = null; - _requestedAnimationFrame: boolean = false; - _canvasState: { renderScheduled: boolean; inView: boolean } = { - renderScheduled: false, - inView: false, - }; - - drawCanvas(canvas: HTMLCanvasElement): void { - const { - rangeStart, - rangeEnd, - counter, - height, - width, - lineWidth, - interval, - maxCounterSampleCountPerMs, - counterSampleRange, - } = this.props; - if (width === 0) { - // This is attempting to draw before the canvas was laid out. - return; - } - - const ctx = canvas.getContext('2d')!; - const devicePixelRatio = window.devicePixelRatio; - const deviceWidth = width * devicePixelRatio; - const deviceHeight = height * devicePixelRatio; - const deviceLineWidth = lineWidth * devicePixelRatio; - const deviceLineHalfWidth = deviceLineWidth * 0.5; - const innerDeviceHeight = deviceHeight - deviceLineWidth; - const rangeLength = rangeEnd - rangeStart; - const millisecondWidth = deviceWidth / rangeLength; - const intervalWidth = interval * millisecondWidth; - - // Resize and clear the canvas. - canvas.width = Math.round(deviceWidth); - canvas.height = Math.round(deviceHeight); - ctx.clearRect(0, 0, deviceWidth, deviceHeight); - - const samples = counter.samples; - if (samples.length === 0) { - // There's no reason to draw the samples, there are none. - return; - } - - // Take the sample information, and convert it into chart coordinates. Use a slightly - // smaller space than the deviceHeight, so that the stroke will be fully visible - // both at the top and bottom of the chart. - const [sampleStart, sampleEnd] = counterSampleRange; - const countRangePerMs = maxCounterSampleCountPerMs; - - { - // Draw the chart. - // - // ...--` - // 1 ...---```..-- `--. 2 - // |_____________________| - // 4 3 - // - // Start by drawing from 1 - 2. This will be the top of all the peaks of the - // bandwidth graph. - - ctx.lineWidth = deviceLineWidth; - ctx.lineJoin = 'bevel'; - ctx.strokeStyle = getStrokeColor( - counter.color || TRACK_BANDWIDTH_DEFAULT_COLOR - ); - ctx.fillStyle = getFillColor( - counter.color || TRACK_BANDWIDTH_DEFAULT_COLOR - ); - ctx.beginPath(); - - const getX = (i: number) => - Math.round((samples.time[i] - rangeStart) * millisecondWidth); - const getY = (i: number) => { - const rawY = samples.count[i]; - if (!rawY) { - // Make the 0 values invisible so that 'almost 0' is noticeable. - return deviceHeight + deviceLineHalfWidth; - } - - const sampleTimeDeltaInMs = - i === 0 ? interval : samples.time[i] - samples.time[i - 1]; - const unitGraphCount = rawY / sampleTimeDeltaInMs / countRangePerMs; - return ( - innerDeviceHeight - - innerDeviceHeight * unitGraphCount + - // Add on half the stroke's line width so that it won't be cut off the edge - // of the graph. - deviceLineHalfWidth - ); - }; - - // The x and y are used after the loop. - const firstX = getX(sampleStart); - let x = firstX; - let y = getY(sampleStart); - - // For the first sample, only move the line, do not draw it. Also - // remember this first X, as the bottom of the graph will need to connect - // back up to it. - ctx.moveTo(x, y); - - // Create a path for the top of the chart. This is the line that will have - // a stroke applied to it. - for (let i = sampleStart + 1; i < sampleEnd; i++) { - x = getX(i); - y = getY(i); - ctx.lineTo(x, y); - - // If we have multiple samples to draw on the same horizontal pixel, - // we process all of them together with a max-min decimation algorithm - // to save time: - // - We draw the first and last samples to ensure the display is - // correct if there are sampling gaps. - // - For the values in between, we only draw the min and max values, - // to draw a vertical line covering all the other sample values. - const values = [y]; - while (i + 1 < sampleEnd && getX(i + 1) === x) { - values.push(getY(++i)); - } - - // Looking for the min and max only makes sense if we have more than 2 - // samples to draw. - if (values.length > 2) { - const maxY = Math.max(...values); - if (maxY !== y) { - y = maxY; - ctx.lineTo(x, y); - } - const minY = Math.min(...values); - if (minY !== y) { - y = minY; - ctx.lineTo(x, y); - } - } - - const lastY = values[values.length - 1]; - if (lastY !== y) { - y = lastY; - ctx.lineTo(x, y); - } - } - - // The samples range ends at the time of the last sample, plus the interval. - // Draw this last bit. - ctx.lineTo(x + intervalWidth, y); - - // Don't do the fill yet, just stroke the top line. This will draw a line from - // point 1 to 2 in the diagram above. - ctx.stroke(); - - // After doing the stroke, continue the path to complete the fill to the bottom - // of the canvas. This continues the path to point 3 and then 4. - - // Create a line from 2 to 3. - ctx.lineTo(x + intervalWidth, deviceHeight); - - // Create a line from 3 to 4. - ctx.lineTo(firstX, deviceHeight); - - // The line from 4 to 1 will be implicitly filled in. - ctx.fill(); - } - } - - _scheduleDraw() { - if (!this._canvasState.inView) { - // Canvas is not in the view. Schedule the render for a later intersection - // observer callback. - this._canvasState.renderScheduled = true; - return; - } - - // Canvas is in the view. Render the canvas and reset the schedule state. - this._canvasState.renderScheduled = false; - - if (!this._requestedAnimationFrame) { - this._requestedAnimationFrame = true; - window.requestAnimationFrame(() => { - this._requestedAnimationFrame = false; - const canvas = this._canvas; - if (canvas) { - this.drawCanvas(canvas); - } - }); - } - } - - _takeCanvasRef = (canvas: HTMLCanvasElement | null) => { - this._canvas = canvas; - }; - - _observerCallback = (inView: boolean, _entry: IntersectionObserverEntry) => { - this._canvasState.inView = inView; - if (!this._canvasState.renderScheduled) { - // Skip if render is not scheduled. - return; - } - - this._scheduleDraw(); - }; - - override componentDidMount() { - this._scheduleDraw(); - } - - override componentDidUpdate() { - this._scheduleDraw(); - } - - override render() { - return ( - - - - ); - } -} - -type OwnProps = { - readonly counterIndex: CounterIndex; - readonly lineWidth: CssPixels; - readonly graphHeight: CssPixels; -}; - -type StateProps = { - readonly threadIndex: ThreadIndex; - readonly rangeStart: Milliseconds; - readonly rangeEnd: Milliseconds; - readonly counter: Counter; - readonly counterSampleRange: [IndexIntoSamplesTable, IndexIntoSamplesTable]; - readonly accumulatedSamples: AccumulatedCounterSamples; - readonly maxCounterSampleCountPerMs: number; - readonly interval: Milliseconds; - readonly filteredThread: Thread; - readonly unfilteredSamplesRange: StartEndRange | null; - readonly previewSelection: PreviewSelection | null; -}; - -type DispatchProps = {}; - -type Props = SizeProps & ConnectedProps; - -type State = { - hoveredCounter: null | number; - mouseX: CssPixels; - mouseY: CssPixels; -}; - -/** - * The bandwidth track graph takes bandwidth information from counters, and renders it as a - * graph in the timeline. - */ -class TrackBandwidthGraphImpl extends React.PureComponent { - override state = { - hoveredCounter: null, - mouseX: 0, - mouseY: 0, - }; - - _onMouseLeave = () => { - // This persistTooltips property is part of the web console API. It helps - // in being able to inspect and debug tooltips. - if (window.persistTooltips) { - return; - } - - this.setState({ hoveredCounter: null }); - }; - - _onMouseMove = (event: React.MouseEvent) => { - const { pageX: mouseX, pageY: mouseY } = event; - // Get the offset from here, and apply it to the time lookup. - const { left } = event.currentTarget.getBoundingClientRect(); - const { - width, - rangeStart, - rangeEnd, - counter, - interval, - counterSampleRange, - } = this.props; - const rangeLength = rangeEnd - rangeStart; - const timeAtMouse = rangeStart + ((mouseX - left) / width) * rangeLength; - - if (counter.samples.length === 0) { - // Gecko failed to capture samples for some reason and it shouldn't happen for - // malloc counter. Print an error and bail out early. - throw new Error('No sample group found for bandwidth counter'); - } - const { samples } = counter; - - if ( - timeAtMouse < samples.time[0] || - timeAtMouse > samples.time[samples.length - 1] + interval - ) { - // We are outside the range of the samples, do not display hover information. - this.setState({ hoveredCounter: null }); - } else { - // When the mouse pointer hovers between two points, select the point that's closer. - let hoveredCounter; - const [sampleStart, sampleEnd] = counterSampleRange; - const bisectionCounter = bisectionRight( - samples.time, - timeAtMouse, - sampleStart, - sampleEnd - ); - if (bisectionCounter > 0 && bisectionCounter < samples.time.length) { - const leftDistance = timeAtMouse - samples.time[bisectionCounter - 1]; - const rightDistance = samples.time[bisectionCounter] - timeAtMouse; - if (leftDistance < rightDistance) { - // Left point is closer - hoveredCounter = bisectionCounter - 1; - } else { - // Right point is closer - hoveredCounter = bisectionCounter; - } - - // If there are samples before or after hoveredCounter that fall - // horizontally on the same pixel, move hoveredCounter to the sample - // with the highest power value. - const mouseAtTime = (t: number) => - Math.round(((t - rangeStart) / rangeLength) * width + left); - for ( - let currentIndex = hoveredCounter - 1; - mouseAtTime(samples.time[currentIndex]) === mouseX && - currentIndex > 0; - --currentIndex - ) { - if (samples.count[currentIndex] > samples.count[hoveredCounter]) { - hoveredCounter = currentIndex; - } - } - for ( - let currentIndex = hoveredCounter + 1; - mouseAtTime(samples.time[currentIndex]) === mouseX && - currentIndex < samples.time.length; - ++currentIndex - ) { - if (samples.count[currentIndex] > samples.count[hoveredCounter]) { - hoveredCounter = currentIndex; - } - } - } else { - hoveredCounter = bisectionCounter; - } - - if (hoveredCounter === samples.length) { - // When hovering the last sample, it's possible the mouse is past the time. - // In this case, hover over the last sample. This happens because of the - // ` + interval` line in the `if` condition above. - hoveredCounter = samples.time.length - 1; - } - - this.setState({ - mouseX, - mouseY, - hoveredCounter, - }); - } - }; - - _co2: InstanceType | null = null; - _formatDataTransferValue(bytes: number, l10nId: string) { - if (!this._co2) { - this._co2 = new co2({ model: 'swd' }); - } - // By default when estimating emissions per byte, co2.js takes into account - // emissions for the user device, the data center and the network. - // Because we already have power tracks showing the power use and estimated - // emissions of the device, set the 'device' grid intensity to 0 to avoid - // double counting. - const co2eq = this._co2!.perByteTrace(bytes, false, { - gridIntensity: { device: 0 }, - }); - const carbonValue = formatNumber( - typeof co2eq.co2 === 'number' ? co2eq.co2 : co2eq.co2.total - ); - const value = formatBytes(bytes); - return ( - - {value} - - ); - } - - _renderTooltip(counterIndex: number): React.ReactNode { - const { - accumulatedSamples, - counter, - rangeStart, - rangeEnd, - interval, - previewSelection, - } = this.props; - const { mouseX, mouseY } = this.state; - const { samples } = counter; - if (samples.length === 0) { - // Gecko failed to capture samples for some reason and it shouldn't happen for - // malloc counter. Print an error and bail out early. - throw new Error('No accumulated sample found for bandwidth counter'); - } - - const sampleTime = samples.time[counterIndex]; - if (sampleTime < rangeStart || sampleTime > rangeEnd) { - // Do not draw the tooltip if it will be rendered outside of the timeline. - // This could happen when a sample time is outside of the time range. - // While range filtering the counters, we add the sample before start and - // after end, so charts will not be cut off at the edges. - return null; - } - - const { minCount, countRange, accumulatedCounts } = accumulatedSamples; - const bytes = accumulatedCounts[counterIndex] - minCount; - const operations = - samples.number !== undefined ? samples.number[counterIndex] : null; - - const sampleTimeDeltaInMs = - counterIndex === 0 - ? interval - : samples.time[counterIndex] - samples.time[counterIndex - 1]; - const unitGraphCount = samples.count[counterIndex] / sampleTimeDeltaInMs; - - let rangeTotal = 0; - if (previewSelection) { - const [beginIndex, endIndex] = getSampleIndexRangeForSelection( - samples, - previewSelection.selectionStart, - previewSelection.selectionEnd - ); - - for ( - let counterSampleIndex = beginIndex; - counterSampleIndex < endIndex; - counterSampleIndex++ - ) { - rangeTotal += samples.count[counterSampleIndex]; - } - } - - let ops; - if (operations !== null) { - ops = formatNumber(operations, 2, 0); - } - - return ( - -
    - - {this._formatDataTransferValue( - unitGraphCount * 1000 /* ms -> s */, - 'TrackBandwidthGraph--speed' - )} - {operations !== null ? ( - - {ops} - - ) : null} - - {this._formatDataTransferValue( - bytes, - 'TrackBandwidthGraph--cumulative-bandwidth-at-this-time' - )} - {this._formatDataTransferValue( - countRange, - 'TrackBandwidthGraph--total-bandwidth-in-graph' - )} - {previewSelection - ? this._formatDataTransferValue( - rangeTotal, - 'TrackBandwidthGraph--total-bandwidth-in-range' - ) - : null} - -
    -
    - ); - } - - /** - * Create a div that is a dot on top of the graph representing the current - * height of the graph. - */ - _renderBandwidthDot(counterIndex: number): React.ReactNode { - const { - counter, - rangeStart, - rangeEnd, - graphHeight, - width, - lineWidth, - maxCounterSampleCountPerMs, - interval, - } = this.props; - - const { samples } = counter; - if (samples.length === 0) { - // Gecko failed to capture samples for some reason and it shouldn't happen for - // malloc counter. Print an error and bail out early. - throw new Error('No sample found for bandwidth counter'); - } - const rangeLength = rangeEnd - rangeStart; - const sampleTime = samples.time[counterIndex]; - - if (sampleTime < rangeStart || sampleTime > rangeEnd) { - // Do not draw the dot if it will be rendered outside of the timeline. - // This could happen when a sample time is outside of the time range. - // While range filtering the counters, we add the sample before start and - // after end, so charts will not be cut off at the edges. - return null; - } - - const left = (width * (sampleTime - rangeStart)) / rangeLength; - const countRangePerMs = maxCounterSampleCountPerMs; - const sampleTimeDeltaInMs = - counterIndex === 0 - ? interval - : samples.time[counterIndex] - samples.time[counterIndex - 1]; - const unitSampleCount = - samples.count[counterIndex] / sampleTimeDeltaInMs / countRangePerMs; - const innerTrackHeight = graphHeight - lineWidth / 2; - const top = - innerTrackHeight - unitSampleCount * innerTrackHeight + lineWidth / 2; - - return ( -
    - ); - } - - override render() { - const { hoveredCounter } = this.state; - const { - filteredThread, - interval, - rangeStart, - rangeEnd, - unfilteredSamplesRange, - counter, - counterSampleRange, - graphHeight, - width, - lineWidth, - accumulatedSamples, - maxCounterSampleCountPerMs, - } = this.props; - - return ( -
    - - {hoveredCounter === null ? null : ( - <> - {this._renderBandwidthDot(hoveredCounter)} - {this._renderTooltip(hoveredCounter)} - - )} - -
    - ); - } -} - -export const TrackBandwidthGraph = explicitConnect< - OwnProps, - StateProps, - DispatchProps ->({ - mapStateToProps: (state, ownProps) => { - const { counterIndex } = ownProps; - const counterSelectors = getCounterSelectors(counterIndex); - const counter = counterSelectors.getCounter(state); - const { start, end } = getCommittedRange(state); - const counterSampleRange = - counterSelectors.getCommittedRangeCounterSampleRange(state); - const selectors = getThreadSelectors(counter.mainThreadIndex); - return { - counter, - threadIndex: counter.mainThreadIndex, - maxCounterSampleCountPerMs: - counterSelectors.getMaxRangeCounterSampleCountPerMs(state), - accumulatedSamples: counterSelectors.getAccumulateCounterSamples(state), - rangeStart: start, - rangeEnd: end, - counterSampleRange, - interval: getProfileInterval(state), - filteredThread: selectors.getFilteredThread(state), - unfilteredSamplesRange: selectors.unfilteredSamplesRange(state), - previewSelection: getPreviewSelection(state), - }; - }, - component: withSize(TrackBandwidthGraphImpl), -}); diff --git a/src/components/timeline/TrackContextMenu.tsx b/src/components/timeline/TrackContextMenu.tsx index d4939d3526..eb17df155e 100644 --- a/src/components/timeline/TrackContextMenu.tsx +++ b/src/components/timeline/TrackContextMenu.tsx @@ -936,7 +936,7 @@ class TimelineTrackContextMenuImpl extends PureComponent< const ALLOWED_TYPES = [ 'screenshots', - 'memory', + 'counter', 'network', 'ipc', 'event-delay', diff --git a/src/components/timeline/TrackMemory.css b/src/components/timeline/TrackCounter.css similarity index 74% rename from src/components/timeline/TrackMemory.css rename to src/components/timeline/TrackCounter.css index 404b625b0c..f76deda599 100644 --- a/src/components/timeline/TrackMemory.css +++ b/src/components/timeline/TrackCounter.css @@ -2,19 +2,19 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -.timelineTrackMemoryGraph { +.timelineTrackCounterGraph { position: relative; width: 100%; height: var(--graph-height); } -.timelineTrackMemoryCanvas { +.timelineTrackCounterCanvas { position: absolute; width: 100%; height: 100%; } -.timelineTrackMemoryGraphDot { +.timelineTrackCounterGraphDot { position: absolute; width: 6px; height: 6px; @@ -24,18 +24,19 @@ pointer-events: none; } -.timelineTrackMemoryTooltipLine { +.timelineTrackCounterTooltipLine { white-space: nowrap; } -.timelineTrackMemoryTooltipNumber { +.timelineTrackCounterTooltipNumber { display: inline-block; min-width: 60px; color: var(--tooltip-number-foreground-color); font-weight: bold; } +.timelineMarkersCounter, .timelineMarkersMemory { - height: var(--markers-height, 15px); + height: var(--markers-height); opacity: 1; } diff --git a/src/components/timeline/TrackProcessCPU.tsx b/src/components/timeline/TrackCounter.tsx similarity index 52% rename from src/components/timeline/TrackProcessCPU.tsx rename to src/components/timeline/TrackCounter.tsx index 44b6eb15b6..effc64db1d 100644 --- a/src/components/timeline/TrackProcessCPU.tsx +++ b/src/components/timeline/TrackCounter.tsx @@ -8,22 +8,25 @@ import { getCommittedRange, getCounterSelectors, } from 'firefox-profiler/selectors/profile'; +import { TimelineMarkersCounter } from './Markers'; import { updatePreviewSelection } from 'firefox-profiler/actions/profile-view'; -import { TrackProcessCPUGraph } from './TrackProcessCPUGraph'; +import { TrackCounterGraph } from './TrackCounterGraph'; import { - TRACK_PROCESS_CPU_HEIGHT, - TRACK_PROCESS_CPU_LINE_WIDTH, + TRACK_COUNTER_GRAPH_HEIGHT, + TRACK_COUNTER_MARKERS_HEIGHT, + TRACK_COUNTER_LINE_WIDTH, } from 'firefox-profiler/app-logic/constants'; import type { CounterIndex, ThreadIndex, Milliseconds, + MarkerDisplayLocation, } from 'firefox-profiler/types'; import type { ConnectedProps } from 'firefox-profiler/utils/connect'; -import './TrackProcessCPU.css'; +import './TrackCounter.css'; type OwnProps = { readonly counterIndex: CounterIndex; @@ -33,6 +36,7 @@ type StateProps = { readonly threadIndex: ThreadIndex; readonly rangeStart: Milliseconds; readonly rangeEnd: Milliseconds; + readonly markerSchemaLocation: MarkerDisplayLocation | null; }; type DispatchProps = { @@ -41,32 +45,55 @@ type DispatchProps = { type Props = ConnectedProps; -type State = {}; +export class TrackCounterImpl extends React.PureComponent { + _onMarkerSelect = (start: Milliseconds, end: Milliseconds) => { + const { rangeStart, rangeEnd, updatePreviewSelection } = this.props; + updatePreviewSelection({ + isModifying: false, + selectionStart: Math.max(rangeStart, start), + selectionEnd: Math.min(rangeEnd, end), + }); + }; -export class TrackProcessCPUImpl extends React.PureComponent { override render() { - const { counterIndex } = this.props; + const { + counterIndex, + rangeStart, + rangeEnd, + threadIndex, + markerSchemaLocation, + } = this.props; + return (
    - + ) : null} +
    ); } } -export const TrackProcessCPU = explicitConnect< +export const TrackCounter = explicitConnect< OwnProps, StateProps, DispatchProps @@ -80,8 +107,9 @@ export const TrackProcessCPU = explicitConnect< threadIndex: counter.mainThreadIndex, rangeStart: start, rangeEnd: end, + markerSchemaLocation: counter.display.markerSchemaLocation, }; }, mapDispatchToProps: { updatePreviewSelection }, - component: TrackProcessCPUImpl, + component: TrackCounterImpl, }); diff --git a/src/components/timeline/TrackCounterGraph.tsx b/src/components/timeline/TrackCounterGraph.tsx new file mode 100644 index 0000000000..a90c36eb39 --- /dev/null +++ b/src/components/timeline/TrackCounterGraph.tsx @@ -0,0 +1,901 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; +import { InView } from 'react-intersection-observer'; +import { Localized } from '@fluent/react'; +import { withSize } from 'firefox-profiler/components/shared/WithSize'; +import { + getStrokeColor, + getFillColor, + getDotColor, +} from 'firefox-profiler/profile-logic/graph-color'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { + formatBytes, + formatNumber, + formatPercent, +} from 'firefox-profiler/utils/format-numbers'; +import { bisectionRight } from 'firefox-profiler/utils/bisect'; +import { + getCommittedRange, + getCounterSelectors, + getPreviewSelection, + getProfileInterval, +} from 'firefox-profiler/selectors/profile'; +import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { Tooltip } from 'firefox-profiler/components/tooltip/Tooltip'; +import { TooltipTrackPower } from 'firefox-profiler/components/tooltip/TrackPower'; +import { + TooltipDetails, + TooltipDetail, + TooltipDetailSeparator, +} from 'firefox-profiler/components/tooltip/TooltipDetails'; +import { EmptyThreadIndicator } from './EmptyThreadIndicator'; +import { getSampleIndexRangeForSelection } from 'firefox-profiler/profile-logic/profile-data'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import { co2 } from '@tgwf/co2'; + +import type { + CounterIndex, + Counter, + Thread, + ThreadIndex, + AccumulatedCounterSamples, + Milliseconds, + PreviewSelection, + CssPixels, + StartEndRange, + IndexIntoSamplesTable, +} from 'firefox-profiler/types'; + +import type { SizeProps } from 'firefox-profiler/components/shared/WithSize'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './TrackCounter.css'; + +/** + * When adding properties to these props, please consider the comment above `TrackCounterCanvas`. + */ +type CanvasProps = { + readonly rangeStart: Milliseconds; + readonly rangeEnd: Milliseconds; + readonly counter: Counter; + readonly counterSampleRange: [IndexIntoSamplesTable, IndexIntoSamplesTable]; + readonly accumulatedSamples: AccumulatedCounterSamples; + readonly maxCounterSampleCountPerMs: number; + readonly interval: Milliseconds; + readonly width: CssPixels; + readonly height: CssPixels; + readonly lineWidth: CssPixels; +}; + +/** + * This component controls the rendering of the canvas. Every render call through + * React triggers a new canvas render. Because of this, it's important to only pass + * in the props that are needed for the canvas draw call. + */ +class TrackCounterCanvas extends React.PureComponent { + _canvas: null | HTMLCanvasElement = null; + _requestedAnimationFrame: boolean = false; + _canvasState: { renderScheduled: boolean; inView: boolean } = { + renderScheduled: false, + inView: false, + }; + + drawCanvas(canvas: HTMLCanvasElement): void { + const { + rangeStart, + rangeEnd, + counter, + height, + width, + lineWidth, + interval, + accumulatedSamples, + maxCounterSampleCountPerMs, + counterSampleRange, + } = this.props; + const { display } = counter; + if (width === 0) { + // Attempt to draw before the canvas was laid out. + return; + } + + const ctx = canvas.getContext('2d')!; + const devicePixelRatio = window.devicePixelRatio; + const deviceWidth = width * devicePixelRatio; + const deviceHeight = height * devicePixelRatio; + const deviceLineWidth = lineWidth * devicePixelRatio; + const deviceLineHalfWidth = deviceLineWidth * 0.5; + const innerDeviceHeight = deviceHeight - deviceLineWidth; + const rangeLength = rangeEnd - rangeStart; + const millisecondWidth = deviceWidth / rangeLength; + const intervalWidth = interval * millisecondWidth; + + // Resize and clear the canvas. + canvas.width = Math.round(deviceWidth); + canvas.height = Math.round(deviceHeight); + ctx.clearRect(0, 0, deviceWidth, deviceHeight); + + const samples = counter.samples; + if (samples.length === 0) { + // There's no reason to draw the samples, there are none. + return; + } + + // Take the sample information, and convert it into chart coordinates. Use a slightly + // smaller space than the deviceHeight, so that the stroke will be fully visible + // both at the top and bottom of the chart. + const [sampleStart, sampleEnd] = counterSampleRange; + + { + // Draw the chart. + // + // ...--` + // 1 ...---```..-- `--. 2 + // |_____________________| + // 4 3 + // + // Start by drawing from 1 to 2. This will be the top of all the peaks of the + // counter graph. + + ctx.lineWidth = deviceLineWidth; + ctx.lineJoin = 'bevel'; + ctx.strokeStyle = getStrokeColor(display.color); + ctx.fillStyle = getFillColor(display.color); + ctx.beginPath(); + + switch (display.graphType) { + case 'line-accumulated': { + // Accumulated graph: plot the running total. + const { minCount, countRange, accumulatedCounts } = + accumulatedSamples; + + // The x and y are used after the loop. + let x = 0; + let y = 0; + let firstX = 0; + for (let i = sampleStart; i < sampleEnd; i++) { + // Create a path for the top of the chart. This is the line that will have + // a stroke applied to it. + x = (samples.time[i] - rangeStart) * millisecondWidth; + // Add on half the stroke's line width so that it won't be cut off the edge + // of the graph. + const unitGraphCount = + (accumulatedCounts[i] - minCount) / countRange; + y = + innerDeviceHeight - + innerDeviceHeight * unitGraphCount + + deviceLineHalfWidth; + if (i === sampleStart) { + // This is the first iteration, only move the line, do not draw it. Also + // remember this first X, as the bottom of the graph will need to connect + // back up to it. + firstX = x; + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + // The samples range ends at the time of the last sample, plus the interval. + // Draw this last bit. + ctx.lineTo(x + intervalWidth, y); + + // Don't do the fill yet, just stroke the top line. This will draw a line from + // point 1 to 2 in the diagram above. + ctx.stroke(); + + // After doing the stroke, continue the path to complete the fill to the bottom + // of the canvas. This continues the path to point 3 and then 4. + + // Create a line from 2 to 3. + ctx.lineTo(x + intervalWidth, deviceHeight); + + // Create a line from 3 to 4. + ctx.lineTo(firstX, deviceHeight); + + // The line from 4 to 1 will be implicitly filled in. + ctx.fill(); + break; + } + case 'line-rate': { + // Rate graph: plot count / timeDelta with min-max decimation. + const countRangePerMs = maxCounterSampleCountPerMs; + + const getX = (i: number) => + Math.round((samples.time[i] - rangeStart) * millisecondWidth); + const getY = (rawY: number) => { + if (!rawY) { + // Make the 0 values invisible so that 'almost 0' is noticeable. + return deviceHeight + deviceLineHalfWidth; + } + const unitGraphCount = rawY / countRangePerMs; + return ( + innerDeviceHeight - + innerDeviceHeight * unitGraphCount + + // Add on half the stroke's line width so that it won't be cut off the edge + // of the graph. + deviceLineHalfWidth + ); + }; + + const getRate = (i: number) => { + const sampleTimeDeltaInMs = + i === 0 ? interval : samples.time[i] - samples.time[i - 1]; + return samples.count[i] / sampleTimeDeltaInMs; + }; + + // The x and y are used after the loop. + const firstX = getX(sampleStart); + let x = firstX; + let y = getY(getRate(sampleStart)); + + // For the first sample, only move the line, do not draw it. Also + // remember this first X, as the bottom of the graph will need to connect + // back up to it. + ctx.moveTo(x, y); + + // Create a path for the top of the chart. This is the line that will have + // a stroke applied to it. + for (let i = sampleStart + 1; i < sampleEnd; i++) { + const rateValues = [getRate(i)]; + x = getX(i); + y = getY(rateValues[0]); + ctx.lineTo(x, y); + + // If we have multiple samples to draw on the same horizontal pixel, + // we process all of them together with a max-min decimation algorithm + // to save time: + // - We draw the first and last samples to ensure the display is + // correct if there are sampling gaps. + // - For the values in between, we only draw the min and max values, + // to draw a vertical line covering all the other sample values. + while (i + 1 < sampleEnd && getX(i + 1) === x) { + rateValues.push(getRate(++i)); + } + + // Looking for the min and max only makes sense if we have more than 2 + // samples to draw. + if (rateValues.length > 2) { + const minY = getY(Math.min(...rateValues)); + if (minY !== y) { + y = minY; + ctx.lineTo(x, y); + } + const maxY = getY(Math.max(...rateValues)); + if (maxY !== y) { + y = maxY; + ctx.lineTo(x, y); + } + } + + const lastY = getY(rateValues[rateValues.length - 1]); + if (lastY !== y) { + y = lastY; + ctx.lineTo(x, y); + } + } + + // The samples range ends at the time of the last sample, plus the interval. + // Draw this last bit. + ctx.lineTo(x + intervalWidth, y); + + // Don't do the fill yet, just stroke the top line. This will draw a line from + // point 1 to 2 in the diagram above. + ctx.stroke(); + + // After doing the stroke, continue the path to complete the fill to the bottom + // of the canvas. This continues the path to point 3 and then 4. + + // Create a line from 2 to 3. + ctx.lineTo(x + intervalWidth, deviceHeight); + + // Create a line from 3 to 4. + ctx.lineTo(firstX, deviceHeight); + + // The line from 4 to 1 will be implicitly filled in. + ctx.fill(); + break; + } + default: + throw assertExhaustiveCheck(display.graphType); + } + } + } + + _scheduleDraw() { + if (!this._canvasState.inView) { + // Canvas is not in the view. Schedule the render for a later intersection + // observer callback. + this._canvasState.renderScheduled = true; + return; + } + + // Canvas is in the view. Render the canvas and reset the schedule state. + this._canvasState.renderScheduled = false; + + if (!this._requestedAnimationFrame) { + this._requestedAnimationFrame = true; + window.requestAnimationFrame(() => { + this._requestedAnimationFrame = false; + const canvas = this._canvas; + if (canvas) { + this.drawCanvas(canvas); + } + }); + } + } + + _takeCanvasRef = (canvas: HTMLCanvasElement | null) => { + this._canvas = canvas; + }; + + _observerCallback = (inView: boolean, _entry: IntersectionObserverEntry) => { + this._canvasState.inView = inView; + if (!this._canvasState.renderScheduled) { + // Skip if render is not scheduled. + return; + } + + this._scheduleDraw(); + }; + + override componentDidMount() { + this._scheduleDraw(); + } + + override componentDidUpdate() { + this._scheduleDraw(); + } + + override render() { + return ( + + + + ); + } +} + +type OwnProps = { + readonly counterIndex: CounterIndex; + readonly lineWidth: CssPixels; + readonly graphHeight: CssPixels; +}; + +type StateProps = { + readonly threadIndex: ThreadIndex; + readonly rangeStart: Milliseconds; + readonly rangeEnd: Milliseconds; + readonly counter: Counter; + readonly counterSampleRange: [IndexIntoSamplesTable, IndexIntoSamplesTable]; + readonly accumulatedSamples: AccumulatedCounterSamples; + readonly maxCounterSampleCountPerMs: number; + readonly interval: Milliseconds; + readonly filteredThread: Thread; + readonly unfilteredSamplesRange: StartEndRange | null; + readonly previewSelection: PreviewSelection | null; +}; + +type DispatchProps = {}; + +type Props = SizeProps & ConnectedProps; + +type State = { + hoveredCounter: null | number; + mouseX: CssPixels; + mouseY: CssPixels; +}; + +/** + * The generic counter track graph component. It renders information from any counters + * (eg, Memory, Power, etc.) as a graph in the timeline. It branches on + * `display.graphType` for drawing, and on `counter.category`/`counter.name` + * for tooltip rendering of known counter types. + */ +class TrackCounterGraphImpl extends React.PureComponent { + override state = { + hoveredCounter: null, + mouseX: 0, + mouseY: 0, + }; + + _co2: InstanceType | null = null; + + _onMouseLeave = () => { + // This persistTooltips property is part of the web console API. It helps + // in being able to inspect and debug tooltips. + if (window.persistTooltips) { + return; + } + + this.setState({ hoveredCounter: null }); + }; + + _onMouseMove = (event: React.MouseEvent) => { + const { pageX: mouseX, pageY: mouseY } = event; + // Get the offset from here, and apply it to the time lookup. + const { left } = event.currentTarget.getBoundingClientRect(); + const { + width, + rangeStart, + rangeEnd, + counter, + interval, + counterSampleRange, + } = this.props; + const rangeLength = rangeEnd - rangeStart; + const timeAtMouse = rangeStart + ((mouseX - left) / width) * rangeLength; + + if (counter.samples.length === 0) { + throw new Error('No sample group found for counter'); + } + const { samples } = counter; + + if ( + timeAtMouse < samples.time[0] || + timeAtMouse > samples.time[samples.length - 1] + interval + ) { + // We are outside the range of the samples, do not display hover information. + this.setState({ hoveredCounter: null }); + } else { + // When the mouse pointer hovers between two points, select the point that's closer. + let hoveredCounter; + const [sampleStart, sampleEnd] = counterSampleRange; + const bisectionCounter = bisectionRight( + samples.time, + timeAtMouse, + sampleStart, + sampleEnd + ); + if (bisectionCounter > 0 && bisectionCounter < samples.time.length) { + const leftDistance = timeAtMouse - samples.time[bisectionCounter - 1]; + const rightDistance = samples.time[bisectionCounter] - timeAtMouse; + if (leftDistance < rightDistance) { + // Left point is closer + hoveredCounter = bisectionCounter - 1; + } else { + // Right point is closer + hoveredCounter = bisectionCounter; + } + + // For rate-based graphs with decimation, find the sample with the + // highest value at the same pixel position. + if (this.props.counter.display.graphType === 'line-rate') { + const mouseAtTime = (t: number) => + Math.round(((t - rangeStart) / rangeLength) * width + left); + for ( + let currentIndex = hoveredCounter - 1; + mouseAtTime(samples.time[currentIndex]) === mouseX && + currentIndex > 0; + --currentIndex + ) { + if (samples.count[currentIndex] > samples.count[hoveredCounter]) { + hoveredCounter = currentIndex; + } + } + for ( + let currentIndex = hoveredCounter + 1; + mouseAtTime(samples.time[currentIndex]) === mouseX && + currentIndex < samples.time.length; + ++currentIndex + ) { + if (samples.count[currentIndex] > samples.count[hoveredCounter]) { + hoveredCounter = currentIndex; + } + } + } + } else { + hoveredCounter = bisectionCounter; + } + + if (hoveredCounter === samples.length) { + // When hovering the last sample, it's possible the mouse is past the time. + // In this case, hover over the last sample. This happens because of the + // ` + interval` line in the `if` condition above. + hoveredCounter = samples.time.length - 1; + } + + this.setState({ + mouseX, + mouseY, + hoveredCounter, + }); + } + }; + + _formatDataTransferValue(bytes: number, l10nId: string) { + if (!this._co2) { + this._co2 = new co2({ model: 'swd' }); + } + // By default, when estimating emissions per byte, co2.js takes into account + // emissions for the user device, the data center and the network. + // Because we already have power tracks showing the power use and estimated + // emissions of the device, set the 'device' grid intensity to 0 to avoid + // double counting. + const co2eq = this._co2.perByteTrace(bytes, false, { + gridIntensity: { device: 0 }, + }); + const carbonValue = formatNumber( + typeof co2eq.co2 === 'number' ? co2eq.co2 : co2eq.co2.total + ); + const value = formatBytes(bytes); + return ( + + {value} + + ); + } + + _renderTooltip(counterIndex: number): React.ReactNode { + const { + accumulatedSamples, + counter, + rangeStart, + rangeEnd, + interval, + maxCounterSampleCountPerMs, + previewSelection, + } = this.props; + const { display } = counter; + const { mouseX, mouseY } = this.state; + const { samples } = counter; + + if (samples.length === 0) { + throw new Error('No sample found for counter'); + } + + const sampleTime = samples.time[counterIndex]; + if (sampleTime < rangeStart || sampleTime > rangeEnd) { + // Do not draw the tooltip if it will be rendered outside the timeline. + // This could happen when a sample time is outside the time range. + // While range filtering the counters, we add the sample before start and + // after end, so charts will not be cut off at the edges. + return null; + } + + const { category, name } = counter; + + // Power tooltip — delegate to the dedicated component. + if (category === 'power') { + return ( + + + + ); + } + + // Process CPU tooltip. + if (category === 'CPU' && name === 'processCPU') { + const cpuUsage = samples.count[counterIndex]; + const sampleTimeDeltaInMs = + counterIndex === 0 + ? interval + : samples.time[counterIndex] - samples.time[counterIndex - 1]; + const cpuRatio = + cpuUsage / sampleTimeDeltaInMs / maxCounterSampleCountPerMs; + return ( + +
    +
    + CPU:{' '} + + {formatPercent(cpuRatio)} + +
    +
    +
    + ); + } + + // Bandwidth tooltip — bytes with rate, CO2, and accumulated total. + if (category === 'Bandwidth') { + const { minCount, countRange, accumulatedCounts } = accumulatedSamples; + const bytes = accumulatedCounts[counterIndex] - minCount; + const operations = + samples.number !== undefined ? samples.number[counterIndex] : null; + + const sampleTimeDeltaInMs = + counterIndex === 0 + ? interval + : samples.time[counterIndex] - samples.time[counterIndex - 1]; + const unitGraphCount = samples.count[counterIndex] / sampleTimeDeltaInMs; + + let rangeTotal = 0; + if (previewSelection) { + const [beginIndex, endIndex] = getSampleIndexRangeForSelection( + samples, + previewSelection.selectionStart, + previewSelection.selectionEnd + ); + + for ( + let counterSampleIndex = beginIndex; + counterSampleIndex < endIndex; + counterSampleIndex++ + ) { + rangeTotal += samples.count[counterSampleIndex]; + } + } + + let ops; + if (operations !== null) { + ops = formatNumber(operations, 2, 0); + } + + return ( + +
    + + {this._formatDataTransferValue( + unitGraphCount * 1000 /* ms -> s */, + 'TrackBandwidthGraph--speed' + )} + {operations !== null ? ( + + {ops} + + ) : null} + + {this._formatDataTransferValue( + bytes, + 'TrackBandwidthGraph--cumulative-bandwidth-at-this-time' + )} + {this._formatDataTransferValue( + countRange, + 'TrackBandwidthGraph--total-bandwidth-in-graph' + )} + {previewSelection + ? this._formatDataTransferValue( + rangeTotal, + 'TrackBandwidthGraph--total-bandwidth-in-range' + ) + : null} + +
    +
    + ); + } + + // Memory tooltip — accumulated bytes with operations count. + if (category === 'Memory') { + const { minCount, countRange, accumulatedCounts } = accumulatedSamples; + const bytes = accumulatedCounts[counterIndex] - minCount; + const operations = + samples.number !== undefined ? samples.number[counterIndex] : null; + return ( + +
    +
    + + {formatBytes(bytes)} + + + relative memory at this time + +
    + +
    + + {formatBytes(countRange)} + + + memory range in graph + +
    + {operations !== null ? ( +
    + + {formatNumber(operations, 2, 0)} + + + allocations and deallocations since the previous sample + +
    + ) : null} +
    +
    + ); + } + + // Generic tooltip for unknown counter types - format the value based on + // the counter's unit. + const value = samples.count[counterIndex]; + let formattedValue; + if (display.unit === 'bytes') { + formattedValue = formatBytes(value); + } else if (display.unit === 'percent') { + formattedValue = formatPercent(value); + } else if (display.unit) { + // Bypasses i18n but this is hit only for unknown counters. + formattedValue = `${formatNumber(value)} ${display.unit}`; + } else { + formattedValue = formatNumber(value); + } + return ( + +
    +
    + + {formattedValue} + + {display.label || name} +
    +
    +
    + ); + } + + /** + * Create a div that is a dot on top of the graph representing the current + * height of the graph. + */ + _renderDot(counterIndex: number): React.ReactNode { + const { + counter, + rangeStart, + rangeEnd, + graphHeight, + width, + lineWidth, + accumulatedSamples, + maxCounterSampleCountPerMs, + interval, + } = this.props; + + const { samples, display } = counter; + if (samples.length === 0) { + throw new Error('No sample found for counter'); + } + const rangeLength = rangeEnd - rangeStart; + const sampleTime = samples.time[counterIndex]; + + if (sampleTime < rangeStart || sampleTime > rangeEnd) { + // Do not draw the dot if it will be rendered outside the timeline. + // This could happen when a sample time is outside the time range. + // While range filtering the counters, we add the sample before start and + // after end, so charts will not be cut off at the edges. + return null; + } + + const left = (width * (sampleTime - rangeStart)) / rangeLength; + const innerTrackHeight = graphHeight - lineWidth / 2; + let top; + + switch (display.graphType) { + case 'line-accumulated': { + const { minCount, countRange, accumulatedCounts } = accumulatedSamples; + const unitSampleCount = + (accumulatedCounts[counterIndex] - minCount) / countRange; + top = + innerTrackHeight - unitSampleCount * innerTrackHeight + lineWidth / 2; + break; + } + case 'line-rate': { + const sampleTimeDeltaInMs = + counterIndex === 0 + ? interval + : samples.time[counterIndex] - samples.time[counterIndex - 1]; + const unitSampleCount = + samples.count[counterIndex] / + sampleTimeDeltaInMs / + maxCounterSampleCountPerMs; + top = + innerTrackHeight - unitSampleCount * innerTrackHeight + lineWidth / 2; + break; + } + default: + throw assertExhaustiveCheck(display.graphType); + } + + return ( +
    + ); + } + + override render() { + const { hoveredCounter } = this.state; + const { + filteredThread, + interval, + rangeStart, + rangeEnd, + unfilteredSamplesRange, + counter, + counterSampleRange, + graphHeight, + width, + lineWidth, + accumulatedSamples, + maxCounterSampleCountPerMs, + } = this.props; + + return ( +
    + + {hoveredCounter === null ? null : ( + <> + {this._renderDot(hoveredCounter)} + {this._renderTooltip(hoveredCounter)} + + )} + +
    + ); + } +} + +export const TrackCounterGraph = explicitConnect< + OwnProps, + StateProps, + DispatchProps +>({ + mapStateToProps: (state, ownProps) => { + const { counterIndex } = ownProps; + const counterSelectors = getCounterSelectors(counterIndex); + const counter = counterSelectors.getCounter(state); + const { start, end } = getCommittedRange(state); + const counterSampleRange = + counterSelectors.getCommittedRangeCounterSampleRange(state); + const selectors = getThreadSelectors(counter.mainThreadIndex); + return { + counter, + threadIndex: counter.mainThreadIndex, + accumulatedSamples: counterSelectors.getAccumulateCounterSamples(state), + maxCounterSampleCountPerMs: + counterSelectors.getMaxRangeCounterSampleCountPerMs(state), + rangeStart: start, + rangeEnd: end, + counterSampleRange, + interval: getProfileInterval(state), + filteredThread: selectors.getFilteredThread(state), + unfilteredSamplesRange: selectors.unfilteredSamplesRange(state), + previewSelection: getPreviewSelection(state), + }; + }, + component: withSize(TrackCounterGraphImpl), +}); diff --git a/src/components/timeline/TrackMemory.tsx b/src/components/timeline/TrackMemory.tsx deleted file mode 100644 index 7c0dc3cf00..0000000000 --- a/src/components/timeline/TrackMemory.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import * as React from 'react'; -import explicitConnect from 'firefox-profiler/utils/connect'; -import { - getCommittedRange, - getCounterSelectors, -} from 'firefox-profiler/selectors/profile'; -import { TimelineMarkersMemory } from './Markers'; -import { updatePreviewSelection } from 'firefox-profiler/actions/profile-view'; -import { TrackMemoryGraph } from './TrackMemoryGraph'; -import { - TRACK_MEMORY_GRAPH_HEIGHT, - TRACK_MEMORY_MARKERS_HEIGHT, - TRACK_MEMORY_LINE_WIDTH, -} from 'firefox-profiler/app-logic/constants'; - -import type { - CounterIndex, - ThreadIndex, - Milliseconds, -} from 'firefox-profiler/types'; - -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - -import './TrackMemory.css'; - -type OwnProps = { - readonly counterIndex: CounterIndex; -}; - -type StateProps = { - readonly threadIndex: ThreadIndex; - readonly rangeStart: Milliseconds; - readonly rangeEnd: Milliseconds; -}; - -type DispatchProps = { - updatePreviewSelection: typeof updatePreviewSelection; -}; - -type Props = ConnectedProps; - -type State = {}; - -export class TrackMemoryImpl extends React.PureComponent { - _onMarkerSelect = (start: Milliseconds, end: Milliseconds) => { - const { rangeStart, rangeEnd, updatePreviewSelection } = this.props; - updatePreviewSelection({ - isModifying: false, - selectionStart: Math.max(rangeStart, start), - selectionEnd: Math.min(rangeEnd, end), - }); - }; - - override render() { - const { counterIndex, rangeStart, rangeEnd, threadIndex } = this.props; - return ( -
    - - -
    - ); - } -} - -export const TrackMemory = explicitConnect( - { - mapStateToProps: (state, ownProps) => { - const { counterIndex } = ownProps; - const counterSelectors = getCounterSelectors(counterIndex); - const counter = counterSelectors.getCounter(state); - const { start, end } = getCommittedRange(state); - return { - threadIndex: counter.mainThreadIndex, - rangeStart: start, - rangeEnd: end, - }; - }, - mapDispatchToProps: { updatePreviewSelection }, - component: TrackMemoryImpl, - } -); diff --git a/src/components/timeline/TrackMemoryGraph.tsx b/src/components/timeline/TrackMemoryGraph.tsx deleted file mode 100644 index 8e1a0f74a6..0000000000 --- a/src/components/timeline/TrackMemoryGraph.tsx +++ /dev/null @@ -1,549 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import * as React from 'react'; -import { InView } from 'react-intersection-observer'; -import { Localized } from '@fluent/react'; -import { withSize } from 'firefox-profiler/components/shared/WithSize'; -import { - getStrokeColor, - getFillColor, - getDotColor, -} from 'firefox-profiler/profile-logic/graph-color'; -import explicitConnect from 'firefox-profiler/utils/connect'; -import { - formatBytes, - formatNumber, -} from 'firefox-profiler/utils/format-numbers'; -import { bisectionRight } from 'firefox-profiler/utils/bisect'; -import { - getCommittedRange, - getCounterSelectors, - getProfileInterval, -} from 'firefox-profiler/selectors/profile'; -import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; -import { Tooltip } from 'firefox-profiler/components/tooltip/Tooltip'; -import { EmptyThreadIndicator } from './EmptyThreadIndicator'; -import { TRACK_MEMORY_DEFAULT_COLOR } from 'firefox-profiler/app-logic/constants'; - -import type { - CounterIndex, - Counter, - Thread, - ThreadIndex, - AccumulatedCounterSamples, - Milliseconds, - CssPixels, - StartEndRange, - IndexIntoSamplesTable, -} from 'firefox-profiler/types'; - -import type { SizeProps } from 'firefox-profiler/components/shared/WithSize'; -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - -import './TrackMemory.css'; - -/** - * When adding properties to these props, please consider the comment above the component. - */ -type CanvasProps = { - readonly rangeStart: Milliseconds; - readonly rangeEnd: Milliseconds; - readonly counter: Counter; - readonly counterSampleRange: [IndexIntoSamplesTable, IndexIntoSamplesTable]; - readonly accumulatedSamples: AccumulatedCounterSamples; - readonly interval: Milliseconds; - readonly width: CssPixels; - readonly height: CssPixels; - readonly lineWidth: CssPixels; -}; - -/** - * This component controls the rendering of the canvas. Every render call through - * React triggers a new canvas render. Because of this, it's important to only pass - * in the props that are needed for the canvas draw call. - */ -class TrackMemoryCanvas extends React.PureComponent { - _canvas: null | HTMLCanvasElement = null; - _requestedAnimationFrame: boolean = false; - _canvasState: { renderScheduled: boolean; inView: boolean } = { - renderScheduled: false, - inView: false, - }; - - drawCanvas(canvas: HTMLCanvasElement): void { - const { - rangeStart, - rangeEnd, - counter, - height, - width, - lineWidth, - interval, - accumulatedSamples, - counterSampleRange, - } = this.props; - if (width === 0) { - // This is attempting to draw before the canvas was laid out. - return; - } - - const ctx = canvas.getContext('2d')!; - const devicePixelRatio = window.devicePixelRatio; - const deviceWidth = width * devicePixelRatio; - const deviceHeight = height * devicePixelRatio; - const deviceLineWidth = lineWidth * devicePixelRatio; - const deviceLineHalfWidth = deviceLineWidth * 0.5; - const innerDeviceHeight = deviceHeight - deviceLineWidth; - const rangeLength = rangeEnd - rangeStart; - const millisecondWidth = deviceWidth / rangeLength; - const intervalWidth = interval * millisecondWidth; - - // Resize and clear the canvas. - canvas.width = Math.round(deviceWidth); - canvas.height = Math.round(deviceHeight); - ctx.clearRect(0, 0, deviceWidth, deviceHeight); - - const samples = counter.samples; - if (samples.length === 0) { - // There's no reason to draw the samples, there are none. - return; - } - - // Take the sample information, and convert it into chart coordinates. Use a slightly - // smaller space than the deviceHeight, so that the stroke will be fully visible - // both at the top and bottom of the chart. - const { minCount, countRange, accumulatedCounts } = accumulatedSamples; - const [sampleStart, sampleEnd] = counterSampleRange; - - { - // Draw the chart. - // - // ...--` - // 1 ...---```..-- `--. 2 - // |_____________________| - // 4 3 - // - // Start by drawing from 1 - 2. This will be the top of all the peaks of the - // memory graph. - - ctx.lineWidth = deviceLineWidth; - ctx.lineJoin = 'bevel'; - ctx.strokeStyle = getStrokeColor( - counter.color || TRACK_MEMORY_DEFAULT_COLOR - ); - ctx.fillStyle = getFillColor(counter.color || TRACK_MEMORY_DEFAULT_COLOR); - ctx.beginPath(); - - // The x and y are used after the loop. - let x = 0; - let y = 0; - let firstX = 0; - for (let i = sampleStart; i < sampleEnd; i++) { - // Create a path for the top of the chart. This is the line that will have - // a stroke applied to it. - x = (samples.time[i] - rangeStart) * millisecondWidth; - // Add on half the stroke's line width so that it won't be cut off the edge - // of the graph. - const unitGraphCount = (accumulatedCounts[i] - minCount) / countRange; - y = - innerDeviceHeight - - innerDeviceHeight * unitGraphCount + - deviceLineHalfWidth; - if (i === 0) { - // This is the first iteration, only move the line, do not draw it. Also - // remember this first X, as the bottom of the graph will need to connect - // back up to it. - firstX = x; - ctx.moveTo(x, y); - } else { - ctx.lineTo(x, y); - } - } - // The samples range ends at the time of the last sample, plus the interval. - // Draw this last bit. - ctx.lineTo(x + intervalWidth, y); - - // Don't do the fill yet, just stroke the top line. This will draw a line from - // point 1 to 2 in the diagram above. - ctx.stroke(); - - // After doing the stroke, continue the path to complete the fill to the bottom - // of the canvas. This continues the path to point 3 and then 4. - - // Create a line from 2 to 3. - ctx.lineTo(x + intervalWidth, deviceHeight); - - // Create a line from 3 to 4. - ctx.lineTo(firstX, deviceHeight); - - // The line from 4 to 1 will be implicitly filled in. - ctx.fill(); - } - } - - _scheduleDraw() { - if (!this._canvasState.inView) { - // Canvas is not in the view. Schedule the render for a later intersection - // observer callback. - this._canvasState.renderScheduled = true; - return; - } - - // Canvas is in the view. Render the canvas and reset the schedule state. - this._canvasState.renderScheduled = false; - - if (!this._requestedAnimationFrame) { - this._requestedAnimationFrame = true; - window.requestAnimationFrame(() => { - this._requestedAnimationFrame = false; - const canvas = this._canvas; - if (canvas) { - this.drawCanvas(canvas); - } - }); - } - } - - _takeCanvasRef = (canvas: HTMLCanvasElement | null) => { - this._canvas = canvas; - }; - - _observerCallback = (inView: boolean, _entry: IntersectionObserverEntry) => { - this._canvasState.inView = inView; - if (!this._canvasState.renderScheduled) { - // Skip if render is not scheduled. - return; - } - - this._scheduleDraw(); - }; - - override componentDidMount() { - this._scheduleDraw(); - } - - override componentDidUpdate() { - this._scheduleDraw(); - } - - override render() { - return ( - - - - ); - } -} - -type OwnProps = { - readonly counterIndex: CounterIndex; - readonly lineWidth: CssPixels; - readonly graphHeight: CssPixels; -}; - -type StateProps = { - readonly threadIndex: ThreadIndex; - readonly rangeStart: Milliseconds; - readonly rangeEnd: Milliseconds; - readonly counter: Counter; - readonly counterSampleRange: [IndexIntoSamplesTable, IndexIntoSamplesTable]; - readonly accumulatedSamples: AccumulatedCounterSamples; - readonly interval: Milliseconds; - readonly filteredThread: Thread; - readonly unfilteredSamplesRange: StartEndRange | null; -}; - -type DispatchProps = {}; - -type Props = SizeProps & ConnectedProps; - -type State = { - hoveredCounter: null | number; - mouseX: CssPixels; - mouseY: CssPixels; -}; - -/** - * The memory track graph takes memory information from counters, and renders it as a - * graph in the timeline. - */ -class TrackMemoryGraphImpl extends React.PureComponent { - override state = { - hoveredCounter: null, - mouseX: 0, - mouseY: 0, - }; - - _onMouseLeave = () => { - // This persistTooltips property is part of the web console API. It helps - // in being able to inspect and debug tooltips. - if (window.persistTooltips) { - return; - } - - this.setState({ hoveredCounter: null }); - }; - - _onMouseMove = (event: React.MouseEvent) => { - const { pageX: mouseX, pageY: mouseY } = event; - // Get the offset from here, and apply it to the time lookup. - const { left } = event.currentTarget.getBoundingClientRect(); - const { - width, - rangeStart, - rangeEnd, - counter, - interval, - counterSampleRange, - } = this.props; - const rangeLength = rangeEnd - rangeStart; - const timeAtMouse = rangeStart + ((mouseX - left) / width) * rangeLength; - - if (counter.samples.length === 0) { - // Gecko failed to capture samples for some reason and it shouldn't happen for - // malloc counter. Print an error and bail out early. - throw new Error('No sample group found for memory counter'); - } - const { samples } = counter; - - if ( - timeAtMouse < samples.time[0] || - timeAtMouse > samples.time[samples.length - 1] + interval - ) { - // We are outside the range of the samples, do not display hover information. - this.setState({ hoveredCounter: null }); - } else { - // When the mouse pointer hovers between two points, select the point that's closer. - let hoveredCounter; - const [sampleStart, sampleEnd] = counterSampleRange; - const bisectionCounter = bisectionRight( - samples.time, - timeAtMouse, - sampleStart, - sampleEnd - ); - if (bisectionCounter > 0 && bisectionCounter < samples.time.length) { - const leftDistance = timeAtMouse - samples.time[bisectionCounter - 1]; - const rightDistance = samples.time[bisectionCounter] - timeAtMouse; - if (leftDistance < rightDistance) { - // Left point is closer - hoveredCounter = bisectionCounter - 1; - } else { - // Right point is closer - hoveredCounter = bisectionCounter; - } - } else { - hoveredCounter = bisectionCounter; - } - - if (hoveredCounter === samples.length) { - // When hovering the last sample, it's possible the mouse is past the time. - // In this case, hover over the last sample. This happens because of the - // ` + interval` line in the `if` condition above. - hoveredCounter = samples.time.length - 1; - } - - this.setState({ - mouseX, - mouseY, - hoveredCounter, - }); - } - }; - - _renderTooltip(counterIndex: number): React.ReactNode { - const { accumulatedSamples, counter, rangeStart, rangeEnd } = this.props; - const { mouseX, mouseY } = this.state; - const { samples } = counter; - if (samples.length === 0) { - // Gecko failed to capture samples for some reason and it shouldn't happen for - // malloc counter. Print an error and bail out early. - throw new Error('No accumulated sample found for memory counter'); - } - - const sampleTime = samples.time[counterIndex]; - if (sampleTime < rangeStart || sampleTime > rangeEnd) { - // Do not draw the tooltip if it will be rendered outside of the timeline. - // This could happen when a sample time is outside of the time range. - // While range filtering the counters, we add the sample before start and - // after end, so charts will not be cut off at the edges. - return null; - } - - const { minCount, countRange, accumulatedCounts } = accumulatedSamples; - const bytes = accumulatedCounts[counterIndex] - minCount; - const operations = - samples.number !== undefined ? samples.number[counterIndex] : null; - return ( - -
    -
    - - {formatBytes(bytes)} - - - relative memory at this time - -
    - -
    - - {formatBytes(countRange)} - - - memory range in graph - -
    - {operations !== null ? ( -
    - - {formatNumber(operations, 2, 0)} - - - allocations and deallocations since the previous sample - -
    - ) : null} -
    -
    - ); - } - - /** - * Create a div that is a dot on top of the graph representing the current - * height of the graph. - */ - _renderMemoryDot(counterIndex: number): React.ReactNode { - const { - counter, - rangeStart, - rangeEnd, - graphHeight, - width, - lineWidth, - accumulatedSamples, - } = this.props; - - const { samples } = counter; - if (samples.length === 0) { - // Gecko failed to capture samples for some reason and it shouldn't happen for - // malloc counter. Print an error and bail out early. - throw new Error('No sample found for memory counter'); - } - const rangeLength = rangeEnd - rangeStart; - const sampleTime = samples.time[counterIndex]; - - if (sampleTime < rangeStart || sampleTime > rangeEnd) { - // Do not draw the dot if it will be rendered outside of the timeline. - // This could happen when a sample time is outside of the time range. - // While range filtering the counters, we add the sample before start and - // after end, so charts will not be cut off at the edges. - return null; - } - - const left = (width * (sampleTime - rangeStart)) / rangeLength; - - const { minCount, countRange, accumulatedCounts } = accumulatedSamples; - const unitSampleCount = - (accumulatedCounts[counterIndex] - minCount) / countRange; - const innerTrackHeight = graphHeight - lineWidth / 2; - const top = - innerTrackHeight - unitSampleCount * innerTrackHeight + lineWidth / 2; - - return ( -
    - ); - } - - override render() { - const { hoveredCounter } = this.state; - const { - filteredThread, - interval, - rangeStart, - rangeEnd, - unfilteredSamplesRange, - counter, - counterSampleRange, - graphHeight, - width, - lineWidth, - accumulatedSamples, - } = this.props; - - return ( -
    - - {hoveredCounter === null ? null : ( - <> - {this._renderMemoryDot(hoveredCounter)} - {this._renderTooltip(hoveredCounter)} - - )} - -
    - ); - } -} - -export const TrackMemoryGraph = explicitConnect< - OwnProps, - StateProps, - DispatchProps ->({ - mapStateToProps: (state, ownProps) => { - const { counterIndex } = ownProps; - const counterSelectors = getCounterSelectors(counterIndex); - const counter = counterSelectors.getCounter(state); - const { start, end } = getCommittedRange(state); - const counterSampleRange = - counterSelectors.getCommittedRangeCounterSampleRange(state); - const selectors = getThreadSelectors(counter.mainThreadIndex); - return { - counter, - threadIndex: counter.mainThreadIndex, - accumulatedSamples: counterSelectors.getAccumulateCounterSamples(state), - rangeStart: start, - rangeEnd: end, - counterSampleRange, - interval: getProfileInterval(state), - filteredThread: selectors.getFilteredThread(state), - unfilteredSamplesRange: selectors.unfilteredSamplesRange(state), - }; - }, - component: withSize(TrackMemoryGraphImpl), -}); diff --git a/src/components/timeline/TrackPower.css b/src/components/timeline/TrackPower.css deleted file mode 100644 index 09f616a1c2..0000000000 --- a/src/components/timeline/TrackPower.css +++ /dev/null @@ -1,25 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -.timelineTrackPowerGraph { - position: relative; - width: 100%; - height: var(--graph-height); -} - -.timelineTrackPowerCanvas { - position: absolute; - width: 100%; - height: 100%; -} - -.timelineTrackPowerGraphDot { - position: absolute; - width: 6px; - height: 6px; - border-radius: 3px; - margin-top: -3px; - margin-left: -3px; - pointer-events: none; -} diff --git a/src/components/timeline/TrackPower.tsx b/src/components/timeline/TrackPower.tsx deleted file mode 100644 index 3272f09c19..0000000000 --- a/src/components/timeline/TrackPower.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import * as React from 'react'; -import explicitConnect from 'firefox-profiler/utils/connect'; -import { - getCommittedRange, - getCounterSelectors, -} from 'firefox-profiler/selectors/profile'; -import { updatePreviewSelection } from 'firefox-profiler/actions/profile-view'; -import { TrackPowerGraph } from './TrackPowerGraph'; -import { - TRACK_POWER_HEIGHT, - TRACK_POWER_LINE_WIDTH, -} from 'firefox-profiler/app-logic/constants'; - -import type { - CounterIndex, - ThreadIndex, - Milliseconds, -} from 'firefox-profiler/types'; - -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - -import './TrackPower.css'; - -type OwnProps = { - readonly counterIndex: CounterIndex; -}; - -type StateProps = { - readonly threadIndex: ThreadIndex; - readonly rangeStart: Milliseconds; - readonly rangeEnd: Milliseconds; -}; - -type DispatchProps = { - updatePreviewSelection: typeof updatePreviewSelection; -}; - -type Props = ConnectedProps; - -type State = {}; - -export class TrackPowerImpl extends React.PureComponent { - override render() { - const { counterIndex } = this.props; - return ( -
    - -
    - ); - } -} - -export const TrackPower = explicitConnect({ - mapStateToProps: (state, ownProps) => { - const { counterIndex } = ownProps; - const counterSelectors = getCounterSelectors(counterIndex); - const counter = counterSelectors.getCounter(state); - const { start, end } = getCommittedRange(state); - return { - threadIndex: counter.mainThreadIndex, - rangeStart: start, - rangeEnd: end, - }; - }, - mapDispatchToProps: { updatePreviewSelection }, - component: TrackPowerImpl, -}); diff --git a/src/components/timeline/TrackPowerGraph.tsx b/src/components/timeline/TrackPowerGraph.tsx deleted file mode 100644 index c8613636f5..0000000000 --- a/src/components/timeline/TrackPowerGraph.tsx +++ /dev/null @@ -1,574 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import * as React from 'react'; -import { InView } from 'react-intersection-observer'; -import { withSize } from 'firefox-profiler/components/shared/WithSize'; -import { - getStrokeColor, - getFillColor, - getDotColor, -} from 'firefox-profiler/profile-logic/graph-color'; -import explicitConnect from 'firefox-profiler/utils/connect'; -import { bisectionRight } from 'firefox-profiler/utils/bisect'; -import { - getCommittedRange, - getCounterSelectors, - getProfileInterval, -} from 'firefox-profiler/selectors/profile'; -import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; -import { Tooltip } from 'firefox-profiler/components/tooltip/Tooltip'; -import { TooltipTrackPower } from 'firefox-profiler/components/tooltip/TrackPower'; -import { EmptyThreadIndicator } from './EmptyThreadIndicator'; -import { TRACK_POWER_DEFAULT_COLOR } from 'firefox-profiler/app-logic/constants'; - -import type { - CounterIndex, - Counter, - Thread, - ThreadIndex, - Milliseconds, - CssPixels, - StartEndRange, - IndexIntoSamplesTable, -} from 'firefox-profiler/types'; - -import type { SizeProps } from 'firefox-profiler/components/shared/WithSize'; -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; -import { timeCode } from 'firefox-profiler/utils/time-code'; - -import './TrackPower.css'; - -/** - * When adding properties to these props, please consider the comment above the component. - */ -type CanvasProps = { - readonly rangeStart: Milliseconds; - readonly rangeEnd: Milliseconds; - readonly counter: Counter; - readonly counterSampleRange: [IndexIntoSamplesTable, IndexIntoSamplesTable]; - readonly maxCounterSampleCountPerMs: number; - readonly interval: Milliseconds; - readonly width: CssPixels; - readonly height: CssPixels; - readonly lineWidth: CssPixels; -}; - -/** - * This component controls the rendering of the canvas. Every render call through - * React triggers a new canvas render. Because of this, it's important to only pass - * in the props that are needed for the canvas draw call. - */ -class TrackPowerCanvas extends React.PureComponent { - _canvas: null | HTMLCanvasElement = null; - _canvasState: { renderScheduled: boolean; inView: boolean } = { - renderScheduled: false, - inView: false, - }; - - drawCanvas(canvas: HTMLCanvasElement): void { - const { - rangeStart, - rangeEnd, - counter, - height, - width, - lineWidth, - interval, - maxCounterSampleCountPerMs, - counterSampleRange, - } = this.props; - if (width === 0) { - // This is attempting to draw before the canvas was laid out. - return; - } - - const ctx = canvas.getContext('2d')!; - const devicePixelRatio = window.devicePixelRatio; - const deviceWidth = width * devicePixelRatio; - const deviceHeight = height * devicePixelRatio; - const deviceLineWidth = lineWidth * devicePixelRatio; - const deviceLineHalfWidth = deviceLineWidth * 0.5; - const innerDeviceHeight = deviceHeight - deviceLineWidth; - const rangeLength = rangeEnd - rangeStart; - const millisecondWidth = deviceWidth / rangeLength; - const intervalWidth = interval * millisecondWidth; - - // Resize and clear the canvas. - canvas.width = Math.round(deviceWidth); - canvas.height = Math.round(deviceHeight); - ctx.clearRect(0, 0, deviceWidth, deviceHeight); - - const samples = counter.samples; - if (samples.length === 0) { - // There's no reason to draw the samples, there are none. - return; - } - - const [sampleStart, sampleEnd] = counterSampleRange; - const countRangePerMs = maxCounterSampleCountPerMs; - - { - // Draw the chart. - // - // ...--` - // 1 ...---```..-- `--. 2 - // |_____________________| - // 4 3 - // - // Start by drawing from 1 - 2. This will be the top of all the peaks of the - // power graph. - - ctx.lineWidth = deviceLineWidth; - ctx.lineJoin = 'bevel'; - ctx.strokeStyle = getStrokeColor( - counter.color || TRACK_POWER_DEFAULT_COLOR - ); - ctx.fillStyle = getFillColor(counter.color || TRACK_POWER_DEFAULT_COLOR); - ctx.beginPath(); - - const getX = (i: number) => - Math.round((samples.time[i] - rangeStart) * millisecondWidth); - const getPower = (i: number) => { - const sampleTimeDeltaInMs = - i === 0 ? interval : samples.time[i] - samples.time[i - 1]; - return samples.count[i] / sampleTimeDeltaInMs; - }; - const getY = (rawY: number) => { - if (!rawY) { - // Make the 0 values invisible so that 'almost 0' is noticeable. - return deviceHeight + deviceLineHalfWidth; - } - - const unitGraphCount = rawY / countRangePerMs; - // Add on half the stroke's line width so that it won't be cut off the edge - // of the graph. - return Math.round( - innerDeviceHeight - - innerDeviceHeight * unitGraphCount + - deviceLineHalfWidth - ); - }; - - // The x and y are used after the loop. - const firstX = getX(sampleStart); - let x = firstX; - let y = getY(getPower(sampleStart)); - - // For the first sample, only move the line, do not draw it. Also - // remember this first X, as the bottom of the graph will need to connect - // back up to it. - ctx.moveTo(x, y); - - // Create a path for the top of the chart. This is the line that will have - // a stroke applied to it. - for (let i = sampleStart + 1; i < sampleEnd; i++) { - const powerValues = [getPower(i)]; - x = getX(i); - y = getY(powerValues[0]); - ctx.lineTo(x, y); - - // If we have multiple samples to draw on the same horizontal pixel, - // we process all of them together with a max-min decimation algorithm - // to save time: - // - We draw the first and last samples to ensure the display is - // correct if there are sampling gaps. - // - For the values in between, we only draw the min and max values, - // to draw a vertical line covering all the other sample values. - while (i + 1 < sampleEnd && getX(i + 1) === x) { - powerValues.push(getPower(++i)); - } - - // Looking for the min and max only makes sense if we have more than 2 - // samples to draw. - if (powerValues.length > 2) { - const minY = getY(Math.min(...powerValues)); - if (minY !== y) { - y = minY; - ctx.lineTo(x, y); - } - const maxY = getY(Math.max(...powerValues)); - if (maxY !== y) { - y = maxY; - ctx.lineTo(x, y); - } - } - - const lastY = getY(powerValues[powerValues.length - 1]); - if (lastY !== y) { - y = lastY; - ctx.lineTo(x, y); - } - } - - // The samples range ends at the time of the last sample, plus the interval. - // Draw this last bit. - ctx.lineTo(x + intervalWidth, y); - - // Don't do the fill yet, just stroke the top line. This will draw a line from - // point 1 to 2 in the diagram above. - ctx.stroke(); - - // After doing the stroke, continue the path to complete the fill to the bottom - // of the canvas. This continues the path to point 3 and then 4. - - // Create a line from 2 to 3. - ctx.lineTo(x + intervalWidth, deviceHeight); - - // Create a line from 3 to 4. - ctx.lineTo(firstX, deviceHeight); - - // The line from 4 to 1 will be implicitly filled in. - ctx.fill(); - } - } - - _renderCanvas() { - if (!this._canvasState.inView) { - // Canvas is not in the view. Schedule the render for a later intersection - // observer callback. - this._canvasState.renderScheduled = true; - return; - } - - // Canvas is in the view. Render the canvas and reset the schedule state. - this._canvasState.renderScheduled = false; - - const canvas = this._canvas; - if (canvas) { - timeCode('TrackPowerCanvas render', () => { - this.drawCanvas(canvas); - }); - } - } - - _takeCanvasRef = (canvas: HTMLCanvasElement | null) => { - this._canvas = canvas; - }; - - _observerCallback = (inView: boolean, _entry: IntersectionObserverEntry) => { - this._canvasState.inView = inView; - if (!this._canvasState.renderScheduled) { - // Skip if render is not scheduled. - return; - } - - this._renderCanvas(); - }; - - override render() { - this._renderCanvas(); - - return ( - - - - ); - } -} - -type OwnProps = { - readonly counterIndex: CounterIndex; - readonly lineWidth: CssPixels; - readonly graphHeight: CssPixels; -}; - -type StateProps = { - readonly threadIndex: ThreadIndex; - readonly rangeStart: Milliseconds; - readonly rangeEnd: Milliseconds; - readonly counter: Counter; - readonly counterSampleRange: [IndexIntoSamplesTable, IndexIntoSamplesTable]; - readonly maxCounterSampleCountPerMs: number; - readonly interval: Milliseconds; - readonly filteredThread: Thread; - readonly unfilteredSamplesRange: StartEndRange | null; -}; - -type DispatchProps = {}; - -type Props = SizeProps & ConnectedProps; - -type State = { - hoveredCounter: null | number; - mouseX: CssPixels; - mouseY: CssPixels; -}; - -/** - * The power track graph takes power use information from counters, and renders it as a - * graph in the timeline. - */ -class TrackPowerGraphImpl extends React.PureComponent { - override state = { - hoveredCounter: null, - mouseX: 0, - mouseY: 0, - }; - - _onMouseLeave = () => { - this.setState({ hoveredCounter: null }); - }; - - _onMouseMove = (event: React.MouseEvent) => { - const { pageX: mouseX, pageY: mouseY } = event; - // Get the offset from here, and apply it to the time lookup. - const { left } = event.currentTarget.getBoundingClientRect(); - const { - width, - rangeStart, - rangeEnd, - counter, - interval, - counterSampleRange, - } = this.props; - const rangeLength = rangeEnd - rangeStart; - const timeAtMouse = rangeStart + ((mouseX - left) / width) * rangeLength; - - const { samples } = counter; - - if ( - timeAtMouse < samples.time[0] || - timeAtMouse > samples.time[samples.length - 1] + interval - ) { - // We are outside the range of the samples, do not display hover information. - this.setState({ hoveredCounter: null }); - } else { - // When the mouse pointer hovers between two points, select the point that's closer. - let hoveredCounter; - const [sampleStart, sampleEnd] = counterSampleRange; - const bisectionCounter = bisectionRight( - samples.time, - timeAtMouse, - sampleStart, - sampleEnd - ); - if (bisectionCounter > 0 && bisectionCounter < samples.time.length) { - const leftDistance = timeAtMouse - samples.time[bisectionCounter - 1]; - const rightDistance = samples.time[bisectionCounter] - timeAtMouse; - if (leftDistance < rightDistance) { - // Left point is closer - hoveredCounter = bisectionCounter - 1; - } else { - // Right point is closer - hoveredCounter = bisectionCounter; - } - - // If there are samples before or after hoveredCounter that fall - // horizontally on the same pixel, move hoveredCounter to the sample - // with the highest power value. - const mouseAtTime = (t: number) => - Math.round(((t - rangeStart) / rangeLength) * width + left); - for ( - let currentIndex = hoveredCounter - 1; - mouseAtTime(samples.time[currentIndex]) === mouseX && - currentIndex > 0; - --currentIndex - ) { - if (samples.count[currentIndex] > samples.count[hoveredCounter]) { - hoveredCounter = currentIndex; - } - } - for ( - let currentIndex = hoveredCounter + 1; - mouseAtTime(samples.time[currentIndex]) === mouseX && - currentIndex < samples.time.length; - ++currentIndex - ) { - if (samples.count[currentIndex] > samples.count[hoveredCounter]) { - hoveredCounter = currentIndex; - } - } - } else { - hoveredCounter = bisectionCounter; - } - - if (hoveredCounter === samples.length) { - // When hovering the last sample, it's possible the mouse is past the time. - // In this case, hover over the last sample. This happens because of the - // ` + interval` line in the `if` condition above. - hoveredCounter = samples.time.length - 1; - } - - this.setState({ - mouseX, - mouseY, - hoveredCounter, - }); - } - }; - - _renderTooltip(counterSampleIndex: number): React.ReactNode { - const { counter, rangeStart, rangeEnd } = this.props; - const { mouseX, mouseY } = this.state; - - const { samples } = counter; - if (samples.length === 0) { - // Gecko failed to capture samples for some reason and it shouldn't happen for - // malloc counter. Print an error and bail out early. - throw new Error('No sample found for power counter'); - } - - const sampleTime = samples.time[counterSampleIndex]; - if (sampleTime < rangeStart || sampleTime > rangeEnd) { - // Do not draw the tooltip if it will be rendered outside of the timeline. - // This could happen when a sample time is outside of the time range. - // While range filtering the counters, we add the sample before start and - // after end, so charts will not be cut off at the edges. - return null; - } - - return ( - - - - ); - } - - /** - * Create a div that is a dot on top of the graph representing the current - * height of the graph. - */ - _renderDot(counterIndex: number): React.ReactNode { - const { - counter, - rangeStart, - rangeEnd, - graphHeight, - width, - lineWidth, - maxCounterSampleCountPerMs, - interval, - } = this.props; - - const { samples } = counter; - const rangeLength = rangeEnd - rangeStart; - const sampleTime = samples.time[counterIndex]; - - if (sampleTime < rangeStart || sampleTime > rangeEnd) { - // Do not draw the dot if it will be rendered outside of the timeline. - // This could happen when a sample time is outside of the time range. - // While range filtering the counters, we add the sample before start and - // after end, so charts will not be cut off at the edges. - return null; - } - - const left = (width * (sampleTime - rangeStart)) / rangeLength; - - if (samples.length === 0) { - // Gecko failed to capture samples for some reason and it shouldn't happen for - // power counter. Print an error and bail out early. - throw new Error('No sample found for power counter'); - } - const countRangePerMs = maxCounterSampleCountPerMs; - const sampleTimeDeltaInMs = - counterIndex === 0 - ? interval - : samples.time[counterIndex] - samples.time[counterIndex - 1]; - const unitSampleCount = - samples.count[counterIndex] / sampleTimeDeltaInMs / countRangePerMs; - const innerTrackHeight = graphHeight - lineWidth / 2; - const top = - innerTrackHeight - unitSampleCount * innerTrackHeight + lineWidth / 2; - - return ( -
    - ); - } - - override render() { - const { hoveredCounter } = this.state; - const { - filteredThread, - interval, - rangeStart, - rangeEnd, - unfilteredSamplesRange, - counter, - counterSampleRange, - graphHeight, - width, - lineWidth, - maxCounterSampleCountPerMs, - } = this.props; - - return ( -
    - - {hoveredCounter === null ? null : ( - <> - {this._renderDot(hoveredCounter)} - {this._renderTooltip(hoveredCounter)} - - )} - -
    - ); - } -} - -export const TrackPowerGraph = explicitConnect< - OwnProps, - StateProps, - DispatchProps ->({ - mapStateToProps: (state, ownProps) => { - const { counterIndex } = ownProps; - const counterSelectors = getCounterSelectors(counterIndex); - const counter = counterSelectors.getCounter(state); - const { start, end } = getCommittedRange(state); - const counterSampleRange = - counterSelectors.getCommittedRangeCounterSampleRange(state); - - const selectors = getThreadSelectors(counter.mainThreadIndex); - return { - counter, - threadIndex: counter.mainThreadIndex, - maxCounterSampleCountPerMs: - counterSelectors.getMaxRangeCounterSampleCountPerMs(state), - rangeStart: start, - rangeEnd: end, - counterSampleRange, - interval: getProfileInterval(state), - filteredThread: selectors.getFilteredThread(state), - unfilteredSamplesRange: selectors.unfilteredSamplesRange(state), - }; - }, - component: withSize(TrackPowerGraphImpl), -}); diff --git a/src/components/timeline/TrackProcessCPU.css b/src/components/timeline/TrackProcessCPU.css deleted file mode 100644 index 76f8c45d52..0000000000 --- a/src/components/timeline/TrackProcessCPU.css +++ /dev/null @@ -1,39 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -.timelineTrackProcessCPUGraph { - position: relative; - width: 100%; - height: var(--graph-height); -} - -.timelineTrackProcessCPUCanvas { - position: absolute; - width: 100%; - height: 100%; -} - -.timelineTrackProcessCPUGraphDot { - --internal-background-color: var(--grey-50); - - position: absolute; - width: 6px; - height: 6px; - border-radius: 3px; - margin-top: -3px; - margin-left: -3px; - background-color: var(--internal-background-color); - pointer-events: none; -} - -.timelineTrackProcessCPUTooltipLine { - white-space: nowrap; -} - -.timelineTrackProcessCPUTooltipNumber { - display: inline-block; - min-width: 60px; - color: var(--tooltip-number-foreground-color); - font-weight: bold; -} diff --git a/src/components/timeline/TrackProcessCPUGraph.tsx b/src/components/timeline/TrackProcessCPUGraph.tsx deleted file mode 100644 index 0f6c7d7fe3..0000000000 --- a/src/components/timeline/TrackProcessCPUGraph.tsx +++ /dev/null @@ -1,477 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -import * as React from 'react'; -import { withSize } from 'firefox-profiler/components/shared/WithSize'; -import explicitConnect from 'firefox-profiler/utils/connect'; -import { formatPercent } from 'firefox-profiler/utils/format-numbers'; -import { bisectionRight } from 'firefox-profiler/utils/bisect'; -import { - getCommittedRange, - getCounterSelectors, - getProfileInterval, -} from 'firefox-profiler/selectors/profile'; -import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; -import { GREY_50 } from 'photon-colors'; -import { Tooltip } from 'firefox-profiler/components/tooltip/Tooltip'; -import { EmptyThreadIndicator } from './EmptyThreadIndicator'; - -import type { - CounterIndex, - Counter, - Thread, - ThreadIndex, - Milliseconds, - CssPixels, - StartEndRange, - IndexIntoSamplesTable, -} from 'firefox-profiler/types'; - -import type { SizeProps } from 'firefox-profiler/components/shared/WithSize'; -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - -import './TrackProcessCPU.css'; - -/** - * When adding properties to these props, please consider the comment above the component. - */ -type CanvasProps = { - readonly rangeStart: Milliseconds; - readonly rangeEnd: Milliseconds; - readonly counter: Counter; - readonly counterSampleRange: [IndexIntoSamplesTable, IndexIntoSamplesTable]; - readonly maxCounterSampleCountPerMs: number; - readonly interval: Milliseconds; - readonly width: CssPixels; - readonly height: CssPixels; - readonly lineWidth: CssPixels; -}; - -/** - * This component controls the rendering of the canvas. Every render call through - * React triggers a new canvas render. Because of this, it's important to only pass - * in the props that are needed for the canvas draw call. - */ -class TrackProcessCPUCanvas extends React.PureComponent { - _canvas: null | HTMLCanvasElement = null; - _requestedAnimationFrame: boolean = false; - - drawCanvas(canvas: HTMLCanvasElement): void { - const { - rangeStart, - rangeEnd, - counter, - height, - width, - lineWidth, - interval, - maxCounterSampleCountPerMs, - counterSampleRange, - } = this.props; - if (width === 0) { - // This is attempting to draw before the canvas was laid out. - return; - } - - const ctx = canvas.getContext('2d')!; - const devicePixelRatio = window.devicePixelRatio; - const deviceWidth = width * devicePixelRatio; - const deviceHeight = height * devicePixelRatio; - const deviceLineWidth = lineWidth * devicePixelRatio; - const deviceLineHalfWidth = deviceLineWidth * 0.5; - const innerDeviceHeight = deviceHeight - deviceLineWidth; - const rangeLength = rangeEnd - rangeStart; - const millisecondWidth = deviceWidth / rangeLength; - const intervalWidth = interval * millisecondWidth; - - // Resize and clear the canvas. - canvas.width = Math.round(deviceWidth); - canvas.height = Math.round(deviceHeight); - ctx.clearRect(0, 0, deviceWidth, deviceHeight); - - const samples = counter.samples; - if (samples.length === 0) { - // There's no reason to draw the samples, there are none. - return; - } - - const [sampleStart, sampleEnd] = counterSampleRange; - const countRangePerMs = maxCounterSampleCountPerMs; - - { - // Draw the chart. - // - // ...--` - // 1 ...---```..-- `--. 2 - // |_____________________| - // 4 3 - // - // Start by drawing from 1 - 2. This will be the top of all the peaks of the - // process CPU graph. - - ctx.lineWidth = deviceLineWidth; - ctx.lineJoin = 'bevel'; - ctx.strokeStyle = GREY_50; - ctx.fillStyle = '#73737388'; // Grey 50 with transparency. - ctx.beginPath(); - - // The x and y are used after the loop. - let x = 0; - let y = 0; - let firstX = 0; - for (let i = sampleStart; i < sampleEnd; i++) { - // Create a path for the top of the chart. This is the line that will have - // a stroke applied to it. - x = (samples.time[i] - rangeStart) * millisecondWidth; - const sampleTimeDeltaInMs = - i === 0 ? interval : samples.time[i] - samples.time[i - 1]; - const unitGraphCount = - samples.count[i] / sampleTimeDeltaInMs / countRangePerMs; - // Add on half the stroke's line width so that it won't be cut off the edge - // of the graph. - y = - innerDeviceHeight - - innerDeviceHeight * unitGraphCount + - deviceLineHalfWidth; - if (i === 0) { - // This is the first iteration, only move the line, do not draw it. Also - // remember this first X, as the bottom of the graph will need to connect - // back up to it. - firstX = x; - ctx.moveTo(x, y); - } else { - ctx.lineTo(x, y); - } - } - // The samples range ends at the time of the last sample, plus the interval. - // Draw this last bit. - ctx.lineTo(x + intervalWidth, y); - - // Don't do the fill yet, just stroke the top line. This will draw a line from - // point 1 to 2 in the diagram above. - ctx.stroke(); - - // After doing the stroke, continue the path to complete the fill to the bottom - // of the canvas. This continues the path to point 3 and then 4. - - // Create a line from 2 to 3. - ctx.lineTo(x + intervalWidth, deviceHeight); - - // Create a line from 3 to 4. - ctx.lineTo(firstX, deviceHeight); - - // The line from 4 to 1 will be implicitly filled in. - ctx.fill(); - } - } - - _scheduleDraw() { - if (!this._requestedAnimationFrame) { - this._requestedAnimationFrame = true; - window.requestAnimationFrame(() => { - this._requestedAnimationFrame = false; - const canvas = this._canvas; - if (canvas) { - this.drawCanvas(canvas); - } - }); - } - } - - _takeCanvasRef = (canvas: HTMLCanvasElement | null) => { - this._canvas = canvas; - }; - - override render() { - this._scheduleDraw(); - - return ( - - ); - } -} - -type OwnProps = { - readonly counterIndex: CounterIndex; - readonly lineWidth: CssPixels; - readonly graphHeight: CssPixels; -}; - -type StateProps = { - readonly threadIndex: ThreadIndex; - readonly rangeStart: Milliseconds; - readonly rangeEnd: Milliseconds; - readonly counter: Counter; - readonly counterSampleRange: [IndexIntoSamplesTable, IndexIntoSamplesTable]; - readonly maxCounterSampleCountPerMs: number; - readonly interval: Milliseconds; - readonly filteredThread: Thread; - readonly unfilteredSamplesRange: StartEndRange | null; -}; - -type DispatchProps = {}; - -type Props = SizeProps & ConnectedProps; - -type State = { - hoveredCounter: null | number; - mouseX: CssPixels; - mouseY: CssPixels; -}; - -/** - * The process CPU track graph takes CPU information from counters, and renders it as a - * graph in the timeline. - */ -class TrackProcessCPUGraphImpl extends React.PureComponent { - override state = { - hoveredCounter: null, - mouseX: 0, - mouseY: 0, - }; - - _onMouseLeave = () => { - this.setState({ hoveredCounter: null }); - }; - - _onMouseMove = (event: React.MouseEvent) => { - const { pageX: mouseX, pageY: mouseY } = event; - // Get the offset from here, and apply it to the time lookup. - const { left } = event.currentTarget.getBoundingClientRect(); - const { - width, - rangeStart, - rangeEnd, - counter, - interval, - counterSampleRange, - } = this.props; - const rangeLength = rangeEnd - rangeStart; - const timeAtMouse = rangeStart + ((mouseX - left) / width) * rangeLength; - - const { samples } = counter; - - if ( - timeAtMouse < samples.time[0] || - timeAtMouse > samples.time[samples.length - 1] + interval - ) { - // We are outside the range of the samples, do not display hover information. - this.setState({ hoveredCounter: null }); - } else { - // When the mouse pointer hovers between two points, select the point that's closer. - let hoveredCounter; - const [sampleStart, sampleEnd] = counterSampleRange; - const bisectionCounter = bisectionRight( - samples.time, - timeAtMouse, - sampleStart, - sampleEnd - ); - if (bisectionCounter > 0 && bisectionCounter < samples.time.length) { - const leftDistance = timeAtMouse - samples.time[bisectionCounter - 1]; - const rightDistance = samples.time[bisectionCounter] - timeAtMouse; - if (leftDistance < rightDistance) { - // Left point is closer - hoveredCounter = bisectionCounter - 1; - } else { - // Right point is closer - hoveredCounter = bisectionCounter; - } - } else { - hoveredCounter = bisectionCounter; - } - - if (hoveredCounter === samples.length) { - // When hovering the last sample, it's possible the mouse is past the time. - // In this case, hover over the last sample. This happens because of the - // ` + interval` line in the `if` condition above. - hoveredCounter = samples.time.length - 1; - } - - this.setState({ - mouseX, - mouseY, - hoveredCounter, - }); - } - }; - - _renderTooltip(counterIndex: number): React.ReactNode { - const { - counter, - maxCounterSampleCountPerMs, - interval, - rangeStart, - rangeEnd, - } = this.props; - const { mouseX, mouseY } = this.state; - const { samples } = counter; - if (samples.length === 0) { - // Gecko failed to capture samples for some reason and it shouldn't happen for - // malloc counter. Print an error and bail out early. - throw new Error('No sample found for process CPU counter'); - } - const sampleTime = samples.time[counterIndex]; - if (sampleTime < rangeStart || sampleTime > rangeEnd) { - // Do not draw the tooltip if it will be rendered outside of the timeline. - // This could happen when a sample time is outside of the time range. - // While range filtering the counters, we add the sample before start and - // after end, so charts will not be cut off at the edges. - return null; - } - - const maxCPUPerMs = maxCounterSampleCountPerMs; - const cpuUsage = samples.count[counterIndex]; - const sampleTimeDeltaInMs = - counterIndex === 0 - ? interval - : samples.time[counterIndex] - samples.time[counterIndex - 1]; - const cpuRatio = cpuUsage / sampleTimeDeltaInMs / maxCPUPerMs; - return ( - -
    -
    - CPU:{' '} - - {formatPercent(cpuRatio)} - -
    -
    -
    - ); - } - - /** - * Create a div that is a dot on top of the graph representing the current - * height of the graph. - */ - _renderDot(counterIndex: number): React.ReactNode { - const { - counter, - rangeStart, - rangeEnd, - graphHeight, - width, - lineWidth, - maxCounterSampleCountPerMs, - interval, - } = this.props; - const { samples } = counter; - const rangeLength = rangeEnd - rangeStart; - const sampleTime = samples.time[counterIndex]; - - if (sampleTime < rangeStart || sampleTime > rangeEnd) { - // Do not draw the dot if it will be rendered outside of the timeline. - // This could happen when a sample time is outside of the time range. - // While range filtering the counters, we add the sample before start and - // after end, so charts will not be cut off at the edges. - return null; - } - - const left = - (width * (samples.time[counterIndex] - rangeStart)) / rangeLength; - - if (samples.length === 0) { - // Gecko failed to capture samples for some reason and it shouldn't happen for - // process CPU counter. Print an error and bail out early. - throw new Error('No sample found for process CPU counter'); - } - const countRangePerMs = maxCounterSampleCountPerMs; - const sampleTimeDeltaInMs = - counterIndex === 0 - ? interval - : samples.time[counterIndex] - samples.time[counterIndex - 1]; - const unitSampleCount = - samples.count[counterIndex] / sampleTimeDeltaInMs / countRangePerMs; - const innerTrackHeight = graphHeight - lineWidth / 2; - const top = - innerTrackHeight - unitSampleCount * innerTrackHeight + lineWidth / 2; - - return ( -
    - ); - } - - override render() { - const { hoveredCounter } = this.state; - const { - filteredThread, - interval, - rangeStart, - rangeEnd, - unfilteredSamplesRange, - counter, - counterSampleRange, - graphHeight, - width, - lineWidth, - maxCounterSampleCountPerMs, - } = this.props; - - return ( -
    - - {hoveredCounter === null ? null : ( - <> - {this._renderDot(hoveredCounter)} - {this._renderTooltip(hoveredCounter)} - - )} - -
    - ); - } -} - -export const TrackProcessCPUGraph = explicitConnect< - OwnProps, - StateProps, - DispatchProps ->({ - mapStateToProps: (state, ownProps) => { - const { counterIndex } = ownProps; - const counterSelectors = getCounterSelectors(counterIndex); - const counter = counterSelectors.getCounter(state); - const { start, end } = getCommittedRange(state); - const counterSampleRange = - counterSelectors.getCommittedRangeCounterSampleRange(state); - const selectors = getThreadSelectors(counter.mainThreadIndex); - return { - counter, - threadIndex: counter.mainThreadIndex, - maxCounterSampleCountPerMs: - counterSelectors.getMaxCounterSampleCountPerMs(state), - rangeStart: start, - rangeEnd: end, - counterSampleRange, - interval: getProfileInterval(state), - filteredThread: selectors.getFilteredThread(state), - unfilteredSamplesRange: selectors.unfilteredSamplesRange(state), - }; - }, - component: withSize(TrackProcessCPUGraphImpl), -}); diff --git a/src/components/timeline/TrackThread.tsx b/src/components/timeline/TrackThread.tsx index afbbfcdda5..b0ee5338a6 100644 --- a/src/components/timeline/TrackThread.tsx +++ b/src/components/timeline/TrackThread.tsx @@ -87,7 +87,6 @@ type StateProps = { b: IndexIntoSamplesTable ) => number; readonly selectedThreadIndexes: Set; - readonly enableCPUUsage: boolean; readonly isExperimentalCPUGraphsEnabled: boolean; readonly implementationFilter: ImplementationFilter; readonly callTreeVisible: boolean; @@ -186,7 +185,6 @@ class TimelineTrackThreadImpl extends PureComponent { treeOrderSampleComparator, trackType, trackName, - enableCPUUsage, isExperimentalCPUGraphsEnabled, implementationFilter, zeroAt, @@ -241,8 +239,7 @@ class TimelineTrackThreadImpl extends PureComponent { /> ) : null} - {(timelineType === 'category' || timelineType === 'cpu-category') && - !filteredThread.isJsTracer ? ( + {timelineType !== 'stack' && !filteredThread.isJsTracer ? ( <> { categories={categories} sampleSelectedStates={sampleSelectedStates} treeOrderSampleComparator={treeOrderSampleComparator} - enableCPUUsage={enableCPUUsage} implementationFilter={implementationFilter} - timelineType={timelineType} zeroAt={zeroAt} profileTimelineUnit={profileTimelineUnit} /> @@ -274,7 +269,6 @@ class TimelineTrackThreadImpl extends PureComponent { sampleSelectedStates={sampleSelectedStates} categories={categories} onSampleClick={this._onSampleClick} - timelineType={timelineType} implementationFilter={implementationFilter} zeroAt={zeroAt} profileTimelineUnit={profileTimelineUnit} @@ -335,8 +329,6 @@ export const TimelineTrackThread = explicitConnect< const committedRange = getCommittedRange(state); const fullThread = selectors.getThread(state); const timelineType = getTimelineType(state); - const enableCPUUsage = - timelineType === 'cpu-category' && fullThread.samples.hasCPUDeltas; return { fullThread, @@ -361,7 +353,6 @@ export const TimelineTrackThread = explicitConnect< treeOrderSampleComparator: selectors.getTreeOrderComparatorInFilteredThread(state), selectedThreadIndexes, - enableCPUUsage, isExperimentalCPUGraphsEnabled: getIsExperimentalCPUGraphsEnabled(state), implementationFilter: getImplementationFilter(state), callTreeVisible: selectors.getUsefulTabs(state).includes('calltree'), diff --git a/src/components/tooltip/Tooltip.css b/src/components/tooltip/Tooltip.css index 230085ffc2..1ec269b652 100644 --- a/src/components/tooltip/Tooltip.css +++ b/src/components/tooltip/Tooltip.css @@ -9,6 +9,7 @@ --internal-label-foreground-color: var(--grey-60); position: fixed; + z-index: var(--z-tooltip); display: inline-block; overflow: hidden; max-width: 600px; diff --git a/src/node-tools/profiler-edit.ts b/src/node-tools/profiler-edit.ts new file mode 100644 index 0000000000..961df029b3 --- /dev/null +++ b/src/node-tools/profiler-edit.ts @@ -0,0 +1,211 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import fs from 'fs'; +import minimist from 'minimist'; + +import { unserializeProfileOfArbitraryFormat } from 'firefox-profiler/profile-logic/process-profile'; +import { GOOGLE_STORAGE_BUCKET } from 'firefox-profiler/app-logic/constants'; +import { compress } from 'firefox-profiler/utils/gz'; +import { SymbolStore } from 'firefox-profiler/profile-logic/symbol-store'; +import { + symbolicateProfile, + applySymbolicationSteps, +} from 'firefox-profiler/profile-logic/symbolication'; +import type { SymbolicationStepInfo } from 'firefox-profiler/profile-logic/symbolication'; +import * as MozillaSymbolicationAPI from 'firefox-profiler/profile-logic/mozilla-symbolication-api'; +import type { Profile } from 'firefox-profiler/types/profile'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; + +/** + * A CLI tool for editing profiles. + * + * To use it, first build: + * yarn build-node-tools + * + * Then run: + * node node-tools-dist/profiler-edit.js -i -o [options] + * + * Examples: + * node node-tools-dist/profiler-edit.js -i samply-profile.json -o out.json \ + * --symbolicate-with-server http://localhost:8001/abcdef/ + */ + +type ProfileSource = + | { type: 'FILE'; path: string } + | { type: 'URL'; url: string } + | { type: 'HASH'; hash: string }; + +export interface CliOptions { + input: ProfileSource; + output: string; + symbolicateWithServer?: string; +} + +async function loadProfile(source: ProfileSource): Promise { + switch (source.type) { + case 'FILE': { + console.log(`Loading profile from file ${source.path}`); + const bytes = fs.readFileSync(source.path, null); + const profile = await unserializeProfileOfArbitraryFormat(bytes); + if (profile === undefined) { + throw new Error('Unable to parse the profile.'); + } + return profile; + } + case 'URL': { + console.log(`Loading profile from URL ${source.url}`); + const response = await fetch(source.url); + if (!response.ok) { + throw new Error( + `Unexpected response code: ${response.status} / ${response.statusText}` + ); + } + const bytes = await response.arrayBuffer(); + const profile = await unserializeProfileOfArbitraryFormat( + new Uint8Array(bytes) + ); + if (profile === undefined) { + throw new Error('Unable to parse the profile.'); + } + return profile; + } + case 'HASH': { + const url = `https://storage.googleapis.com/${GOOGLE_STORAGE_BUCKET}/${source.hash}`; + console.log(`Loading profile from hash ${source.hash}`); + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Unexpected response code: ${response.status} / ${response.statusText}` + ); + } + const bytes = await response.arrayBuffer(); + const profile = await unserializeProfileOfArbitraryFormat( + new Uint8Array(bytes) + ); + if (profile === undefined) { + throw new Error('Unable to parse the profile.'); + } + return profile; + } + default: + throw assertExhaustiveCheck(source); + } +} + +export async function run(options: CliOptions) { + const profile = await loadProfile(options.input); + + if (options.symbolicateWithServer !== undefined) { + const server = options.symbolicateWithServer; + const symbolStore = new SymbolStore({ + requestSymbolsFromServer: async (requests) => { + for (const { lib } of requests) { + console.log(` Loading symbols for ${lib.debugName}`); + } + try { + return await MozillaSymbolicationAPI.requestSymbols( + 'symbol server', + requests, + async (path, json) => { + const response = await fetch(server + path, { + body: json, + method: 'POST', + }); + return response.json(); + } + ); + } catch (e) { + throw new Error( + `There was a problem with the symbolication API request to the symbol server: ${e.message}` + ); + } + }, + requestSymbolsFromBrowser: async () => [], + requestSymbolsViaSymbolTableFromBrowser: async () => { + throw new Error('Not supported in this context'); + }, + }); + + console.log('Symbolicating...'); + const symbolicationSteps: SymbolicationStepInfo[] = []; + await symbolicateProfile(profile, symbolStore, (step) => { + symbolicationSteps.push(step); + }); + console.log('Applying collected symbolication steps...'); + const { shared, threads } = applySymbolicationSteps( + profile.threads, + profile.shared, + symbolicationSteps + ); + profile.shared = shared; + profile.threads = threads; + profile.meta.symbolicated = true; + } + + console.log(`Saving profile to ${options.output}`); + if (options.output.endsWith('.gz')) { + fs.writeFileSync(options.output, await compress(JSON.stringify(profile))); + } else { + fs.writeFileSync(options.output, JSON.stringify(profile)); + } + console.log('Finished.'); +} + +export function makeOptionsFromArgv(processArgv: string[]): CliOptions { + const argv = minimist(processArgv.slice(2), { + alias: { i: 'input', o: 'output' }, + }); + + const sources: ProfileSource[] = []; + + if (typeof argv.input === 'string' && argv.input !== '') { + if (/^https?:\/\//i.test(argv.input)) { + sources.push({ type: 'URL', url: argv.input }); + } else { + sources.push({ type: 'FILE', path: argv.input }); + } + } + if (typeof argv['from-file'] === 'string' && argv['from-file'] !== '') { + sources.push({ type: 'FILE', path: argv['from-file'] }); + } + if (typeof argv['from-url'] === 'string' && argv['from-url'] !== '') { + sources.push({ type: 'URL', url: argv['from-url'] }); + } + if (typeof argv['from-hash'] === 'string' && argv['from-hash'] !== '') { + sources.push({ type: 'HASH', hash: argv['from-hash'] }); + } + + if (sources.length === 0) { + throw new Error( + 'An input must be supplied: use -i , --from-file , --from-url , or --from-hash ' + ); + } + if (sources.length > 1) { + throw new Error( + 'Only one input may be supplied (-i, --from-file, --from-url, --from-hash)' + ); + } + + if (!(typeof argv.output === 'string' && argv.output !== '')) { + throw new Error('An output path must be supplied with --output / -o'); + } + + return { + input: sources[0], + output: argv.output, + symbolicateWithServer: + typeof argv['symbolicate-with-server'] === 'string' && + argv['symbolicate-with-server'] !== '' + ? argv['symbolicate-with-server'] + : undefined, + }; +} + +if (require.main === module) { + const options = makeOptionsFromArgv(process.argv); + run(options).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/src/profile-logic/js-tracer.tsx b/src/profile-logic/js-tracer.ts similarity index 100% rename from src/profile-logic/js-tracer.tsx rename to src/profile-logic/js-tracer.ts diff --git a/src/profile-logic/process-profile.ts b/src/profile-logic/process-profile.ts index fbf4c99cb5..709b3c048e 100644 --- a/src/profile-logic/process-profile.ts +++ b/src/profile-logic/process-profile.ts @@ -97,6 +97,7 @@ import type { GeckoSourceTable, IndexIntoCategoryList, IndexIntoFrameTable, + CounterDisplayConfig, } from 'firefox-profiler/types'; import { decompress, isGzip } from 'firefox-profiler/utils/gz'; @@ -972,6 +973,61 @@ function _processSamples( return samples; } +/** + * Derive a CounterDisplayConfig from a counter's category and name. + */ +function _deriveCounterDisplay( + category: string, + name: string +): CounterDisplayConfig { + if (category === 'Bandwidth') { + return { + graphType: 'line-rate', + unit: 'bytes', + color: 'blue', + markerSchemaLocation: null, + sortWeight: 10, + label: 'Bandwidth', + }; + } else if (category === 'Memory') { + return { + graphType: 'line-accumulated', + unit: 'bytes', + color: 'orange', + markerSchemaLocation: 'timeline-memory', + sortWeight: 20, + label: 'Memory', + }; + } else if (category === 'power') { + return { + graphType: 'line-rate', + unit: 'pWh', + color: 'grey', + markerSchemaLocation: null, + sortWeight: 30, + label: name, + }; + } else if (category === 'CPU' && name === 'processCPU') { + return { + graphType: 'line-rate', + unit: 'percent', + color: 'grey', + markerSchemaLocation: null, + sortWeight: 40, + label: 'Process CPU', + }; + } + + return { + graphType: 'line-rate', + unit: '', + color: 'grey', + markerSchemaLocation: null, + sortWeight: 50, + label: name, + }; +} + /** * Converts the Gecko list of counters into the processed format. */ @@ -1031,6 +1087,7 @@ function _processCounters( pid: mainThreadPid, mainThreadIndex, samples: adjustTableTimeDeltas(processedCounterSamples, delta), + display: _deriveCounterDisplay(category, name), }); return result; }, diff --git a/src/profile-logic/processed-profile-versioning.ts b/src/profile-logic/processed-profile-versioning.ts index 691380cf78..725b597997 100644 --- a/src/profile-logic/processed-profile-versioning.ts +++ b/src/profile-logic/processed-profile-versioning.ts @@ -3049,6 +3049,67 @@ const _upgraders: { } }, + [62]: (profile: any) => { + // Added CounterDisplayConfig to counters. This metadata controls how a + // counter is rendered (graph type, color, unit, etc.). + // Derive defaults from the counter's category and name. + if (profile.counters) { + for (const counter of profile.counters) { + if (counter.display !== undefined) { + continue; + } + const { category, name } = counter; + if (category === 'Bandwidth') { + counter.display = { + graphType: 'line-rate', + unit: 'bytes', + color: counter.color ?? 'blue', + markerSchemaLocation: null, + sortWeight: 10, + label: 'Bandwidth', + }; + } else if (category === 'Memory') { + counter.display = { + graphType: 'line-accumulated', + unit: 'bytes', + color: counter.color ?? 'orange', + markerSchemaLocation: 'timeline-memory', + sortWeight: 20, + label: 'Memory', + }; + } else if (category === 'power') { + counter.display = { + graphType: 'line-rate', + unit: 'pWh', + color: counter.color ?? 'grey', + markerSchemaLocation: null, + sortWeight: 30, + label: name, + }; + } else if (category === 'CPU' && name === 'processCPU') { + counter.display = { + graphType: 'line-rate', + unit: 'percent', + color: counter.color ?? 'grey', + markerSchemaLocation: null, + sortWeight: 40, + label: 'Process CPU', + }; + } else { + counter.display = { + graphType: 'line-rate', + unit: '', + color: counter.color ?? 'grey', + markerSchemaLocation: null, + sortWeight: 50, + label: name, + }; + } + delete counter.color; + } + } + }, + // If you add a new upgrader here, please document the change in // `docs-developer/CHANGELOG-formats.md`. }; diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 49a521c9f1..28cfba5169 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -28,6 +28,7 @@ import { type BitSet, checkBit, combineTwoBitSetsWithAnd, + combineTwoBitSetsWithOr, makeBitSet, setBit, } from 'firefox-profiler/utils/bitset'; @@ -223,6 +224,9 @@ type CallNodeTableHierarchy = { prefix: Array; firstChild: Array; nextSibling: Array; + // The smallest-func root, i.e. the head of the roots' sibling list. -1 when + // there are no call nodes. + firstRoot: IndexIntoCallNodeTable; length: number; stackIndexToCallNodeIndex: Int32Array; }; @@ -268,6 +272,19 @@ type CallNodeTableExtraColumns = { * structure represented by those columns only has a very basic property, which * is "a prefix always comes before its children". * + * Sibling lists are kept sorted by func (ascending). This speeds up the "is + * there already a sibling with this func?" lookup: we can stop scanning as + * soon as we see a sibling with a greater func. + * + * On top of that, for each parent we remember the child we most recently + * matched or inserted. Consecutive stacks that share a prefix very often also + * share a func (different frames of the same function), so checking the + * last-used child first catches those repeats in O(1). When the last-used + * child's func is less than the new func, we can start the scan from it + * rather than from the head; in the common case where the last-used child is + * also the tail (because funcs have been arriving in ascending order), that + * scan starts at -1 and we append without any work. + * * This function does not compute the other columns yet, because at this point * we don't know the final order of the call nodes. And we want to store those * other values in typed arrays, for which we need to know the size upfront, and @@ -287,14 +304,14 @@ function _computeCallNodeTableHierarchy( let length = 0; // An extra column that only gets used while the table is built up: For each - // node A, currentLastChild[A] tracks the last currently-known child node of A. - // It is updated whenever a new node is created; e.g. creating node B updates - // currentLastChild[prefix[B]]. - // currentLastChild[A] is -1 while A has no children. - const currentLastChild: Array = []; + // node A, lastUsedChild[A] is the child of A that we most recently matched + // or inserted. It is -1 while A has no children. + const lastUsedChild: Array = []; - // The last currently-known root node, i.e. the last known "child of -1". - let currentLastRoot = -1; + // The root counterparts to firstChild / lastUsedChild for the "virtual" + // parent -1. + let firstRoot = -1; + let lastUsedRoot = -1; // Go through each stack, and create a new callNode table, which is based off of // functions rather than frames. @@ -307,25 +324,63 @@ function _computeCallNodeTableHierarchy( const frameIndex = stackTable.frame[stackIndex]; const funcIndex = frameTable.func[frameIndex]; - // Check if the call node for this stack already exists. + const firstSibling = + prefixCallNode === -1 ? firstRoot : firstChild[prefixCallNode]; + + // Locate this (prefixCallNode, funcIndex) in the sorted sibling list. + // Either we find an existing match and reuse it, or we find the insertion + // point for a new node. When we need to insert, prevSibling is the node + // our new node should be linked after, or -1 if it should become the new + // head of the sibling list. let callNodeIndex = -1; - if (stackIndex !== 0) { - const currentFirstSibling = - prefixCallNode === -1 ? 0 : firstChild[prefixCallNode]; - for ( - let currentSibling = currentFirstSibling; - currentSibling !== -1; - currentSibling = nextSibling[currentSibling] - ) { - if (func[currentSibling] === funcIndex) { - callNodeIndex = currentSibling; - break; + let prevSibling = -1; // used for insertion, if callNodeIndex === -1 + + if (firstSibling !== -1) { + // Get the sibling that we used most recently for this parent. + // We know lastUsed is !== -1 because we know there is at least one sibling. + const lastUsed = + prefixCallNode === -1 ? lastUsedRoot : lastUsedChild[prefixCallNode]; + + if (funcIndex === func[lastUsed]) { + // Hot path: same func as the last child we touched for this parent. + callNodeIndex = lastUsed; + } else { + // We'll have to scan (at least part of) the list of siblings. + let sibling = firstSibling; + if (funcIndex > func[lastUsed]) { + // Since the list of siblings is ordered by func, we now know that can + // skip the part of the list that's before lastUsed. + // If lastUsed is the tail, sibling starts at -1 and we append without + // scanning. + prevSibling = lastUsed; + sibling = nextSibling[lastUsed]; + } + while (sibling !== -1) { + const siblingFunc = func[sibling]; + if (siblingFunc === funcIndex) { + // Found a match! + callNodeIndex = sibling; + break; + } + if (siblingFunc > funcIndex) { + // No match, and we can stop scanning here due to the ordering. + // We'll insert the new node before `sibling`; prevSibling is + // already its predecessor. + break; + } + prevSibling = sibling; + sibling = nextSibling[sibling]; } } } if (callNodeIndex !== -1) { stackIndexToCallNodeIndex[stackIndex] = callNodeIndex; + if (prefixCallNode === -1) { + lastUsedRoot = callNodeIndex; + } else { + lastUsedChild[prefixCallNode] = callNodeIndex; + } continue; } @@ -335,39 +390,34 @@ function _computeCallNodeTableHierarchy( prefix[callNodeIndex] = prefixCallNode; func[callNodeIndex] = funcIndex; - - // Initialize these firstChild and nextSibling to -1. They will be updated - // once this node's first child or next sibling gets created. firstChild[callNodeIndex] = -1; - nextSibling[callNodeIndex] = -1; - currentLastChild[callNodeIndex] = -1; + lastUsedChild[callNodeIndex] = -1; + + // Splice the new node into the sibling list. + if (prevSibling === -1) { + // Insert at head. + nextSibling[callNodeIndex] = firstSibling; + if (prefixCallNode === -1) { + firstRoot = callNodeIndex; + } else { + firstChild[prefixCallNode] = callNodeIndex; + } + } else { + // Insert after prevSibling. + nextSibling[callNodeIndex] = nextSibling[prevSibling]; + nextSibling[prevSibling] = callNodeIndex; + } - // Update the next sibling of our previous sibling, and the first child of - // our prefix (if we're the first child). - // Also set this node's depth. if (prefixCallNode === -1) { - // This node is a root. Just update the previous root's nextSibling. Because - // this node has no parent, there's also no firstChild information to update. - if (currentLastRoot !== -1) { - nextSibling[currentLastRoot] = callNodeIndex; - } - currentLastRoot = callNodeIndex; + lastUsedRoot = callNodeIndex; } else { - // This node is not a root: update both firstChild and nextSibling information - // when appropriate. - const prevSiblingIndex = currentLastChild[prefixCallNode]; - if (prevSiblingIndex === -1) { - // This is the first child for this prefix. - firstChild[prefixCallNode] = callNodeIndex; - } else { - nextSibling[prevSiblingIndex] = callNodeIndex; - } - currentLastChild[prefixCallNode] = callNodeIndex; + lastUsedChild[prefixCallNode] = callNodeIndex; } } return { prefix, firstChild, + firstRoot, nextSibling, length, stackIndexToCallNodeIndex, @@ -389,15 +439,21 @@ function _computeCallNodeTableHierarchy( * column and allows other parts of the codebase to perform cheap "is descendant" * checks. * - * We do not order siblings by func. The order of siblings is meaningless, and - * is based on the somewhat arbitrary order in which we encounter the original - * stack nodes in the stack table. + * Sibling nodes are ordered by func, though this happens in + * _computeCallNodeTableHierarchy. (This function just keeps the same order of + * siblings as what's in the `hierarchy` argument.) */ function _computeCallNodeTableDFSOrder( hierarchy: CallNodeTableHierarchy ): CallNodeTableDFSOrder { - const { prefix, firstChild, nextSibling, length, stackIndexToCallNodeIndex } = - hierarchy; + const { + prefix, + firstChild, + firstRoot, + nextSibling, + length, + stackIndexToCallNodeIndex, + } = hierarchy; const prefixSorted = new Int32Array(length); const nextSiblingSorted = new Int32Array(length); @@ -422,8 +478,10 @@ function _computeCallNodeTableDFSOrder( // the unsorted columns into the sorted columns. // 2. Find the next node in DFS order, set nextOldIndex to it, and continue // to the next loop iteration. + // Start at firstRoot because, with func-sorted siblings, the head of the + // roots' sibling list is not necessarily call node 0. const oldIndexToNewIndex = new Uint32Array(length); - let nextOldIndex = 0; + let nextOldIndex = firstRoot; let nextNewIndex = 0; let currentDepth = 0; let currentOldPrefix = -1; @@ -1720,7 +1778,22 @@ export function applyTransformOutputToThread( }); } -export function computeTransformOutputForSearchStringFilter( +/** + * Output of the search string filter: both the filter's TransformOutput (used + * to drop non-matching stacks) and the combined func bitset (used to highlight + * matching nodes in the stack chart). Exposed together so both are computed + * once and memoized together. + * + * Stacks are AND-combined across search strings (a stack is kept only if it + * contains a match for every search string), while funcs are OR-combined (a + * func is highlighted if it matches any of the search strings). + */ +export type SearchStringFilterOutput = { + transformOutput: TransformOutput; + funcMatchesSearchStrings: BitSet | null; +}; + +export function computeSearchStringFilterOutput( stackTable: StackTable, frameTable: FrameTable, funcTable: FuncTable, @@ -1728,51 +1801,67 @@ export function computeTransformOutputForSearchStringFilter( sources: SourceTable, stringTable: StringTable, searchStrings: string[] | null -): TransformOutput { - return timeCode('computeTransformOutputForSearchStringFilter', () => { - if (!searchStrings) { - return { newStackTable: stackTable, effectOnThreadData: {} }; +): SearchStringFilterOutput { + return timeCode('computeSearchStringFilterOutput', () => { + if (!searchStrings || searchStrings.length === 0) { + return { + transformOutput: { newStackTable: stackTable, effectOnThreadData: {} }, + funcMatchesSearchStrings: null, + }; } - const stackMatchesAllSearchStrings = searchStrings - .filter((s) => s) - .reduce( - ( - stackMatchesPreviousSearchStrings: BitSet | undefined, - searchString: string - ) => { - const stackMatchesThisString = _computeStackMatchesSearchString( - stackTable, - frameTable, - funcTable, - resourceTable, - sources, - stringTable, - searchString - ); - if (stackMatchesPreviousSearchStrings !== undefined) { - return combineTwoBitSetsWithAnd( - stackMatchesThisString, - stackMatchesPreviousSearchStrings - ); - } - return stackMatchesThisString; - }, - undefined + const computeMatchesForString = (searchString: string) => { + const funcMatches = computeFuncMatchesSearchString( + funcTable, + resourceTable, + sources, + stringTable, + searchString + ); + const stackMatches = _computeStackMatchesFromFuncMatches( + stackTable, + frameTable, + funcMatches + ); + return { funcMatches, stackMatches }; + }; + + let { + funcMatches: combinedFuncMatches, + stackMatches: combinedStackMatches, + } = computeMatchesForString(searchStrings[0]); + for (let i = 1; i < searchStrings.length; i++) { + const { funcMatches, stackMatches } = computeMatchesForString( + searchStrings[i] + ); + combinedFuncMatches = combineTwoBitSetsWithOr( + funcMatches, + combinedFuncMatches ); + combinedStackMatches = combineTwoBitSetsWithAnd( + stackMatches, + combinedStackMatches + ); + } return { - newStackTable: stackTable, - effectOnThreadData: { - dropIfOldStackIsNot: stackMatchesAllSearchStrings, + transformOutput: { + newStackTable: stackTable, + effectOnThreadData: { + dropIfOldStackIsNot: combinedStackMatches, + }, }, + funcMatchesSearchStrings: combinedFuncMatches, }; }); } -function _computeStackMatchesSearchString( - stackTable: StackTable, - frameTable: FrameTable, +/** + * Compute a BitSet of functions whose name, source filename, or resource name + * matches the given search string. This is used both for filtering stacks and + * for dimming non-matching nodes in the stack chart. + */ +export function computeFuncMatchesSearchString( funcTable: FuncTable, resourceTable: ResourceTable, sources: SourceTable, @@ -1815,7 +1904,14 @@ function _computeStackMatchesSearchString( setBit(funcMatchesSearch, funcIndex); } } + return funcMatchesSearch; +} +function _computeStackMatchesFromFuncMatches( + stackTable: StackTable, + frameTable: FrameTable, + funcMatchesSearch: BitSet +): BitSet { const stackMatchesSearch = makeBitSet(stackTable.length); for (let stackIndex = 0; stackIndex < stackTable.length; stackIndex++) { const prefix = stackTable.prefix[stackIndex]; @@ -1962,6 +2058,35 @@ export function getInclusiveIndexRangeForSelection( return [sampleStart, sampleEnd]; } +/** + * Return a thread where samples whose leaf stack frame is in the given + * category have had their stack nulled out. These samples are still present in + * the samples table (indexes are preserved), but they become invisible to the + * call tree, stack chart, and flame graph. + * + * This is used by the "Include idle samples" toggle to hide idle time from + * the call tree. + */ +export function filterThreadSamplesByLeafCategory( + thread: Thread, + categoryToExclude: IndexIntoCategoryList +): Thread { + const { samples } = thread; + const newStackCol = samples.stack.slice(); + for (let i = 0; i < samples.length; i++) { + if (samples.category[i] === categoryToExclude) { + newStackCol[i] = null; + } + } + return { + ...thread, + samples: { + ...samples, + stack: newStackCol, + }, + }; +} + /** * Return a thread whose samples (including allocation samples) have been * filtered to include just those in the given time window. @@ -2304,10 +2429,9 @@ export function processCounter(rawCounter: RawCounter): Counter { name: rawCounter.name, category: rawCounter.category, description: rawCounter.description, - color: rawCounter.color, pid: rawCounter.pid, mainThreadIndex: rawCounter.mainThreadIndex, - + display: rawCounter.display, samples, }; @@ -4305,10 +4429,8 @@ export function getNativeSymbolInfo( /** * Determines the timeline type by looking at the profile data. * - * There are three options: - * 'cpu-category': If a profile has both category and cpu usage information. - * 'category': If a profile has category information but not the cpu usage. - * 'stack': If a profile doesn't have category or cpu usage information. + * 'cpu-category': If a profile has category information. + * 'stack': If a profile doesn't have category information. */ export function determineTimelineType(profile: Profile): TimelineType { if (!profile.meta.categories) { @@ -4318,16 +4440,6 @@ export function determineTimelineType(profile: Profile): TimelineType { return 'stack'; } - if ( - !profile.meta.sampleUnits || - !profile.threads.some((thread) => thread.samples.threadCPUDelta) - ) { - // Have category information but doesn't have the CPU usage information. - // Use 'category'. - return 'category'; - } - - // Have both category and CPU usage information. Use 'cpu-category'. return 'cpu-category'; } diff --git a/src/profile-logic/symbolication.ts b/src/profile-logic/symbolication.ts index 3186fb7b3a..cdc01ac0d2 100644 --- a/src/profile-logic/symbolication.ts +++ b/src/profile-logic/symbolication.ts @@ -789,8 +789,8 @@ function _partiallyApplySymbolicationStep( if (funcIndex === undefined) { // Need a new func. funcIndex = funcTable.length; - funcTable.isJS[funcIndex] = false; - funcTable.relevantForJS[funcIndex] = false; + funcTable.isJS[funcIndex] = funcTable.isJS[oldFunc]; + funcTable.relevantForJS[funcIndex] = funcTable.relevantForJS[oldFunc]; funcTable.resource[funcIndex] = resourceIndex; funcTable.source[funcIndex] = null; funcTable.lineNumber[funcIndex] = null; diff --git a/src/profile-logic/tracks.ts b/src/profile-logic/tracks.ts index c831ad796e..ea5c690bbb 100644 --- a/src/profile-logic/tracks.ts +++ b/src/profile-logic/tracks.ts @@ -56,28 +56,22 @@ export type HiddenTracks = { const LOCAL_TRACK_INDEX_ORDER = { thread: 0, network: 1, - memory: 2, + counter: 2, ipc: 3, 'event-delay': 4, - 'process-cpu': 5, - power: 6, - marker: 7, - bandwidth: 8, + marker: 5, }; const LOCAL_TRACK_DISPLAY_ORDER = { network: 0, - bandwidth: 1, - memory: 2, - power: 3, + counter: 1, // IPC tracks that belong to the global track will appear right after network // and counter tracks. But we want to show the IPC tracks that belong to the // local threads right after their track. This special handling happens inside // the sort function. - ipc: 4, - thread: 5, - 'event-delay': 6, - 'process-cpu': 7, - marker: 8, + ipc: 2, + thread: 3, + 'event-delay': 4, + marker: 5, }; const GLOBAL_TRACK_INDEX_ORDER = { @@ -103,6 +97,27 @@ function _getDefaultLocalTrackOrder( const naturalSort = new Intl.Collator('en-US', { numeric: true }); // In place sort! trackOrder.sort((a, b) => { + // Tie-break between two counter tracks using their display.sortWeight. + // Cross-type ordering is handled below by LOCAL_TRACK_DISPLAY_ORDER. + if ( + tracks[a].type === 'counter' && + tracks[b].type === 'counter' && + profile && + profile.counters + ) { + if (profile.meta.keepProfileThreadOrder) { + return tracks[a].counterIndex - tracks[b].counterIndex; + } + const counterA = profile.counters[tracks[a].counterIndex]; + const counterB = profile.counters[tracks[b].counterIndex]; + const sortWeightDiff = + counterA.display.sortWeight - counterB.display.sortWeight; + if (sortWeightDiff !== 0) { + return sortWeightDiff; + } + return naturalSort.compare(counterA.name, counterB.name); + } + if ( tracks[a].type === 'thread' && tracks[b].type === 'ipc' && @@ -123,22 +138,6 @@ function _getDefaultLocalTrackOrder( return 1; } - if ( - profile && - profile.counters && - tracks[a].type === 'power' && - tracks[b].type === 'power' - ) { - const idxA = tracks[a].counterIndex; - const idxB = tracks[b].counterIndex; - if (profile.meta.keepProfileThreadOrder) { - return idxA - idxB; - } - const nameA = profile.counters[idxA].name; - const nameB = profile.counters[idxB].name; - return naturalSort.compare(nameA, nameB); - } - // If the tracks are both threads, sort them by thread name, and then by // creation time if they have the same name. if (tracks[a].type === 'thread' && tracks[b].type === 'thread' && profile) { @@ -400,30 +399,24 @@ export function computeLocalTracksByPid( const { counters } = profile; if (counters) { for (let counterIndex = 0; counterIndex < counters.length; counterIndex++) { - const { pid, category, samples } = counters[counterIndex]; + const { pid, category, name } = counters[counterIndex]; if (!availablePids.has(pid)) { // If the global track is filtered out ignore it here too. continue; } - if (['Memory', 'power', 'Bandwidth'].includes(category)) { - if (category === 'power' && samples.length <= 2) { - // If we have only 2 samples, they are likely both 0 and we don't have a real counter. - continue; - } - let tracks = localTracksByPid.get(pid); - if (tracks === undefined) { - tracks = []; - localTracksByPid.set(pid, tracks); - } - if (category === 'Memory') { - tracks.push({ type: 'memory', counterIndex }); - } else if (category === 'Bandwidth') { - tracks.push({ type: 'bandwidth', counterIndex }); - } else { - tracks.push({ type: 'power', counterIndex }); - } + // Skip processCPU counters — they are added separately by + // addProcessCPUTracksForProcess when the experimental flag is enabled. + if (category === 'CPU' && name === 'processCPU') { + continue; + } + + let tracks = localTracksByPid.get(pid); + if (tracks === undefined) { + tracks = []; + localTracksByPid.set(pid, tracks); } + tracks.push({ type: 'counter', counterIndex }); } } @@ -496,7 +489,7 @@ export function addProcessCPUTracksForProcess( let localTracks = newLocalTracksByPid.get(pid) ?? []; // Do not mutate the current state. - localTracks = [...localTracks, { type: 'process-cpu', counterIndex }]; + localTracks = [...localTracks, { type: 'counter', counterIndex }]; newLocalTracksByPid.set(pid, localTracks); } @@ -1116,10 +1109,10 @@ export function getLocalTrackName( return getFriendlyThreadName(threads, threads[localTrack.threadIndex]); case 'network': return 'Network'; - case 'memory': - return 'Memory'; - case 'bandwidth': - return 'Bandwidth'; + case 'counter': { + const counter = counters[localTrack.counterIndex]; + return counter.display.label || counter.name; + } case 'ipc': return `IPC — ${getFriendlyThreadName( threads, @@ -1130,10 +1123,6 @@ export function getLocalTrackName( getFriendlyThreadName(threads, threads[localTrack.threadIndex]) + ' Event Delay' ); - case 'process-cpu': - return 'Process CPU'; - case 'power': - return counters[localTrack.counterIndex].name; case 'marker': return shared.stringArray[localTrack.markerName]; default: @@ -1577,14 +1566,18 @@ export function getSearchFilteredLocalTracksByPid( } break; } + case 'counter': { + const trackName = localTrackNames[trackIndex]; + if (searchRegExp.test(trackName)) { + searchFilteredLocalTracks.add(trackIndex); + continue; + } + break; + } case 'network': - case 'memory': - case 'bandwidth': case 'marker': case 'ipc': - case 'event-delay': - case 'power': - case 'process-cpu': { + case 'event-delay': { const { type } = localTrack; if (searchRegExp.test(type)) { searchFilteredLocalTracks.add(trackIndex); @@ -1762,7 +1755,8 @@ export function getTrackReferenceFromThreadIndex( * of them can be hidden to reduce the noise. This mostly depends on either the * usefulness or the activity of that track. * - * TODO: Check the memory track activity here to decide if it should be visible. + * TODO: Check the counter track activity here to decide if it should be visible, + * see https://github.com/firefox-devtools/profiler/issues/5967. */ function _isLocalTrackVisible( localTrack: LocalTrack, @@ -1774,15 +1768,10 @@ function _isLocalTrackVisible( return visibleThreadIndexes.has(localTrack.threadIndex); case 'marker': case 'network': - case 'memory': - case 'bandwidth': - // 'event-delay' and 'process-cpu' tracks are experimental and they should - // be visible by default whenever they are included in a profile. (fallthrough) + case 'counter': + // 'event-delay' track is experimental, and it should be visible by default + // whenever it is included in a profile. (fallthrough) case 'event-delay': - case 'process-cpu': - // Power tracks are there only if the power feature is enabled. So they should - // be visible by default whenever they're included in a profile. (fallthrough) - case 'power': // Keep non-thread local tracks visible. return true; case 'ipc': diff --git a/src/profile-logic/transforms.ts b/src/profile-logic/transforms.ts index 604b7f1179..05ba86b2bc 100644 --- a/src/profile-logic/transforms.ts +++ b/src/profile-logic/transforms.ts @@ -10,8 +10,9 @@ import { toValidImplementationFilter, updateThreadStacks, updateThreadStacksByGeneratingNewStackColumns, - getMapStackUpdater, getOriginAnnotationForFunc, + createStackTableBySkippingDiscarded, + applyTransformOutputToThread, } from './profile-data'; import { timeCode } from '../utils/time-code'; import { assertExhaustiveCheck, convertToTransformType } from '../utils/types'; @@ -54,6 +55,7 @@ import { translateFuncIndex, translateResourceIndex, } from './index-translation'; +import { checkBit, makeBitSet, setBit } from 'firefox-profiler/utils/bitset'; /** * This file contains the functions and logic for working with and applying transforms @@ -756,102 +758,78 @@ export function mergeCallNode( ): Thread { return timeCode('mergeCallNode', () => { const { stackTable, frameTable } = thread; - // Depth here is 0 indexed. - const depthAtCallNodePathLeaf = callNodePath.length - 1; - const oldStackToNewStack: Map< + + // Maps merged stacks to their effective parent (the stack that samples pointing + // to the merged stack should be attributed to). Only contains entries for merged + // stacks; the vast majority of stacks are not merged and map to themselves. + const mergedStackToEffectiveParent = new Map< IndexIntoStackTable | null, IndexIntoStackTable | null - > = new Map(); - // A root stack's prefix will be null. Maintain that relationship from old to new - // stacks by mapping from null to null. - oldStackToNewStack.set(null, null); - const newFrameCol = []; - const newPrefixCol = []; - const newCategoryCol = []; - const newSubcategoryCol = []; - let newLength = 0; - // Provide two arrays to efficiently cache values for the algorithm. This probably - // could be refactored to use only one array here. - const stackDepths = []; - const stackMatches = []; + >(); + const newPrefixCol = new Array( + stackTable.length + ); + const funcMatchesImplementation = FUNC_MATCHES[implementation]; + + const callNodePathLength = callNodePath.length; + // A map to keep track of whether a stack matches (part of) the call node path. + // If undefined: no match + // Otherwise: length of the partial match, including this stack + // All values are < callNodePathLength. + const partialMatchLengthAtStack = new Map< + IndexIntoStackTable | null, + number + >(); + partialMatchLengthAtStack.set(null, 0); for (let stackIndex = 0; stackIndex < stackTable.length; stackIndex++) { const prefix = stackTable.prefix[stackIndex]; + + // If our prefix got merged away, remap it to its parent. + const parentOfPrefix = mergedStackToEffectiveParent.get(prefix); + const effectivePrefix = + parentOfPrefix !== undefined ? parentOfPrefix : prefix; + newPrefixCol[stackIndex] = effectivePrefix; + + const prefixPartialMatchLength = partialMatchLengthAtStack.get(prefix); + if (prefixPartialMatchLength === undefined) { + // No match, nothing else to do here + continue; + } + + // Now we know that this stack's prefix was a (partial) match for our CallNodePath. const frameIndex = stackTable.frame[stackIndex]; - const category = stackTable.category[stackIndex]; - const subcategory = stackTable.subcategory[stackIndex]; const funcIndex = frameTable.func[frameIndex]; - const doesPrefixMatch = prefix === null ? true : stackMatches[prefix]; - const prefixDepth: number = prefix === null ? -1 : stackDepths[prefix]; - const currentFuncOnPath = callNodePath[prefixDepth + 1]; - - let doMerge = false; - let stackDepth: number = prefixDepth; - let doesMatchCallNodePath; - if (doesPrefixMatch && stackDepth < depthAtCallNodePathLeaf) { - // This stack's prefixes were in our CallNodePath. - if (currentFuncOnPath === funcIndex) { - // This stack's function matches too! - doesMatchCallNodePath = true; - if (stackDepth + 1 === depthAtCallNodePathLeaf) { - // Holy cow, we found a match for our merge operation and can merge this stack. - doMerge = true; - } else { - // Since we found a match, increase the stack depth. This should match - // the depth of the implementation filtered stacks. - stackDepth++; - } - } else if (!funcMatchesImplementation(thread, funcIndex)) { - // This stack's function does not match the CallNodePath, however it's not part - // of the CallNodePath's implementation filter. Go ahead and keep it. - doesMatchCallNodePath = true; + if (funcIndex === callNodePath[prefixPartialMatchLength]) { + // This stack's function matches too! + const matchLength = prefixPartialMatchLength + 1; + if (matchLength === callNodePathLength) { + // The entire path matched and we found a node that needs to be merged away. + mergedStackToEffectiveParent.set(stackIndex, effectivePrefix); } else { - // While all of the predecessors matched, this stack's function does not :( - doesMatchCallNodePath = false; + // Not reached the end yet, store the partial match length. + partialMatchLengthAtStack.set(stackIndex, matchLength); } + } else if (!funcMatchesImplementation(thread, funcIndex)) { + // This stack's function does not match the CallNodePath, however it's not part + // of the CallNodePath's implementation filter. Inherit the parent's partial + // match length + partialMatchLengthAtStack.set(stackIndex, prefixPartialMatchLength); } else { - // This stack is not part of a matching branch of the tree. - doesMatchCallNodePath = false; - } - stackMatches[stackIndex] = doesMatchCallNodePath; - stackDepths[stackIndex] = stackDepth; - - // Map the oldStackToNewStack, and only push on the stacks that aren't merged. - if (doMerge) { - const newStackPrefix = oldStackToNewStack.get(prefix); - oldStackToNewStack.set( - stackIndex, - newStackPrefix === undefined ? null : newStackPrefix - ); - } else { - const newStackIndex = newLength++; - const newStackPrefix = oldStackToNewStack.get(prefix); - newPrefixCol[newStackIndex] = - newStackPrefix === undefined ? null : newStackPrefix; - newFrameCol[newStackIndex] = frameIndex; - newCategoryCol[newStackIndex] = category; - newSubcategoryCol[newStackIndex] = subcategory; - oldStackToNewStack.set(stackIndex, newStackIndex); + // While all of the predecessors matched, this stack's function does not :( } } const newStackTable = { - frame: newFrameCol, + ...stackTable, prefix: newPrefixCol, - category: new Uint8Array(newCategoryCol), - subcategory: - stackTable.subcategory instanceof Uint8Array - ? new Uint8Array(newSubcategoryCol) - : new Uint16Array(newSubcategoryCol), - length: newLength, }; - return updateThreadStacks( - thread, - newStackTable, - getMapStackUpdater(oldStackToNewStack) - ); + return updateThreadStacks(thread, newStackTable, (oldStack) => { + const effectiveParent = mergedStackToEffectiveParent.get(oldStack); + return effectiveParent !== undefined ? effectiveParent : oldStack; + }); }); } @@ -1254,29 +1232,18 @@ export function focusSubtree( const prefixDepth = callNodePath.length; const stackMatches = new Int32Array(stackTable.length); const funcMatchesImplementation = FUNC_MATCHES[implementation]; - const oldStackToNewStack: Map< - IndexIntoStackTable | null, - IndexIntoStackTable | null - > = new Map(); - // A root stack's prefix will be null. Maintain that relationship from old to new - // stacks by mapping from null to null. - oldStackToNewStack.set(null, null); - const newFrameCol = []; - const newPrefixCol = []; - const newCategoryCol = []; - const newSubcategoryCol = []; - let newLength = 0; + const oldStackToNewStack = new Int32Array(stackTable.length).fill(-1); + const newPrefixCol: Array = []; + const keepStack = makeBitSet(stackTable.length); for (let stackIndex = 0; stackIndex < stackTable.length; stackIndex++) { const prefix = stackTable.prefix[stackIndex]; const prefixMatchesUpTo = prefix !== null ? stackMatches[prefix] : 0; let stackMatchesUpTo = -1; if (prefixMatchesUpTo !== -1) { - const frame = stackTable.frame[stackIndex]; - const category = stackTable.category[stackIndex]; - const subcategory = stackTable.subcategory[stackIndex]; if (prefixMatchesUpTo === prefixDepth) { stackMatchesUpTo = prefixDepth; } else { + const frame = stackTable.frame[stackIndex]; const funcIndex = frameTable.func[frame]; if (funcIndex === callNodePath[prefixMatchesUpTo]) { stackMatchesUpTo = prefixMatchesUpTo + 1; @@ -1285,41 +1252,25 @@ export function focusSubtree( } } if (stackMatchesUpTo === prefixDepth) { - const newStackIndex = newLength++; - const newStackPrefix = oldStackToNewStack.get(prefix); - newPrefixCol[newStackIndex] = newStackPrefix ?? null; - newFrameCol[newStackIndex] = frame; - newCategoryCol[newStackIndex] = category; - newSubcategoryCol[newStackIndex] = subcategory; - oldStackToNewStack.set(stackIndex, newStackIndex); + const prefixNewStack = + prefix === null ? -1 : oldStackToNewStack[prefix]; + oldStackToNewStack[stackIndex] = newPrefixCol.length; + newPrefixCol.push(prefixNewStack === -1 ? null : prefixNewStack); + setBit(keepStack, stackIndex); } } stackMatches[stackIndex] = stackMatchesUpTo; } - const newStackTable = { - frame: newFrameCol, - prefix: newPrefixCol, - category: new Uint8Array(newCategoryCol), - subcategory: - stackTable.subcategory instanceof Uint8Array - ? new Uint8Array(newSubcategoryCol) - : new Uint16Array(newSubcategoryCol), - length: newLength, - }; - - return updateThreadStacks(thread, newStackTable, (oldStack) => { - if (oldStack === null || stackMatches[oldStack] !== prefixDepth) { - return null; - } - const newStack = oldStackToNewStack.get(oldStack); - if (newStack === undefined) { - throw new Error( - 'Converting from the old stack to a new stack cannot be undefined' - ); - } - return newStack; - }); + const newStackTable = createStackTableBySkippingDiscarded( + stackTable, + newPrefixCol, + keepStack + ); + return applyTransformOutputToThread( + { newStackTable, effectOnThreadData: { oldStackToNewStack } }, + thread + ); }); } @@ -1383,53 +1334,32 @@ export function focusFunction( ): Thread { return timeCode('focusFunction', () => { const { stackTable, frameTable } = thread; - // A map oldStack -> newStack+1, implemented as a Uint32Array for performance. - // If newStack+1 is zero it means "null", i.e. this stack was filtered out. - // Typed arrays are initialized to zero, which we interpret as null. - const oldStackToNewStackPlusOne = new Uint32Array(stackTable.length); - - const newFrameCol = []; - const newPrefixCol = []; - const newCategoryCol = []; - const newSubcategoryCol = []; - let newLength = 0; + const oldStackToNewStack = new Int32Array(stackTable.length).fill(-1); + const newPrefixCol: Array = []; + const keepStack = makeBitSet(stackTable.length); for (let stackIndex = 0; stackIndex < stackTable.length; stackIndex++) { const prefix = stackTable.prefix[stackIndex]; const frameIndex = stackTable.frame[stackIndex]; const funcIndex = frameTable.func[frameIndex]; - const newPrefixPlusOne = - prefix === null ? 0 : oldStackToNewStackPlusOne[prefix]; - const newPrefix = newPrefixPlusOne === 0 ? null : newPrefixPlusOne - 1; - if (newPrefix !== null || funcIndex === funcIndexToFocus) { - const newStackIndex = newLength++; - newPrefixCol[newStackIndex] = newPrefix; - newFrameCol[newStackIndex] = frameIndex; - newCategoryCol[newStackIndex] = stackTable.category[stackIndex]; - newSubcategoryCol[newStackIndex] = stackTable.subcategory[stackIndex]; - oldStackToNewStackPlusOne[stackIndex] = newStackIndex + 1; + const prefixNewStack = prefix === null ? -1 : oldStackToNewStack[prefix]; + if (prefixNewStack !== -1 || funcIndex === funcIndexToFocus) { + oldStackToNewStack[stackIndex] = newPrefixCol.length; + newPrefixCol.push(prefixNewStack === -1 ? null : prefixNewStack); + setBit(keepStack, stackIndex); } } - const newStackTable = { - frame: newFrameCol, - prefix: newPrefixCol, - category: new Uint8Array(newCategoryCol), - subcategory: - stackTable.subcategory instanceof Uint8Array - ? new Uint8Array(newSubcategoryCol) - : new Uint16Array(newSubcategoryCol), - length: newLength, - }; - - return updateThreadStacks(thread, newStackTable, (oldStack) => { - if (oldStack === null) { - return null; - } - const newStackPlusOne = oldStackToNewStackPlusOne[oldStack]; - return newStackPlusOne === 0 ? null : newStackPlusOne - 1; - }); + const newStackTable = createStackTableBySkippingDiscarded( + stackTable, + newPrefixCol, + keepStack + ); + return applyTransformOutputToThread( + { newStackTable, effectOnThreadData: { oldStackToNewStack } }, + thread + ); }); } @@ -1443,96 +1373,86 @@ export function focusSelf( const funcMatchesImplementation = FUNC_MATCHES[implementation]; - const shouldKeepStack = new Uint8Array(stackTable.length); + const shouldKeepStack = makeBitSet(stackTable.length); - const newPrefixCol = stackTable.prefix.slice(); + const newPrefixCol = new Array(); + const oldStackToNewStack = new Int32Array(stackTable.length); for (let stackIndex = 0; stackIndex < stackTable.length; stackIndex++) { const frameIndex = stackTable.frame[stackIndex]; const funcIndex = frameTable.func[frameIndex]; if (funcIndex === funcIndexToFocus) { - shouldKeepStack[stackIndex] = 1; - newPrefixCol[stackIndex] = null; + setBit(shouldKeepStack, stackIndex); + const newStackIndex = newPrefixCol.length; + newPrefixCol[newStackIndex] = null; + oldStackToNewStack[stackIndex] = newStackIndex; } else { - const prefix = newPrefixCol[stackIndex]; + const oldPrefix = stackTable.prefix[stackIndex]; if ( - prefix !== null && - shouldKeepStack[prefix] === 1 && + oldPrefix !== null && + checkBit(shouldKeepStack, oldPrefix) && !funcMatchesImplementation(thread, funcIndex) ) { - shouldKeepStack[stackIndex] = 1; + setBit(shouldKeepStack, stackIndex); + const newPrefix = oldStackToNewStack[oldPrefix]; + const newStackIndex = newPrefixCol.length; + newPrefixCol[newStackIndex] = newPrefix; + oldStackToNewStack[stackIndex] = newStackIndex; } } } - const newStackTable = { - ...stackTable, - prefix: newPrefixCol, - }; + const newStackTable = createStackTableBySkippingDiscarded( + stackTable, + newPrefixCol, + shouldKeepStack + ); - return updateThreadStacks(thread, newStackTable, (oldStack) => { - if (oldStack === null || shouldKeepStack[oldStack] === 0) { - return null; - } - return oldStack; - }); + return applyTransformOutputToThread( + { + newStackTable, + effectOnThreadData: { + dropIfOldStackIsNot: shouldKeepStack, + oldStackToNewStack, + }, + }, + thread + ); }); } export function focusCategory(thread: Thread, category: IndexIntoCategoryList) { return timeCode('focusCategory', () => { const { stackTable } = thread; - const oldStackToNewStack: Map< - IndexIntoStackTable | null, - IndexIntoStackTable | null - > = new Map(); - oldStackToNewStack.set(null, null); - - const newFrameCol = []; - const newPrefixCol = []; - const newCategoryCol = []; - const newSubcategoryCol = []; - let newLength = 0; + const oldStackToNewStack = new Int32Array(stackTable.length).fill(-1); + const newPrefixCol: Array = []; + const keepStack = makeBitSet(stackTable.length); // fill the new stack table with the kept frames for (let stackIndex = 0; stackIndex < stackTable.length; stackIndex++) { const prefix = stackTable.prefix[stackIndex]; - const newPrefix = oldStackToNewStack.get(prefix); - if (newPrefix === undefined) { - throw new Error('The prefix should not map to an undefined value'); - } + const prefixNewStack = prefix === null ? -1 : oldStackToNewStack[prefix]; if (stackTable.category[stackIndex] !== category) { - oldStackToNewStack.set(stackIndex, newPrefix); + oldStackToNewStack[stackIndex] = prefixNewStack; continue; } - const newStackIndex = newLength++; - newPrefixCol[newStackIndex] = newPrefix; - newFrameCol[newStackIndex] = stackTable.frame[stackIndex]; - newCategoryCol[newStackIndex] = stackTable.category[stackIndex]; - newSubcategoryCol[newStackIndex] = stackTable.subcategory[stackIndex]; - oldStackToNewStack.set(stackIndex, newStackIndex); + oldStackToNewStack[stackIndex] = newPrefixCol.length; + newPrefixCol.push(prefixNewStack === -1 ? null : prefixNewStack); + setBit(keepStack, stackIndex); } - const newStackTable = { - frame: newFrameCol, - prefix: newPrefixCol, - category: new Uint8Array(newCategoryCol), - subcategory: - stackTable.subcategory instanceof Uint8Array - ? new Uint8Array(newSubcategoryCol) - : new Uint16Array(newSubcategoryCol), - length: newLength, - }; - - const updated = updateThreadStacks( - thread, - newStackTable, - getMapStackUpdater(oldStackToNewStack) + const newStackTable = createStackTableBySkippingDiscarded( + stackTable, + newPrefixCol, + keepStack + ); + return applyTransformOutputToThread( + { newStackTable, effectOnThreadData: { oldStackToNewStack } }, + thread ); - return updated; }); } diff --git a/src/reducers/app.ts b/src/reducers/app.ts index 5c3653dfd9..05d1856400 100644 --- a/src/reducers/app.ts +++ b/src/reducers/app.ts @@ -110,47 +110,6 @@ const isSidebarOpenPerPanel: Reducer = ( } }; -/** - * The panels that make up the timeline, details view, and sidebar can all change - * their sizes depending on the state that is fed to them. In order to control - * the invalidations of this sizing information, provide a "generation" value that - * increases monotonically for any change that potentially changes the sizing of - * any of the panels. This provides a mechanism for subscribing components to - * deterministically update their sizing correctly. - */ -const panelLayoutGeneration: Reducer = (state = 0, action) => { - switch (action.type) { - case 'INCREMENT_PANEL_LAYOUT_GENERATION': - // Sidebar: (fallthrough) - case 'CHANGE_SIDEBAR_OPEN_STATE': - // Timeline: (fallthrough) - case 'HIDE_GLOBAL_TRACK': - case 'SHOW_ALL_TRACKS': - case 'SHOW_PROVIDED_TRACKS': - case 'HIDE_PROVIDED_TRACKS': - case 'SHOW_GLOBAL_TRACK': - case 'SHOW_GLOBAL_TRACK_INCLUDING_LOCAL_TRACKS': - case 'ISOLATE_PROCESS': - case 'ISOLATE_PROCESS_MAIN_THREAD': - case 'HIDE_LOCAL_TRACK': - case 'SHOW_LOCAL_TRACK': - case 'ISOLATE_LOCAL_TRACK': - case 'TOGGLE_RESOURCES_PANEL': - case 'ENABLE_EXPERIMENTAL_CPU_GRAPHS': - case 'ENABLE_EXPERIMENTAL_PROCESS_CPU_TRACKS': - case 'CHANGE_TAB_FILTER': - // Committed range changes: (fallthrough) - case 'COMMIT_RANGE': - case 'POP_COMMITTED_RANGES': - // Bottom box: (fallthrough) - case 'UPDATE_BOTTOM_BOX': - case 'CLOSE_BOTTOM_BOX_FOR_TAB': - return state + 1; - default: - return state; - } -}; - /** * Clicking on tracks can switch between different tabs. This piece of state holds * on to the last relevant thread-based tab that was viewed. This makes the UX nicer @@ -365,7 +324,6 @@ const appStateReducer: Reducer = combineReducers({ urlSetupPhase, hasZoomedViaMousewheel, isSidebarOpenPerPanel, - panelLayoutGeneration, lastVisibleThreadTabSlug, trackThreadHeights, isNewlyPublished, diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 0d7a5e5c08..724b545b9f 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -312,6 +312,15 @@ const invertCallstack: Reducer = (state = false, action) => { } }; +const includeIdleSamples: Reducer = (state = true, action) => { + switch (action.type) { + case 'CHANGE_INCLUDE_IDLE_SAMPLES': + return action.includeIdleSamples; + default: + return state; + } +}; + /** * Signals whether user timing markers will be shown in the stack chart. */ @@ -707,6 +716,16 @@ const isBottomBoxOpenPerPanel: Reducer = ( } }; +const isBottomBoxFullscreen: Reducer = (state = false, action) => { + switch (action.type) { + case 'TOGGLE_BOTTOM_BOX_FULLSCREEN': { + return !state; + } + default: + return state; + } +}; + /** * This value is only set from the URL and never changed. */ @@ -740,6 +759,7 @@ const profileSpecific = combineReducers({ implementation, lastSelectedCallTreeSummaryStrategy, invertCallstack, + includeIdleSamples, showUserTimings, stackChartSameWidths, committedRanges, @@ -750,6 +770,7 @@ const profileSpecific = combineReducers({ sourceView, assemblyView, isBottomBoxOpenPerPanel, + isBottomBoxFullscreen, timelineType, globalTrackOrder, hiddenGlobalTracks, diff --git a/src/selectors/app.tsx b/src/selectors/app.tsx index d46d71eb4e..c477985be5 100644 --- a/src/selectors/app.tsx +++ b/src/selectors/app.tsx @@ -9,21 +9,20 @@ import { getHiddenGlobalTracks, getHiddenLocalTracksByPid, } from './url-state'; -import { getGlobalTracks, getLocalTracksByPid } from './profile'; +import { getGlobalTracks, getLocalTracksByPid, getCounters } from './profile'; import { getZipFileState } from './zipped-profiles'; import { assertExhaustiveCheck, ensureExists } from '../utils/types'; import { FULL_TRACK_SCREENSHOT_HEIGHT, TRACK_NETWORK_HEIGHT, - TRACK_MEMORY_HEIGHT, - TRACK_BANDWIDTH_HEIGHT, TRACK_IPC_HEIGHT, TRACK_PROCESS_BLANK_HEIGHT, TIMELINE_RULER_HEIGHT, TRACK_VISUAL_PROGRESS_HEIGHT, TRACK_EVENT_DELAY_HEIGHT, - TRACK_PROCESS_CPU_HEIGHT, TRACK_MARKER_HEIGHT, + TRACK_COUNTER_GRAPH_HEIGHT, + TRACK_COUNTER_MARKERS_HEIGHT, } from '../app-logic/constants'; import type { @@ -55,8 +54,6 @@ export const getHasZoomedViaMousewheel: Selector = (state) => { }; export const getIsSidebarOpen: Selector = (state) => getApp(state).isSidebarOpenPerPanel[getSelectedTab(state)]; -export const getPanelLayoutGeneration: Selector = (state) => - getApp(state).panelLayoutGeneration; export const getLastVisibleThreadTabSlug: Selector = (state) => getApp(state).lastVisibleThreadTabSlug; export const getTrackThreadHeights: Selector<{ @@ -106,12 +103,14 @@ export const getTimelineHeight: Selector = createSelector( getHiddenGlobalTracks, getHiddenLocalTracksByPid, getTrackThreadHeights, + getCounters, ( globalTracks, localTracksByPid, hiddenGlobalTracks, hiddenLocalTracksByPid, - trackThreadHeights + trackThreadHeights, + counters ) => { let height = TIMELINE_RULER_HEIGHT; const border = 1; @@ -186,22 +185,22 @@ export const getTimelineHeight: Selector = createSelector( case 'network': height += TRACK_NETWORK_HEIGHT + border; break; - case 'memory': - height += TRACK_MEMORY_HEIGHT + border; - break; - case 'bandwidth': - height += TRACK_BANDWIDTH_HEIGHT + border; + case 'counter': { + // Counter track height depends on whether the counter asks for + // markers to be rendered above the graph. + const counter = ensureExists(counters)[localTrack.counterIndex]; + height += counter.display.markerSchemaLocation + ? TRACK_COUNTER_GRAPH_HEIGHT + TRACK_COUNTER_MARKERS_HEIGHT + : TRACK_COUNTER_GRAPH_HEIGHT; + height += border; break; + } case 'event-delay': height += TRACK_EVENT_DELAY_HEIGHT + border; break; case 'ipc': height += TRACK_IPC_HEIGHT + border; break; - case 'process-cpu': - case 'power': - height += TRACK_PROCESS_CPU_HEIGHT + border; - break; case 'marker': height += TRACK_MARKER_HEIGHT + border; break; diff --git a/src/selectors/per-thread/markers.ts b/src/selectors/per-thread/markers.ts index 8f083a15a7..27d5b86483 100644 --- a/src/selectors/per-thread/markers.ts +++ b/src/selectors/per-thread/markers.ts @@ -21,6 +21,7 @@ import type { MarkerIndex, Marker, MarkerSchema, + MarkerDisplayLocation, MarkerTiming, MarkerTimingAndBuckets, DerivedMarkerInfo, @@ -492,29 +493,40 @@ export function getMarkerSelectorsPerThread( ); /** - * This returns only memory markers. - */ - const getTimelineMemoryMarkerIndexes: Selector = - createSelector( - getMarkerGetter, - getCommittedRangeFilteredMarkerIndexes, - ProfileSelectors.getMarkerSchema, - ProfileSelectors.getMarkerSchemaByName, - () => 'timeline-memory' as const, - MarkerData.filterMarkerByDisplayLocation - ); + * Returns markers for an arbitrary schema location. The inner selectors are + * memoized per location so repeated lookups with the same location reuse a + * single reselect instance. + */ + const _timelineMarkerIndexesSelectorsBySchemaLocation: Map< + MarkerDisplayLocation, + Selector + > = new Map(); + const getTimelineMarkerIndexesBySchemaLocation = ( + schemaLocation: MarkerDisplayLocation + ): Selector => { + let selector = + _timelineMarkerIndexesSelectorsBySchemaLocation.get(schemaLocation); + if (selector === undefined) { + selector = createSelector( + getMarkerGetter, + getCommittedRangeFilteredMarkerIndexes, + ProfileSelectors.getMarkerSchema, + ProfileSelectors.getMarkerSchemaByName, + () => schemaLocation, + MarkerData.filterMarkerByDisplayLocation + ); + _timelineMarkerIndexesSelectorsBySchemaLocation.set( + schemaLocation, + selector + ); + } + return selector; + }; - /** - * This returns only IPC markers. - */ - const getTimelineIPCMarkerIndexes: Selector = createSelector( - getMarkerGetter, - getCommittedRangeFilteredMarkerIndexes, - ProfileSelectors.getMarkerSchema, - ProfileSelectors.getMarkerSchemaByName, - () => 'timeline-ipc' as const, - MarkerData.filterMarkerByDisplayLocation - ); + const getTimelineMemoryMarkerIndexes = + getTimelineMarkerIndexesBySchemaLocation('timeline-memory'); + const getTimelineIPCMarkerIndexes = + getTimelineMarkerIndexesBySchemaLocation('timeline-ipc'); /** * This organizes the network markers in rows so that they're nicely displayed @@ -783,6 +795,7 @@ export function getMarkerSelectorsPerThread( getTimelineFileIoMarkerIndexes, getTimelineMemoryMarkerIndexes, getTimelineIPCMarkerIndexes, + getTimelineMarkerIndexesBySchemaLocation, getNetworkTrackTiming, getRangeFilteredScreenshotsById, getSearchFilteredMarkerIndexes, diff --git a/src/selectors/per-thread/thread.tsx b/src/selectors/per-thread/thread.tsx index 00158837aa..0fbab489ab 100644 --- a/src/selectors/per-thread/thread.tsx +++ b/src/selectors/per-thread/thread.tsx @@ -46,6 +46,7 @@ import type { } from 'firefox-profiler/types'; import type { TransformLabeL10nIds } from 'firefox-profiler/profile-logic/transforms'; +import type { BitSet } from 'firefox-profiler/utils/bitset'; import type { MarkerSelectorsPerThread } from './markers'; import { mergeThreads } from '../../profile-logic/merge-compare'; @@ -62,8 +63,8 @@ const globallyMemoizedComputeTransformOutputForImplementationFilter = memoize( limit: 2, } ); -const globallyMemoizedComputeTransformOutputForSearchStringFilter = memoize( - ProfileData.computeTransformOutputForSearchStringFilter, +const globallyMemoizedComputeSearchStringFilterOutput = memoize( + ProfileData.computeSearchStringFilterOutput, { limit: 2, } @@ -156,9 +157,10 @@ export function getBasicThreadSelectorsPerThread( * 3. Reserved functions - New funcTable with reserved functions for collapsed resources. * 4. Range - New samples table with only samples in the committed range. * 5. Transform - Apply the transform stack that modifies the stacks and samples. - * 6. Implementation - Modify stacks and samples to only show a single implementation. - * 7. Search - Exclude samples that don't include some text in the stack. - * 8. Preview - Only include samples that are within a user's preview range selection. + * 6. Idle - Optionally null out the stack of samples whose leaf frame is idle. + * 7. Implementation - Modify stacks and samples to only show a single implementation. + * 8. Search - Exclude samples that don't include some text in the stack. + * 9. Preview - Only include samples that are within a user's preview range selection. */ const getThread: Selector = createSelector( @@ -471,8 +473,23 @@ export function getThreadSelectorsWithMarkersPerThread( } ); - const _getImplementationFilteredThread: Selector = createSelector( + const _getIdleFilteredThread: Selector = createSelector( getRangeAndTransformFilteredThread, + UrlState.getIncludeIdleSamples, + ProfileSelectors.getIdleCategoryIndex, + (thread, includeIdleSamples, idleCategoryIndex) => { + if (includeIdleSamples || idleCategoryIndex === null) { + return thread; + } + return ProfileData.filterThreadSamplesByLeafCategory( + thread, + idleCategoryIndex + ); + } + ); + + const _getImplementationFilteredThread: Selector = createSelector( + _getIdleFilteredThread, UrlState.getImplementationFilter, (thread: Thread, implementationFilter: ImplementationFilter) => { // Apply the implementation filter. @@ -488,13 +505,17 @@ export function getThreadSelectorsWithMarkersPerThread( } ); - const getFilteredThread: Selector = createSelector( - _getImplementationFilteredThread, - UrlState.getSearchStrings, - (thread: Thread, searchStrings) => { - // Apply the search string filter. - const transformOutput = - globallyMemoizedComputeTransformOutputForSearchStringFilter( + /** + * Single memoized computation of the search string filter, shared by + * `getFilteredThread` (which needs the stack-drop bitset) and + * `getSearchFilteredFuncMatchesBitSet` (which needs the func-match bitset). + */ + const _getSearchStringFilterOutput: Selector = + createSelector( + _getImplementationFilteredThread, + UrlState.getSearchStrings, + (thread: Thread, searchStrings) => + globallyMemoizedComputeSearchStringFilterOutput( thread.stackTable, thread.frameTable, thread.funcTable, @@ -502,11 +523,24 @@ export function getThreadSelectorsWithMarkersPerThread( thread.sources, thread.stringTable, searchStrings - ); - return ProfileData.applyTransformOutputToThread(transformOutput, thread); - } + ) + ); + + const getFilteredThread: Selector = createSelector( + _getImplementationFilteredThread, + _getSearchStringFilterOutput, + (thread: Thread, { transformOutput }) => + ProfileData.applyTransformOutputToThread(transformOutput, thread) ); + /** + * Get a BitSet of func indices that match the current search strings. + * Returns null when there is no active search. This is used by the stack + * chart to dim non-matching nodes without recomputing on every draw call. + */ + const getSearchFilteredFuncMatchesBitSet: Selector = (state) => + _getSearchStringFilterOutput(state).funcMatchesSearchStrings; + const getPreviewFilteredThread: Selector = createSelector( getFilteredThread, ProfileSelectors.getPreviewSelection, @@ -613,6 +647,7 @@ export function getThreadSelectorsWithMarkersPerThread( getTransformStack, getRangeAndTransformFilteredThread, getFilteredThread, + getSearchFilteredFuncMatchesBitSet, getPreviewFilteredThread, getFilteredCtssSamples, getPreviewFilteredCtssSamples, diff --git a/src/selectors/profile.ts b/src/selectors/profile.ts index 7ac0ef4f2c..331b27ed6f 100644 --- a/src/selectors/profile.ts +++ b/src/selectors/profile.ts @@ -192,6 +192,12 @@ export const getPageList = (state: State): PageList | null => getProfile(state).pages || null; export const getDefaultCategory: Selector = (state) => getCategories(state).findIndex((c) => c.color === 'grey'); +export const getIdleCategoryIndex: Selector = ( + state +) => { + const index = getCategories(state).findIndex((c) => c.name === 'Idle'); + return index === -1 ? null : index; +}; export const getThreads: Selector = (state) => getProfile(state).threads; export const getThreadNames: Selector = (state) => @@ -549,10 +555,17 @@ export const getLocalTrackFromReference: DangerousSelectorWithArguments< */ export const getProcessesWithMemoryTrack: Selector> = createSelector( getLocalTracksByPid, - (localTracksByPid) => { + getCounters, + (localTracksByPid, counters) => { const processesWithMemoryTrack = new Set(); for (const [pid, localTracks] of localTracksByPid.entries()) { - if (localTracks.some((track) => track.type === 'memory')) { + if ( + localTracks.some( + (track) => + track.type === 'counter' && + ensureExists(counters)[track.counterIndex].category === 'Memory' + ) + ) { processesWithMemoryTrack.add(pid); } } diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index c3ff9e27e6..bf2bc8fe65 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -124,6 +124,8 @@ export const getSelectedTab: Selector = (state) => export const getInvertCallstack: Selector = (state) => getSelectedTab(state) === 'calltree' && getProfileSpecificState(state).invertCallstack; +export const getIncludeIdleSamples: Selector = (state) => + getProfileSpecificState(state).includeIdleSamples; export const getSelectedThreadIndexesOrNull: Selector< Set | null @@ -248,6 +250,10 @@ export const getIsBottomBoxOpen: Selector = (state) => { return getProfileSpecificState(state).isBottomBoxOpenPerPanel[tab]; }; +export const getIsBottomBoxFullscreen: Selector = (state) => { + return getProfileSpecificState(state).isBottomBoxFullscreen; +}; + /** * The URL predictor is used to generate a link for an uploaded profile, to predict * what the URL will be. diff --git a/src/symbolicator-cli/index.ts b/src/symbolicator-cli/index.ts deleted file mode 100644 index 533a726955..0000000000 --- a/src/symbolicator-cli/index.ts +++ /dev/null @@ -1,151 +0,0 @@ -/* - * This implements a simple CLI to symbolicate profiles captured by the profiler - * or by samply. - * - * To use it it first needs to be built: - * yarn build-symbolicator-cli - * - * Then it can be run from the `dist` directory: - * node dist/symbolicator-cli.js --input --output --server - * - * For example: - * node dist/symbolicator-cli.js --input samply-profile.json --output profile-symbolicated.json --server http://localhost:3000 - * - */ - -import fs from 'fs'; -import minimist from 'minimist'; - -import { unserializeProfileOfArbitraryFormat } from '../profile-logic/process-profile'; -import { SymbolStore } from '../profile-logic/symbol-store'; -import { - symbolicateProfile, - applySymbolicationSteps, -} from '../profile-logic/symbolication'; -import type { SymbolicationStepInfo } from '../profile-logic/symbolication'; -import * as MozillaSymbolicationAPI from '../profile-logic/mozilla-symbolication-api'; - -export interface CliOptions { - input: string; - output: string; - server: string; -} - -export async function run(options: CliOptions) { - console.log(`Loading profile from ${options.input}`); - - // Read the raw bytes from the file. It might be a JSON file, but it could also - // be a binary file, e.g. a .json.gz file, or any of the binary formats supported - // by our importers. - const bytes = fs.readFileSync(options.input, null); - - // Load the profile. - const profile = await unserializeProfileOfArbitraryFormat(bytes); - if (profile === undefined) { - throw new Error('Unable to parse the profile.'); - } - - /** - * SymbolStore implementation which just forwards everything to the symbol server in - * MozillaSymbolicationAPI format. No support for getting symbols from 'the browser' as - * there is no browser in this context. - */ - const symbolStore = new SymbolStore({ - requestSymbolsFromServer: async (requests) => { - for (const { lib } of requests) { - console.log(` Loading symbols for ${lib.debugName}`); - } - try { - return await MozillaSymbolicationAPI.requestSymbols( - 'symbol server', - requests, - async (path, json) => { - const response = await fetch(options.server + path, { - body: json, - method: 'POST', - }); - return response.json(); - } - ); - } catch (e) { - throw new Error( - `There was a problem with the symbolication API request to the symbol server: ${e.message}` - ); - } - }, - - requestSymbolsFromBrowser: async () => { - return []; - }, - - requestSymbolsViaSymbolTableFromBrowser: async () => { - throw new Error('Not supported in this context'); - }, - }); - - console.log('Symbolicating...'); - - const symbolicationSteps: SymbolicationStepInfo[] = []; - await symbolicateProfile( - profile, - symbolStore, - (symbolicationStepInfo: SymbolicationStepInfo) => { - symbolicationSteps.push(symbolicationStepInfo); - } - ); - - console.log('Applying collected symbolication steps...'); - - const { shared, threads } = applySymbolicationSteps( - profile.threads, - profile.shared, - symbolicationSteps - ); - profile.shared = shared; - profile.threads = threads; - profile.meta.symbolicated = true; - - console.log(`Saving profile to ${options.output}`); - fs.writeFileSync(options.output, JSON.stringify(profile)); - console.log('Finished.'); -} - -export function makeOptionsFromArgv(processArgv: string[]): CliOptions { - const argv = minimist(processArgv.slice(2)); - - if (!('input' in argv && typeof argv.input === 'string')) { - throw new Error( - 'Argument --input must be supplied with the path to the input profile' - ); - } - - if (!('output' in argv && typeof argv.output === 'string')) { - throw new Error( - 'Argument --output must be supplied with the path to the output profile' - ); - } - - if (!('server' in argv && typeof argv.server === 'string')) { - throw new Error( - 'Argument --server must be supplied with the URI of the symbol server endpoint' - ); - } - - return { - input: argv.input, - output: argv.output, - server: argv.server, - }; -} - -if (!module.parent) { - try { - const options = makeOptionsFromArgv(process.argv); - run(options).catch((err) => { - throw err; - }); - } catch (e) { - console.error(e); - process.exit(1); - } -} diff --git a/src/test/components/ProfileCallTreeView.test.tsx b/src/test/components/ProfileCallTreeView.test.tsx index c6fc3fd80e..ec74c00529 100644 --- a/src/test/components/ProfileCallTreeView.test.tsx +++ b/src/test/components/ProfileCallTreeView.test.tsx @@ -25,6 +25,7 @@ import { import { changeCallTreeSearchString, changeImplementationFilter, + changeIncludeIdleSamples, changeInvertCallstack, commitRange, addTransformToStack, @@ -343,6 +344,27 @@ describe('calltree/ProfileCallTreeView', function () { expect(getRowElement('E', { selected: true })).toHaveClass('isSelected'); expect(getRowElement('B', { expanded: true })).toBeInTheDocument(); }); + + it('hides samples whose leaf frame is idle when "Include idle samples" is unchecked', () => { + const { profile } = getProfileFromTextSamples(` + A A A A + B B B B + C D[cat:Idle] C D[cat:Idle] + `); + const { dispatch } = setup(profile); + + expect(screen.getByRole('treeitem', { name: /^C/ })).toBeInTheDocument(); + expect(screen.getByRole('treeitem', { name: /^D/ })).toBeInTheDocument(); + + act(() => { + dispatch(changeIncludeIdleSamples(false)); + }); + + expect(screen.getByRole('treeitem', { name: /^C/ })).toBeInTheDocument(); + expect( + screen.queryByRole('treeitem', { name: /^D/ }) + ).not.toBeInTheDocument(); + }); }); describe('calltree/ProfileCallTreeView EmptyReasons', function () { diff --git a/src/test/components/Root-history.test.tsx b/src/test/components/Root-history.test.tsx index e2c34a6bf2..13a130aa71 100644 --- a/src/test/components/Root-history.test.tsx +++ b/src/test/components/Root-history.test.tsx @@ -11,7 +11,7 @@ import { import { Root } from '../../components/app/Root'; import { autoMockCanvasContext } from '../fixtures/mocks/canvas-context'; import { fireFullClick } from '../fixtures/utils'; -import { getProfileUrlForHash } from '../../actions/receive-profile'; +import { getProfileUrlForHash } from '../../utils/profile-fetch'; import { blankStore } from '../fixtures/stores'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; import { diff --git a/src/test/components/StackChart.test.tsx b/src/test/components/StackChart.test.tsx index e8a2769361..1dab68d697 100644 --- a/src/test/components/StackChart.test.tsx +++ b/src/test/components/StackChart.test.tsx @@ -33,6 +33,7 @@ import { changeImplementationFilter, changeCallTreeSummaryStrategy, updatePreviewSelection, + changeCallTreeSearchString, } from '../../actions/profile-view'; import { changeSelectedTab } from '../../actions/app'; import { selectedThreadSelectors } from '../../selectors/per-thread'; @@ -271,6 +272,56 @@ describe('StackChart', function () { expect(drawnFrames).not.toContain('Z'); }); + it('dims non-matching boxes when searching', function () { + const { dispatch, flushRafCalls } = setupSamples(); + flushDrawLog(); + + // Dispatch a search string that matches some function names. + act(() => { + dispatch(changeCallTreeSearchString('B')); + }); + flushRafCalls(); + + const drawCalls = flushDrawLog(); + + // Non-matching boxes should be drawn with the dimmed style. + const dimmedFillCalls = drawCalls.filter( + ([fn, value]) => fn === 'set fillStyle' && value === '#f9f9fa' + ); + expect(dimmedFillCalls.length).toBeGreaterThan(0); + }); + + it('does not dim boxes that match the search string', function () { + // Use a single-node call stack so there is exactly one box. + const { dispatch, flushRafCalls } = setupSamples(` + A[cat:DOM] + `); + flushDrawLog(); + + // Search for "A" — the only node matches, so nothing should be dimmed. + act(() => { + dispatch(changeCallTreeSearchString('A')); + }); + flushRafCalls(); + + const drawCalls = flushDrawLog(); + const dimmedFillCalls = drawCalls.filter( + ([fn, value]) => fn === 'set fillStyle' && value === '#f9f9fa' + ); + expect(dimmedFillCalls).toHaveLength(0); + }); + + it('does not dim any boxes when there is no search string', function () { + setupSamples(); + const drawCalls = flushDrawLog(); + + // No dimmed fill should be applied without a search. + const dimmedFillCalls = drawCalls.filter( + ([fn, value]) => fn === 'set fillStyle' && value === '#f9f9fa' + ); + expect(dimmedFillCalls).toHaveLength(0); + }); + describe('EmptyReasons', () => { it('shows reasons when a profile has no samples', () => { const profile = getEmptyProfile(); diff --git a/src/test/components/TrackBandwidth.test.tsx b/src/test/components/TrackBandwidth.test.tsx index b8109c8693..f019d06bb5 100644 --- a/src/test/components/TrackBandwidth.test.tsx +++ b/src/test/components/TrackBandwidth.test.tsx @@ -13,7 +13,7 @@ import { } from 'firefox-profiler/test/fixtures/testing-library'; import { updatePreviewSelection } from 'firefox-profiler/actions/profile-view'; -import { TrackBandwidth } from '../../components/timeline/TrackBandwidth'; +import { TrackCounter } from '../../components/timeline/TrackCounter'; import { ensureExists } from '../../utils/types'; import { @@ -66,30 +66,37 @@ describe('TrackBandwidth', function () { for (let i = 7; i < thread.samples.length - 1; ++i) { sampleTimes[i] = 7 + i / 100; } - profile.counters = [ - getCounterForThreadWithSamples( - thread, - threadIndex, - { - time: sampleTimes.slice(), - // Bandwidth usage numbers. They are bytes. - count: [ - 10000, 40000, 50000, 100000, 2000000, 5000000, 30000, 1000000, - 20000, 1, 12000, 100000, - ], - length: SAMPLE_COUNT, - }, - 'SystemBandwidth', - 'bandwidth' - ), - ]; + const counter = getCounterForThreadWithSamples( + thread, + threadIndex, + { + time: sampleTimes.slice(), + // Bandwidth usage numbers. They are bytes. + count: [ + 10000, 40000, 50000, 100000, 2000000, 5000000, 30000, 1000000, 20000, + 1, 12000, 100000, + ], + length: SAMPLE_COUNT, + }, + 'SystemBandwidth', + 'Bandwidth' + ); + counter.display = { + ...counter.display, + graphType: 'line-rate', + unit: 'bytes', + color: 'blue', + sortWeight: 10, + label: 'Bandwidth', + }; + profile.counters = [counter]; const store = storeWithProfile(profile); const { getState, dispatch } = store; const flushRafCalls = mockRaf(); const renderResult = render( - + ); const { container } = renderResult; @@ -98,13 +105,13 @@ describe('TrackBandwidth', function () { flushRafCalls(); const canvas = ensureExists( - container.querySelector('.timelineTrackBandwidthCanvas'), - `Couldn't find the bandwidth canvas, with selector .timelineTrackBandwidthCanvas` + container.querySelector('.timelineTrackCounterCanvas'), + `Couldn't find the bandwidth canvas, with selector .timelineTrackCounterCanvas` ); const getTooltipContents = () => - document.querySelector('.timelineTrackBandwidthTooltip'); + document.querySelector('.timelineTrackCounterTooltip'); const getBandwidthDot = () => - container.querySelector('.timelineTrackBandwidthGraphDot'); + container.querySelector('.timelineTrackCounterGraphDot'); const moveMouseAtCounter = (index: number, pos: number) => fireEvent( canvas, diff --git a/src/test/components/TrackMemory.test.tsx b/src/test/components/TrackMemory.test.tsx index ab31d78e21..757293cd48 100644 --- a/src/test/components/TrackMemory.test.tsx +++ b/src/test/components/TrackMemory.test.tsx @@ -7,7 +7,7 @@ import { Provider } from 'react-redux'; import { fireEvent } from '@testing-library/react'; import { render } from 'firefox-profiler/test/fixtures/testing-library'; -import { TrackMemory } from '../../components/timeline/TrackMemory'; +import { TrackCounter } from '../../components/timeline/TrackCounter'; import { ensureExists } from '../../utils/types'; import { @@ -63,16 +63,25 @@ describe('TrackMemory', function () { ); const threadIndex = 0; const thread = profile.threads[threadIndex]; - profile.counters = [ - getCounterForThread(thread, threadIndex, counterConfig), - ]; + const counter = getCounterForThread(thread, threadIndex, counterConfig); + counter.category = 'Memory'; + counter.display = { + ...counter.display, + graphType: 'line-accumulated', + unit: 'bytes', + color: 'orange', + markerSchemaLocation: 'timeline-memory', + sortWeight: 20, + label: 'Memory', + }; + profile.counters = [counter]; const store = storeWithProfile(profile); const { getState, dispatch } = store; const flushRafCalls = mockRaf(); const renderResult = render( - + ); const { container } = renderResult; @@ -81,13 +90,13 @@ describe('TrackMemory', function () { flushRafCalls(); const canvas = ensureExists( - container.querySelector('.timelineTrackMemoryCanvas'), - `Couldn't find the memory canvas, with selector .timelineTrackMemoryCanvas` + container.querySelector('.timelineTrackCounterCanvas'), + `Couldn't find the memory canvas, with selector .timelineTrackCounterCanvas` ); const getTooltipContents = () => - document.querySelector('.timelineTrackMemoryTooltip'); + document.querySelector('.timelineTrackCounterTooltip'); const getMemoryDot = () => - container.querySelector('.timelineTrackMemoryGraphDot'); + container.querySelector('.timelineTrackCounterGraphDot'); const moveMouseAtCounter = (index: number, pos: number) => fireEvent( canvas, @@ -194,7 +203,7 @@ describe('TrackMemory with intersection observer', function () { const renderResult = render( - + ); diff --git a/src/test/components/TrackPower.test.tsx b/src/test/components/TrackPower.test.tsx index 402180cec9..c952a33f35 100644 --- a/src/test/components/TrackPower.test.tsx +++ b/src/test/components/TrackPower.test.tsx @@ -9,7 +9,7 @@ import { fireEvent } from '@testing-library/react'; import { render, screen } from 'firefox-profiler/test/fixtures/testing-library'; import { updatePreviewSelection } from 'firefox-profiler/actions/profile-view'; -import { TrackPower } from '../../components/timeline/TrackPower'; +import { TrackCounter } from '../../components/timeline/TrackCounter'; import { ensureExists } from '../../utils/types'; import { @@ -62,30 +62,36 @@ describe('TrackPower', function () { for (let i = 7; i < sampleTimes.length - 1; ++i) { sampleTimes[i] = 7 + i / 100; } - profile.counters = [ - getCounterForThreadWithSamples( - thread, - threadIndex, - { - time: sampleTimes.slice(), - // Power usage numbers. They are pWh so they are pretty big. - count: [ - 10000, 40000, 50000, 100000, 2000000, 5000000, 30000, 1000000, - 20000, 1, 12000, 100000, - ], - length: SAMPLE_COUNT, - }, - 'SystemPower', - 'power' - ), - ]; + const counter = getCounterForThreadWithSamples( + thread, + threadIndex, + { + time: sampleTimes.slice(), + // Power usage numbers. They are pWh so they are pretty big. + count: [ + 10000, 40000, 50000, 100000, 2000000, 5000000, 30000, 1000000, 20000, + 1, 12000, 100000, + ], + length: SAMPLE_COUNT, + }, + 'SystemPower', + 'power' + ); + counter.display = { + ...counter.display, + graphType: 'line-rate', + unit: 'pWh', + sortWeight: 30, + label: 'SystemPower', + }; + profile.counters = [counter]; const store = storeWithProfile(profile); const { getState, dispatch } = store; const flushRafCalls = mockRaf(); const renderResult = render( - + ); const { container } = renderResult; @@ -94,13 +100,13 @@ describe('TrackPower', function () { flushRafCalls(); const canvas = ensureExists( - container.querySelector('.timelineTrackPowerCanvas'), - `Couldn't find the power canvas, with selector .timelineTrackPowerCanvas` + container.querySelector('.timelineTrackCounterCanvas'), + `Couldn't find the power canvas, with selector .timelineTrackCounterCanvas` ); const getTooltipContents = () => document.querySelector('.timelineTrackPowerTooltip'); const getPowerDot = () => - container.querySelector('.timelineTrackPowerGraphDot'); + container.querySelector('.timelineTrackCounterGraphDot'); const moveMouseAtCounter = (index: number, pos: number) => fireEvent( canvas, diff --git a/src/test/components/TrackProcessCPU.test.tsx b/src/test/components/TrackProcessCPU.test.tsx index 0fc742f5e4..9772159547 100644 --- a/src/test/components/TrackProcessCPU.test.tsx +++ b/src/test/components/TrackProcessCPU.test.tsx @@ -7,7 +7,7 @@ import { Provider } from 'react-redux'; import { fireEvent } from '@testing-library/react'; import { render, screen } from 'firefox-profiler/test/fixtures/testing-library'; -import { TrackProcessCPU } from '../../components/timeline/TrackProcessCPU'; +import { TrackCounter } from '../../components/timeline/TrackCounter'; import { ensureExists } from '../../utils/types'; import { @@ -55,27 +55,33 @@ describe('TrackProcessCPU', function () { const sampleTimes = ensureExists(thread.samples.time); // Changing one of the sample times, so we can test different intervals. sampleTimes[1] = 1.5; // It was 1 before. - profile.counters = [ - getCounterForThreadWithSamples( - thread, - threadIndex, - { - time: sampleTimes.slice(), - // CPU usage numbers for the per-process CPU. - count: [100, 400, 500, 1000, 200, 500, 300, 100], - length: SAMPLE_COUNT, - }, - 'processCPU', - 'CPU' - ), - ]; + const counter = getCounterForThreadWithSamples( + thread, + threadIndex, + { + time: sampleTimes.slice(), + // CPU usage numbers for the per-process CPU. + count: [100, 400, 500, 1000, 200, 500, 300, 100], + length: SAMPLE_COUNT, + }, + 'processCPU', + 'CPU' + ); + counter.display = { + ...counter.display, + graphType: 'line-rate', + unit: 'percent', + sortWeight: 70, + label: 'Process CPU', + }; + profile.counters = [counter]; const store = storeWithProfile(profile); const { getState, dispatch } = store; const flushRafCalls = mockRaf(); const renderResult = render( - + ); const { container } = renderResult; @@ -84,13 +90,13 @@ describe('TrackProcessCPU', function () { flushRafCalls(); const canvas = ensureExists( - container.querySelector('.timelineTrackProcessCPUCanvas'), - `Couldn't find the process CPU canvas, with selector .timelineTrackProcessCPUCanvas` + container.querySelector('.timelineTrackCounterCanvas'), + `Couldn't find the process CPU canvas, with selector .timelineTrackCounterCanvas` ); const getTooltipContents = () => - document.querySelector('.timelineTrackProcessCPUTooltip'); + document.querySelector('.timelineTrackCounterTooltip'); const getProcessCPUDot = () => - container.querySelector('.timelineTrackProcessCPUGraphDot'); + container.querySelector('.timelineTrackCounterGraphDot'); const moveMouseAtCounter = (index: number, pos: number) => fireEvent( canvas, diff --git a/src/test/components/Viewport.test.tsx b/src/test/components/Viewport.test.tsx index b32f27be94..b0f822007f 100644 --- a/src/test/components/Viewport.test.tsx +++ b/src/test/components/Viewport.test.tsx @@ -17,8 +17,6 @@ import { getPreviewSelection, } from '../../selectors/profile'; -import { changeSidebarOpenState } from '../../actions/app'; - import explicitConnect from '../../utils/connect'; import { ensureExists } from '../../utils/types'; @@ -27,6 +25,7 @@ import { autoMockElementSize, setMockedElementSize, } from '../fixtures/mocks/element-size'; +import { triggerResizeObservers } from '../fixtures/mocks/resize-observer'; import { mockRaf } from '../fixtures/mocks/request-animation-frame'; import { storeWithProfile } from '../fixtures/stores'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; @@ -589,8 +588,8 @@ describe('Viewport', function () { }); }); - it('reacts to changes to the panel layout generation', function () { - const { dispatch, getChartViewport, flushRafCalls } = setup(); + it('reacts to container size changes', function () { + const { getChartViewport } = setup(); expect(getChartViewport()).toMatchObject({ containerWidth: BOUNDING_BOX_WIDTH, @@ -606,10 +605,7 @@ describe('Viewport', function () { ...INITIAL_ELEMENT_SIZE, width: BOUNDING_BOX_WIDTH - boundingWidthDiff, }); - act(() => { - dispatch(changeSidebarOpenState('calltree', true)); - }); - flushRafCalls(); + triggerResizeObservers(); expect(getChartViewport()).toMatchObject({ containerWidth: BOUNDING_BOX_WIDTH - boundingWidthDiff, diff --git a/src/test/components/__snapshots__/FlameGraph.test.tsx.snap b/src/test/components/__snapshots__/FlameGraph.test.tsx.snap index ee201e3c8d..9af19bd667 100644 --- a/src/test/components/__snapshots__/FlameGraph.test.tsx.snap +++ b/src/test/components/__snapshots__/FlameGraph.test.tsx.snap @@ -468,6 +468,24 @@ exports[`FlameGraph matches the snapshot 1`] = ` +
  • + +
  • diff --git a/src/test/components/__snapshots__/ProfileCallTreeView.test.tsx.snap b/src/test/components/__snapshots__/ProfileCallTreeView.test.tsx.snap index 937030aa27..7c4300f51d 100644 --- a/src/test/components/__snapshots__/ProfileCallTreeView.test.tsx.snap +++ b/src/test/components/__snapshots__/ProfileCallTreeView.test.tsx.snap @@ -109,6 +109,20 @@ exports[`ProfileCallTreeView with JS Allocations matches the snapshot for JS all Invert call stack +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +