| // Copyright (C) 2025 The Android Open Source Project | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| import m from 'mithril'; | |
| import {assertExists, assertIsInstance} from '../../base/assert'; | |
| import {Memo} from '../../base/memo'; | |
| import {maybeUndefined} from '../../base/utils'; | |
| import {TreeExplorerPanel} from '../../components/tree_explorer_panel'; | |
| import {TreeExplorerFetcher} from '../../components/tree_explorer_fetcher'; | |
| import type {Trace} from '../../public/trace'; | |
| import {Button} from '../../widgets/button'; | |
| import {Callout} from '../../widgets/callout'; | |
| import {CopyToClipboardButton} from '../../widgets/copy_to_clipboard_button'; | |
| import {EmptyState} from '../../widgets/empty_state'; | |
| import {HotkeyContext} from '../../widgets/hotkey_context'; | |
| import {Select} from '../../widgets/select'; | |
| import {Stack, StackAuto, StackFixed} from '../../widgets/stack'; | |
| import {updateTreeExplorerState} from '../../widgets/tree_explorer'; | |
| import type {AggregateProfile, AggregateProfilesPageState} from './types'; | |
| const HIDE_PAGE_EXPLANATION_KEY = 'hideAggregateProfilesPageExplanation'; | |
| const HIDE_VIEW_EXPLANATION_KEY = 'hideAggregateProfilesViewExplanation'; | |
| export interface AggregateProfilesPageAttrs { | |
| readonly trace: Trace; | |
| readonly state: AggregateProfilesPageState; | |
| readonly profiles: ReadonlyArray<AggregateProfile>; | |
| readonly onStateChange: (state: AggregateProfilesPageState) => void; | |
| } | |
| export class AggregateProfilesPage implements m.ClassComponent<AggregateProfilesPageAttrs> { | |
| // The fetcher (and so the virtual tables built for the metrics) is created | |
| // for the profile it serves and disposed by the memo when the user switches | |
| // profile, so at most one generation is alive at a time. | |
| private readonly fetcherMemo = new Memo<TreeExplorerFetcher>(); | |
| view({attrs}: m.CVnode<AggregateProfilesPageAttrs>): m.Children { | |
| // Use the selected profile from the state or just use the first one if none | |
| // supplied, or if we can't find a match. | |
| const selectedProfile = | |
| attrs.profiles.find((p) => p.id === attrs.state.selectedProfileId) ?? | |
| maybeUndefined(attrs.profiles[0]); | |
| if (selectedProfile === undefined) { | |
| return this.renderEmptyState(); | |
| } | |
| return m( | |
| HotkeyContext, | |
| { | |
| fillHeight: true, | |
| autoFocus: true, | |
| hotkeys: [ | |
| { | |
| hotkey: 'ArrowLeft', | |
| callback: () => this.stepProfile(attrs, -1), | |
| }, | |
| { | |
| hotkey: 'ArrowRight', | |
| callback: () => this.stepProfile(attrs, 1), | |
| }, | |
| ], | |
| }, | |
| m( | |
| Stack, | |
| { | |
| fillHeight: true, | |
| spacing: 'medium', | |
| className: 'pf-aggregate-profiles-page', | |
| }, | |
| [ | |
| this.shouldShowExplanation(HIDE_PAGE_EXPLANATION_KEY) && | |
| m(StackFixed, this.renderPageExplanation()), | |
| this.renderControlsRow(attrs, selectedProfile), | |
| this.shouldShowExplanation(HIDE_VIEW_EXPLANATION_KEY) && | |
| m(StackFixed, this.renderViewExplanation()), | |
| m(StackAuto, [this.renderFlamegraph(selectedProfile, attrs)]), | |
| ], | |
| ), | |
| ); | |
| } | |
| onremove(): void { | |
| this.fetcherMemo.dispose(); | |
| } | |
| private stepProfile(attrs: AggregateProfilesPageAttrs, step: number): void { | |
| if (attrs.profiles.length < 2) return; | |
| const cur = attrs.profiles.findIndex( | |
| (p) => p.id === attrs.state.selectedProfileId, | |
| ); | |
| const next = Math.max(cur, 0) + step; | |
| if (next >= 0 && next < attrs.profiles.length) { | |
| this.selectProfile(attrs, attrs.profiles[next]); | |
| } | |
| } | |
| private selectProfile( | |
| attrs: AggregateProfilesPageAttrs, | |
| profile: AggregateProfile, | |
| ): void { | |
| attrs.onStateChange({ | |
| selectedProfileId: profile.id, | |
| flamegraphState: updateTreeExplorerState( | |
| attrs.state.flamegraphState, | |
| profile.metrics, | |
| ), | |
| }); | |
| } | |
| private renderFlamegraph( | |
| selectedProfile: AggregateProfile, | |
| attrs: AggregateProfilesPageAttrs, | |
| ): m.Children { | |
| const fetcher = this.fetcherMemo.use({ | |
| key: {profileId: selectedProfile.id}, | |
| compute: () => | |
| new TreeExplorerFetcher(attrs.trace, selectedProfile.metrics), | |
| }); | |
| return m(TreeExplorerPanel, { | |
| fetcher, | |
| state: attrs.state.flamegraphState, | |
| onStateChange: (state) => { | |
| attrs.onStateChange({ | |
| ...attrs.state, | |
| flamegraphState: state, | |
| }); | |
| }, | |
| }); | |
| } | |
| private shouldShowExplanation(key: string): boolean { | |
| return localStorage.getItem(key) !== 'true'; | |
| } | |
| private dismissExplanation(key: string): void { | |
| localStorage.setItem(key, 'true'); | |
| } | |
| private showExplanation(key: string): void { | |
| localStorage.removeItem(key); | |
| } | |
| // The page's controls: the profile selector on the left, the help buttons | |
| // on the right. The view tabs are not here -- they live in the | |
| // TreeExplorerPanel's own switcher. | |
| private renderControlsRow( | |
| attrs: AggregateProfilesPageAttrs, | |
| selectedProfile: AggregateProfile, | |
| ): m.Children { | |
| const showViewHelp = !this.shouldShowExplanation(HIDE_VIEW_EXPLANATION_KEY); | |
| const showPageHelp = this.shouldShowExplanation(HIDE_PAGE_EXPLANATION_KEY); | |
| const showSelector = attrs.profiles.length > 1; | |
| if (!showViewHelp && !showPageHelp && !showSelector) { | |
| return undefined; | |
| } | |
| return m( | |
| StackFixed, | |
| m(Stack, {orientation: 'horizontal', spacing: 'medium'}, [ | |
| showSelector && | |
| m(StackFixed, this.renderProfileSelector(attrs, selectedProfile)), | |
| m(StackAuto), | |
| showViewHelp && | |
| m( | |
| StackFixed, | |
| m(Button, { | |
| label: 'About views', | |
| icon: 'help', | |
| compact: true, | |
| onclick: () => this.showExplanation(HIDE_VIEW_EXPLANATION_KEY), | |
| }), | |
| ), | |
| showPageHelp && m(StackFixed, this.renderPageHelpButton()), | |
| ]), | |
| ); | |
| } | |
| private renderPageHelpButton(): m.Children { | |
| return m(Button, { | |
| label: 'About page', | |
| icon: 'help', | |
| compact: true, | |
| onclick: () => this.showExplanation(HIDE_PAGE_EXPLANATION_KEY), | |
| }); | |
| } | |
| private renderPageExplanation(): m.Children { | |
| return m( | |
| Callout, | |
| { | |
| icon: 'help', | |
| dismissible: true, | |
| onDismiss: () => this.dismissExplanation(HIDE_PAGE_EXPLANATION_KEY), | |
| className: 'pf-aggregate-profiles-page__page-explanation', | |
| }, | |
| m( | |
| 'p', | |
| `This page shows aggregate profile analysis, complementing the timeline view. | |
| While the timeline visualizes events across time, this page aggregates | |
| samples from profiles (pprof, collapsed stack, etc.) in the trace.`, | |
| ), | |
| ); | |
| } | |
| private renderViewExplanation(): m.Children { | |
| return m( | |
| Callout, | |
| { | |
| icon: 'help', | |
| dismissible: true, | |
| onDismiss: () => this.dismissExplanation(HIDE_VIEW_EXPLANATION_KEY), | |
| className: 'pf-aggregate-profiles-page__view-explanation', | |
| }, | |
| m( | |
| 'p', | |
| `Flamegraphs display weighted tree structures where the x-axis shows | |
| proportion and y-axis shows hierarchy depth. Most commonly used for | |
| call stacks where each rectangle is a function and width shows CPU | |
| time or sample count. More generally, each rectangle represents a | |
| node (function, span, allocation site, etc.), helping identify | |
| hotspots in call stacks, span trees, heap dumps, and other | |
| hierarchical data.`, | |
| ), | |
| ); | |
| } | |
| private renderProfileSelector( | |
| attrs: AggregateProfilesPageAttrs, | |
| selectedProfile: AggregateProfile, | |
| ): m.Children { | |
| return m(Stack, {orientation: 'horizontal', spacing: 'small'}, [ | |
| m( | |
| 'label', | |
| {className: 'pf-aggregate-profiles-page__profile-label'}, | |
| 'Profile:', | |
| ), | |
| m( | |
| Select, | |
| { | |
| className: 'pf-aggregate-profiles-page__profile-select', | |
| oninput: (e: Event) => { | |
| assertIsInstance(e.target, HTMLSelectElement); | |
| const newProfileId = e.target.value; | |
| const newProfile = attrs.profiles.find( | |
| (p) => p.id === newProfileId, | |
| ); | |
| assertExists(newProfile); // Assume this profile actually exists | |
| this.selectProfile(attrs, newProfile); | |
| }, | |
| }, | |
| attrs.profiles.map((profile) => | |
| m( | |
| 'option', | |
| { | |
| value: profile.id, | |
| selected: selectedProfile.id === profile.id, | |
| }, | |
| profile.displayName, | |
| ), | |
| ), | |
| ), | |
| // The name is only in <option> text, which the mouse cannot select. | |
| m(CopyToClipboardButton, { | |
| textToCopy: () => selectedProfile.displayName, | |
| tooltip: 'Copy profile name', | |
| }), | |
| ]); | |
| } | |
| private renderEmptyState(): m.Children { | |
| return m( | |
| EmptyState, | |
| { | |
| icon: 'analytics', | |
| title: 'No Aggregate Profiles Available', | |
| fillHeight: true, | |
| className: 'pf-aggregate-profiles-page__empty', | |
| }, | |
| [ | |
| m( | |
| 'p', | |
| 'This trace contains no aggregate profiles. ' + | |
| 'Aggregate profiles (pprof, collapsed stack) can be captured using various profiling tools and imported into traces.', | |
| ), | |
| ], | |
| ); | |
| } | |
| } |