| /** |
| * @module module/explore-multi-sk |
| * @description <h2><code>explore-multi-sk</code></h2> |
| * |
| * Page of Perf for exploring data in multiple graphs. |
| * |
| * User is able to add multiple ExploreSimpleSk instances. The state reflector will |
| * only keep track of those properties necessary to add traces to each graph. All |
| * other settings, such as the point selected or the beginning and ending range, |
| * will be the same for all graphs. For example, passing ?plotSummary=true as a URI |
| * parameter will enable the mini-map summary for all graphs to be loaded. This is to prevent the |
| * URL from becoming too long and keeping the logic simple. |
| * |
| */ |
| import { html } from 'lit/html.js'; |
| import { define } from '../../../elements-sk/modules/define'; |
| import { |
| DEFAULT_RANGE_S, |
| ExploreSimpleSk, |
| State as ExploreState, |
| LabelMode, |
| } from '../explore-simple-sk/explore-simple-sk'; |
| import { GraphConfig, updateShortcut } from '../common/graph-config'; |
| import { PlotSelectionEventDetails } from '../plot-google-chart-sk/plot-google-chart-sk'; |
| import { load } from '@google-web-components/google-chart/loader'; |
| import { TestPickerSk } from '../test-picker-sk/test-picker-sk'; |
| |
| import { addParamSet, addParamsToParamSet, fromKey, queryFromKey } from '../paramtools'; |
| import { |
| ParamSet as QueryParamSet, |
| fromParamSet, |
| toParamSet, |
| } from '../../../infra-sk/modules/query'; |
| import { stateReflector } from '../../../infra-sk/modules/stateReflector'; |
| import { HintableObject } from '../../../infra-sk/modules/hintable'; |
| import { errorMessage } from '../errorMessage'; |
| import { ElementSk } from '../../../infra-sk/modules/ElementSk'; |
| import { StatusCodes } from 'http-status-codes'; |
| |
| const EXPLORE_MULTI_PAGE_SOURCE = 'explore-multi-sk'; |
| import { |
| AnomalyMap, |
| ColumnHeader, |
| FrameRequest, |
| FrameResponse, |
| ParamSet, |
| QueryConfig, |
| ReadOnlyParamSet, |
| RequestType, |
| TraceSet, |
| Trace, |
| TraceMetadata, |
| } from '../json'; |
| import { CountMetric, SummaryMetric, telemetry } from '../telemetry/telemetry'; |
| |
| import '../../../elements-sk/modules/spinner-sk'; |
| import '../../../elements-sk/modules/icons/help-icon-sk'; |
| import '../explore-simple-sk'; |
| import '../favorites-dialog-sk'; |
| import '../test-picker-sk'; |
| import '../../../golden/modules/pagination-sk/pagination-sk'; |
| import '../window/window'; |
| |
| import { LoggedIn } from '../../../infra-sk/modules/alogin-sk/alogin-sk'; |
| import { Status as LoginStatus } from '../../../infra-sk/modules/json'; |
| import { PaginationSkPageChangedEventDetail } from '../../../golden/modules/pagination-sk/pagination-sk'; |
| import { CommitLinks } from '../point-links-sk/point-links-sk'; |
| import { MISSING_VALUE_SENTINEL } from '../const/const'; |
| import { DataService, DataServiceError } from '../data-service'; |
| |
| const CACHE_KEY_EVEN_X_AXIS_SPACING = 'even_xaxis_spacing'; |
| |
| export class State { |
| begin: number = -1; |
| |
| end: number = -1; |
| |
| shortcut: string = ''; |
| |
| showZero: boolean = false; |
| |
| numCommits: number = 250; |
| |
| request_type: RequestType = 1; |
| |
| domain: 'commit' | 'date' = 'commit'; // The domain of the x-axis, either commit or date. |
| |
| pageSize: number = 30; |
| |
| pageOffset: number = 0; |
| |
| plotSummary: boolean = false; |
| |
| useTestPicker: boolean = false; |
| |
| highlight_anomalies: string[] = []; |
| |
| enable_chart_tooltip: boolean = false; |
| |
| show_remove_all: boolean = true; |
| |
| use_titles: boolean = false; |
| |
| xbaroffset: number = -1; |
| |
| splitByKeys: string[] = []; |
| |
| dayRange: number = -1; |
| |
| dateAxis: boolean = false; |
| |
| evenXAxisSpacing: 'true' | 'false' | 'use_cache' = 'use_cache'; |
| |
| // TODO(eduardoyap): Add support for secondary URL parameters (commit, trace, graph, json) |
| // via stateReflector to fully consolidate URL management in ExploreMultiSk. |
| |
| // TODO(eduardoyap): Handle browser history changes correctly in manual_plot_mode. |
| // TODO(eduardoyap): Ensure new graphs in manual_plot_mode sync time ranges. |
| manual_plot_mode: boolean = false; |
| } |
| |
| export class ExploreMultiSk extends ElementSk { |
| private allGraphConfigs: GraphConfig[] = []; |
| |
| private allFrameResponses: FrameResponse[] = []; |
| |
| private allFrameRequests: FrameRequest[] = []; |
| |
| private mainGraphSelectedRange: { begin: number; end: number } | null = null; |
| |
| private exploreElements: ExploreSimpleSk[] = []; |
| |
| private currentPageExploreElements: ExploreSimpleSk[] = []; |
| |
| private stateHasChanged: (() => void) | null = null; |
| |
| private _state: State = new State(); |
| |
| private _totalGraphs: number = 0; |
| |
| public get totalGraphs(): number { |
| return this._totalGraphs; |
| } |
| |
| public set totalGraphs(v: number) { |
| this._totalGraphs = v; |
| } |
| |
| private graphDiv: Element | null = null; |
| |
| private useTestPicker: boolean = false; |
| |
| private testPicker: TestPickerSk | null = null; |
| |
| private defaults: QueryConfig | null = null; |
| |
| private userEmail: string = ''; |
| |
| private _dataLoading: boolean = false; |
| |
| private progress: string = ''; |
| |
| private initialLoadStartTime: number = 0; |
| |
| private loadTrigger: string = ''; |
| |
| private setProgress(value: string) { |
| this.progress = value; |
| this._render(); |
| } |
| |
| private _onSplitByChanged = async (e: Event) => { |
| this._dataLoading = true; |
| this.testPicker?.setReadOnly(true); |
| const splitByParamKey: string = (e as CustomEvent).detail.param; |
| const split = (e as CustomEvent).detail.split; |
| if (!split) { |
| // No longer split so remove selected param from keys. |
| this.state.splitByKeys = this.state.splitByKeys.filter((key) => key !== splitByParamKey); |
| } else { |
| // Split by only a single key |
| // TODO(seawardt): Enable multiple splits |
| this.state.splitByKeys = [splitByParamKey]; |
| } |
| await this.splitGraphs(); |
| this.updateShortcutMultiview(); |
| }; |
| |
| private _onStateChangedInUrl = async (hintableState: HintableObject) => { |
| this.initialLoadStartTime = performance.now(); |
| this.loadTrigger = 'direct_link'; |
| const state = hintableState as unknown as State; |
| |
| // -- Domain Logic -- |
| const useDateAxis = state.dateAxis |
| ? state.dateAxis |
| : this.defaults?.default_xaxis_domain === 'date'; |
| state.domain = useDateAxis ? 'date' : 'commit'; |
| |
| // -- Time Range Logic -- |
| // Precedence: explicit begin/end > dayRange > component defaults. |
| const beginProvided = state.begin !== -1; |
| const endProvided = state.end !== -1; |
| const dayRangeProvided = state.dayRange !== -1; |
| |
| const now = Math.floor(Date.now() / 1000); |
| const defaultRangeS = this.defaults?.default_range || DEFAULT_RANGE_S; |
| |
| if (beginProvided || endProvided) { |
| // Scenario 1: begin and/or end are provided in the URL. |
| if (!beginProvided) { |
| state.begin = state.end - defaultRangeS; |
| } else if (!endProvided) { |
| state.end = state.begin + defaultRangeS; |
| if (state.end > now) state.end = now; |
| } else if (state.begin === state.end) { |
| // Scenario 1a: begin and end are provided but equal. |
| // This can happen if the user selected a single point in the UI and the URL |
| // invalidly persisted a zero-length range. |
| // We accept the "center" of the range as the intent and expand around it. |
| const halfRange = Math.floor(defaultRangeS / 2); |
| state.begin = state.begin - halfRange; |
| state.end = state.end + halfRange; |
| if (state.end > now) { |
| const shift = state.end - now; |
| state.end = now; |
| state.begin -= shift; |
| } |
| } |
| } else if (dayRangeProvided) { |
| // Scenario 2: dayRange is provided, begin/end are NOT. |
| state.end = now; |
| state.begin = now - state.dayRange * 24 * 60 * 60; |
| } else { |
| // Scenario 3: No time parameters in URL, use component defaults. |
| state.begin = now - defaultRangeS; |
| state.end = now; |
| } |
| |
| state.begin = Math.round(state.begin); |
| state.end = Math.round(state.end); |
| |
| const numElements = this.exploreElements.length; |
| let graphConfigs: GraphConfig[] = []; |
| if (state.shortcut !== '') { |
| const shortcutConfigs = (await this.getConfigsFromShortcut(state.shortcut)) ?? []; |
| graphConfigs = shortcutConfigs.map((c) => Object.assign(new GraphConfig(), c)); |
| } |
| |
| const validGraphs: GraphConfig[] = []; |
| if (state.splitByKeys.length > 0 && graphConfigs.length > 0) { |
| validGraphs.push(new GraphConfig()); |
| this.addEmptyGraph(); |
| } |
| for (let i = 0; i < graphConfigs.length; i++) { |
| if ( |
| graphConfigs[i].formulas.length > 0 || |
| graphConfigs[i].queries.length > 0 || |
| graphConfigs[i].keys !== '' |
| ) { |
| // Merge queries and formulas into the first graph if splitting. |
| if (state.splitByKeys.length > 0) { |
| // Ensure the master query exists and is a single string. |
| if (validGraphs[0].queries.length === 0) { |
| validGraphs[0].queries.push(''); |
| } |
| const aggregatedParams = new URLSearchParams(validGraphs[0].queries[0]); |
| |
| graphConfigs[i].queries.forEach((q) => { |
| const incomingParams = new URLSearchParams(q); |
| incomingParams.forEach((value, key) => { |
| // Check if this exact key-value pair already exists. |
| const existingValues = aggregatedParams.getAll(key); |
| if (!existingValues.includes(value)) { |
| aggregatedParams.append(key, value); |
| } |
| }); |
| }); |
| // URLSearchParams.toString() encodes spaces as '+', but '%20' is generally preferred |
| // and safer for consistent URL handling, so we replace them. |
| validGraphs[0].queries[0] = aggregatedParams.toString().replace(/\+/g, '%20'); |
| |
| // Handle formulas (simple duplicate check is fine here). |
| graphConfigs[i].formulas.forEach((f) => { |
| if (!validGraphs[0].formulas.includes(f)) { |
| validGraphs[0].formulas.push(f); |
| } |
| }); |
| |
| // Handle keys (simple duplicate check is fine here). |
| if (graphConfigs[i].keys && !validGraphs[0].keys.includes(graphConfigs[i].keys)) { |
| if (validGraphs[0].keys) { |
| validGraphs[0].keys += ' '; |
| } |
| validGraphs[0].keys += graphConfigs[i].keys; |
| } |
| } else { |
| if (i >= numElements) { |
| this.addEmptyGraph(); |
| } |
| validGraphs.push(graphConfigs[i]); |
| } |
| } |
| } |
| this.allGraphConfigs = validGraphs; |
| |
| // This loop removes graphs that are not in the current config. |
| // This can happen if you add a graph and then use the browser's back button. |
| while (this.exploreElements.length > this.allGraphConfigs.length) { |
| this.exploreElements.pop(); |
| // Ensure graphDiv exists and has children before removing. |
| if (this.graphDiv && this.graphDiv.lastChild) { |
| this.graphDiv.removeChild(this.graphDiv.lastChild); |
| } |
| } |
| |
| this.state = state; |
| if (state.useTestPicker) { |
| await this.initializeTestPicker(); |
| } |
| await load(); |
| |
| await this.renderCurrentPage(false); |
| // If a key is specified on initial load, we must wait for the |
| // shortcut's graphs to load their data before we can split them. |
| if (this.state.splitByKeys.length > 0 && this.exploreElements.length > 0) { |
| this.setProgress('Loading graphs...'); |
| this._dataLoading = true; |
| |
| const explore0 = this.exploreElements[0]; |
| |
| // We must wait for the component to fully render and fetch its initial data |
| // before we can split. We poll the loading state to avoid race conditions |
| // with the data-loaded event. |
| while (explore0.spinning || explore0.dataLoading) { |
| await new Promise((resolve) => window.requestAnimationFrame(resolve)); |
| } |
| |
| // Now that the component is hydrated and the initial data is loaded, |
| // dfRepo is guaranteed to exist. We now await the actual requestComplete |
| // promise to ensure any background "extended range" queries finish before |
| // we rip the graph apart. |
| await explore0.requestComplete; |
| |
| // Now that ALL data is loaded, we can split. |
| await this.splitGraphs(false); // showErrorIfLoading = false |
| |
| this.setProgress(''); |
| this.checkDataLoaded(); |
| } |
| |
| document.addEventListener('keydown', (e) => { |
| this.exploreElements.forEach((exp) => { |
| exp.keyDown(e); |
| }); |
| }); |
| }; |
| |
| // Event listener to remove the explore object from the list if the user |
| // close it in a Multiview window. |
| private _onRemoveExplore = (e: Event) => { |
| const detail = (e as CustomEvent).detail; |
| if (!detail || !detail.elem) { |
| console.warn('remove-explore event received without detail.elem; ignoring.'); |
| return; |
| } |
| const exploreElemToRemove = detail.elem as ExploreSimpleSk; |
| |
| if (this.state.manual_plot_mode) { |
| this.removeExplore(exploreElemToRemove); |
| this.updateShortcutMultiview(); |
| e.stopPropagation(); |
| return; |
| } |
| |
| this._dataLoading = true; |
| this.testPicker?.setReadOnly(true); |
| |
| if (this.exploreElements.length === 1) { |
| this.removeExplore(exploreElemToRemove); |
| this.updateShortcutMultiview(); |
| e.stopPropagation(); |
| } else { |
| const param = this.state.splitByKeys[0]; |
| if (exploreElemToRemove.state.queries.length > 0) { |
| const query = exploreElemToRemove.state.queries[0]; |
| const valueToRemove = new URLSearchParams(query).get(param); |
| if (valueToRemove) { |
| this.testPicker?.removeItemFromChart(param, [valueToRemove]); |
| } |
| } |
| } |
| }; |
| |
| // Event listener for when the Test Picker plot button is clicked. |
| // This will create a new empty Graph at the top and plot it with the |
| // selected test values. |
| // eslint-disable-next-line @typescript-eslint/no-unused-vars |
| private _onPlotButtonClicked = async (e: Event) => { |
| this._dataLoading = true; |
| this.testPicker?.setReadOnly(true); |
| this.setProgress('Loading graphs...'); |
| try { |
| this.initialLoadStartTime = performance.now(); |
| this.loadTrigger = 'plot_button_clicked'; |
| if (this.state.splitByKeys.length === 0) { |
| // Just load single graph. |
| // We add the graph to the config, but we don't use the returned element |
| // because renderCurrentPage will create a new one. |
| this.addEmptyGraph(true); |
| if (this.allGraphConfigs.length === 0) { |
| return; |
| } |
| if (this.exploreElements.length > 0 && this._dataLoading) { |
| await new Promise<void>((resolve) => { |
| const check = () => { |
| if (!this.exploreElements[0].spinning) { |
| resolve(); |
| } else { |
| setTimeout(check, 100); // Poll every 100ms. |
| } |
| }; |
| check(); |
| }); |
| } |
| const shouldPreserveExistingData = this.state.manual_plot_mode; |
| this.renderCurrentPage(shouldPreserveExistingData); |
| // Get the actual element that was just added to the DOM. |
| // Since we unshifted (true), it should be at index 0 of the current page. |
| const newExplore = this.currentPageExploreElements[0]; |
| const query = this.testPicker!.createQueryFromFieldData(); |
| |
| // Explicitly update the parent configuration with the query being plotted. |
| if (this.allGraphConfigs.length > 0) { |
| this.allGraphConfigs[0].queries = [query]; |
| } |
| |
| await newExplore.addFromQueryOrFormula(true, 'query', query, ''); |
| } else { |
| // Load multiple graphs, split by the selected split key. |
| // To improve UX, allow some interactivity before all the data is loaded. |
| // To achieve this, load everything in 2 steps: |
| // 1. Load all graphs, but only the selected range. This stage can be chunked, |
| // updates are incremental. |
| // 2. Load extended range data for all graphs. This is one huge request, but it |
| // allows to avoid any troubles with concurrency and merging. |
| |
| // Split the graphs before loading, so we can load each group separately. |
| const paramSet = this.testPicker!.createParamSetFromFieldData(); |
| const groups = this.groupParamSetBySplitKey(paramSet, this.state.splitByKeys); |
| |
| if (groups.length === 0) { |
| return; |
| } |
| |
| // The mainGraph (exploreElements[0]) will act as an accumulator for all queries. |
| // splitGraphs will then use its accumulated traceset to create the individual |
| // split graphs. |
| this.addEmptyGraph(true); |
| this.renderCurrentPage(false); |
| // Note: We use this.exploreElements[0] directly instead of a local variable |
| // because splitGraphs() recreates the graphs, invalidating any local reference. |
| if (!this.exploreElements[0]) { |
| return; |
| } |
| await this.exploreElements[0].requestComplete; |
| |
| const CHUNK_SIZE = 5; |
| const groupedToLoadInChunks = Math.min(this.state.pageSize, groups.length); |
| for (let i = 0; i < groupedToLoadInChunks; ) { |
| // The first chunk is always of size 1 - this is to avoid showing the primary |
| // graph / "unsplit" mode. |
| const chunkSize = CHUNK_SIZE; |
| const endGroupIndex = Math.min(i + chunkSize, groupedToLoadInChunks); |
| const chunk = groups.slice(i, endGroupIndex); |
| if (chunk.length === 0) { |
| break; // No more groups to process. |
| } |
| |
| this.setProgress(`Loading graphs ${i + 1}-${endGroupIndex} of ${groups.length}`); |
| await this.exploreElements[0].addFromQueryOrFormula( |
| /*replace=*/ false, |
| 'query', |
| fromParamSet(this.mergeParamSets(chunk)), |
| '', |
| '', |
| // Important! Do not load extended range data. Otherwise it produces a lot of |
| // queries fetching the same data + creates concurrency issues. |
| /*loadExtendedRange=*/ false |
| ); |
| await this.exploreElements[0].requestComplete; |
| await this.splitGraphs(/*showErrorIfLoading=*/ false, /*splitIfOnlyOneGraph=*/ true); |
| |
| i = endGroupIndex; |
| } |
| |
| // We were postponing loading more data until all the graphs are ready. Now it's time. |
| this.setProgress(`Loading more data for all graphs...`); |
| if (groups.length > groupedToLoadInChunks) { |
| // Note that we load all the graphs, even if they don't fit in one page. It slows down |
| // the initial load, but speeds up page navigation and "Load All Graphs". |
| await this.exploreElements[0].addFromQueryOrFormula( |
| /*replace=*/ true, |
| 'query', |
| fromParamSet(this.mergeParamSets(groups)), |
| '', |
| '', |
| /*loadExtendedRange=*/ true |
| ); |
| } else { |
| await this.exploreElements[0].loadExtendedRangeData(); |
| } |
| await this.exploreElements[0].requestComplete; |
| await this.splitGraphs(); |
| } |
| } catch (err: unknown) { |
| errorMessage(err as string, 0, { |
| countMetricSource: CountMetric.FrontendErrorReported, |
| source: EXPLORE_MULTI_PAGE_SOURCE, |
| errorCode: StatusCodes.INTERNAL_SERVER_ERROR.toString(), |
| }); |
| } finally { |
| this.updateShortcutMultiview(); |
| this.setProgress(''); |
| this.checkDataLoaded(); |
| } |
| |
| // Prevent auto-add trace behavior in independent mode |
| if (this.testPicker && !this.state.manual_plot_mode) { |
| this.testPicker.autoAddTrace = true; |
| } |
| }; |
| |
| // Event listener for when the Test Picker plot button is clicked. |
| // This will create a new empty Graph at the top and plot it with the |
| // selected test values. |
| // eslint-disable-next-line @typescript-eslint/no-unused-vars |
| private _onAddToGraph = async (e: Event) => { |
| this.initialLoadStartTime = performance.now(); |
| this.loadTrigger = 'picker_add_trace'; |
| const query = (e as CustomEvent).detail.query; |
| // Query is the same as the first graph, so do nothing. |
| if (this.allGraphConfigs.length > 0 && query === this.allGraphConfigs[0].queries[0]) { |
| return; |
| } |
| let explore: ExploreSimpleSk; |
| this._dataLoading = true; |
| this.testPicker?.setReadOnly(true); |
| if (this.currentPageExploreElements.length === 0) { |
| this.addEmptyGraph(true); |
| // We don't use the returned element, but rely on renderCurrentPage to create |
| // the element and put it in currentPageExploreElements. |
| if (this.allGraphConfigs.length > 0) { |
| if (this.exploreElements.length > 0 && this._dataLoading) { |
| await this.exploreElements[0].requestComplete; |
| } |
| this.renderCurrentPage(true); |
| // The newly added graph is at index 0. |
| explore = this.currentPageExploreElements[0]; |
| } else { |
| return; |
| } |
| } else { |
| // Reset pagination and re-render to ensure we are targeting the main graph (index 0). |
| // This collapses any existing split view back to a single master graph. |
| this.state.pageOffset = 0; |
| |
| this.exploreElements.splice(1); |
| this.allGraphConfigs.splice(1); |
| this.allFrameRequests.splice(1); |
| this.allFrameResponses.splice(1); |
| this.totalGraphs = this.exploreElements.length; |
| |
| // Ensure the main graph is in the DOM so it can process data. |
| await this.renderCurrentPage(false); |
| explore = this.currentPageExploreElements[0]; |
| if (!explore) { |
| return; |
| } |
| explore.state.doNotQueryData = false; |
| } |
| await explore.addFromQueryOrFormula(true, 'query', query, '', '', true); |
| |
| if (this.state.splitByKeys.length > 0) { |
| await this.splitGraphs(); |
| } |
| |
| // Ensure the master config explicitly has the query before we save, |
| // protecting us from any race conditions where splitGraphs might have |
| // cleared it due to pending DOM updates. |
| if (this.allGraphConfigs.length > 0) { |
| this.allGraphConfigs[0].queries = [query]; |
| } |
| |
| this.updateShortcutMultiview(); |
| }; |
| |
| private _onRemoveTrace = async (e: Event) => { |
| try { |
| const param = (e as CustomEvent).detail.param as string; |
| const values = (e as CustomEvent).detail.value as string[]; |
| const query = (e as CustomEvent).detail.query as string[]; |
| |
| if (values.length === 0) { |
| this.resetGraphs(); |
| this.checkDataLoaded(); |
| return; |
| } |
| this._dataLoading = true; |
| this.testPicker?.setReadOnly(true); |
| |
| // Capture the split state before yielding, as a subsequent split-by-changed event |
| // (triggered by the same action) might update the state while we are yielded. |
| const currentSplitByKeys = [...this.state.splitByKeys]; |
| |
| // Yield to the browser to update the UI |
| await new Promise((resolve) => setTimeout(resolve, 0)); |
| |
| const traceSet = this.getCompleteTraceset(); |
| const tracesToRemove: string[] = []; |
| const queriesToRemove: string[] = []; |
| |
| // Check through all existing TraceSets and find matches. |
| Object.keys(traceSet).forEach((trace) => { |
| const traceParams = fromKey(trace); |
| const traceVal = traceParams[param] || MISSING_VALUE_SENTINEL; |
| if (values.includes(traceVal)) { |
| // Load remove array and delete from existing traceSet. |
| tracesToRemove.push(trace); |
| let query = queryFromKey(trace); |
| if (this.defaults?.include_params) { |
| const paramSet = toParamSet(query); |
| const filteredParamSet = ParamSet({}); |
| const includeParams = this.defaults.include_params; |
| for (const key in paramSet) { |
| if (includeParams.includes(key)) { |
| filteredParamSet[key] = paramSet[key]; |
| } |
| } |
| query = fromParamSet(filteredParamSet); |
| } |
| queriesToRemove.push(query); |
| delete traceSet[trace]; |
| } |
| }); |
| |
| if (Object.keys(traceSet).length === 0) { |
| this.emptyCurrentPage(); |
| await this.checkDataLoaded(); // Ensure readonly is reset |
| return; |
| } |
| |
| // Remove the traces from the current page explore elements. |
| const elemsToRemove: ExploreSimpleSk[] = []; |
| |
| const updatePromises = this.exploreElements.map((elem, index) => { |
| // If the graph has no queries AND no data, it is a zombie and should be removed. |
| // We only remove split graphs (index > 0). The main graph (index 0) should persist. |
| const hasTraces = elem.getTraceset() && Object.keys(elem.getTraceset()!).length > 0; |
| if (index > 0 && (!elem.state.queries || elem.state.queries.length === 0) && !hasTraces) { |
| elemsToRemove.push(elem); |
| return Promise.resolve(); |
| } |
| |
| const hasQueryToRemove = |
| elem.state.queries && queriesToRemove.some((qr) => elem.state.queries.includes(qr)); |
| |
| const traceset = elem.getTraceset() as TraceSet; |
| const hasTraceToRemove = traceset && tracesToRemove.some((key) => traceset[key]); |
| |
| // Only proceed with updates if the element is affected. |
| if ( |
| hasQueryToRemove || |
| (elem.state.queries?.length && query !== undefined) || |
| hasTraceToRemove |
| ) { |
| elem.state.doNotQueryData = true; |
| let keysWereRemoved = false; |
| if (elem.state.queries.length > 0 && queriesToRemove.length > 0) { |
| const queryCount = elem.state.queries.length; |
| // Remove any queries that match queriesToRemove. |
| elem.state.queries = elem.state.queries.filter((q) => !queriesToRemove.includes(q)); |
| // If queries were removed, we definitely need to remove keys. |
| if (elem.state.queries.length !== queryCount) { |
| keysWereRemoved = true; |
| } |
| } |
| let shouldRemoveGraph = false; |
| |
| // When one query exists, check if param/value matches and replace with new query. |
| if (elem.state.queries.length === 1 && !keysWereRemoved) { |
| const queryToRemove = Array.isArray(query) ? query[0] : query; |
| // If we are splitting by this param, we should remove the graph (clear queries) |
| // instead of updating it to the new master query. |
| // Prioritize the state passed from the event (source of truth). |
| let isSplitParam = (e as CustomEvent).detail.isSplit; |
| |
| if (isSplitParam === undefined) { |
| isSplitParam = currentSplitByKeys.includes(param); |
| } |
| |
| values.forEach((v) => { |
| const queryStr = elem.state.queries[0]; |
| let match = false; |
| if (queryStr) { |
| match = queryStr.includes(`${param}=${v}`); |
| if (!match) { |
| const p = new URLSearchParams(queryStr); |
| if (p.get(param) === v) { |
| match = true; |
| } |
| } |
| } |
| |
| if (match || queryStr === queryToRemove || !queryStr) { |
| if (isSplitParam) { |
| shouldRemoveGraph = true; |
| |
| elem.state.queries = []; |
| } else { |
| elem.state.queries = [queryToRemove]; |
| } |
| keysWereRemoved = true; |
| } |
| }); |
| } |
| |
| if ( |
| !shouldRemoveGraph && |
| (keysWereRemoved || tracesToRemove.some((key) => traceset[key])) |
| ) { |
| elem.removeKeys(tracesToRemove, false); |
| const newTraceset = elem.getTraceset(); |
| if (!newTraceset || Object.keys(newTraceset).length === 0) { |
| elem.state.queries = []; |
| } |
| } |
| if (index > 0 && (elem.state.queries.length === 0 || shouldRemoveGraph)) { |
| elemsToRemove.push(elem); |
| return Promise.resolve(); |
| } |
| const elemTraceset = elem.getTraceset() as TraceSet; |
| const elemHeader = elem.getHeader(); |
| // Default to filtering the global set by the element's existing keys (minus |
| // removed ones). This ensures we preserve the split state of the graph while |
| // getting fresh data. |
| let updatedTraceset = TraceSet({}); |
| Object.keys(elemTraceset).forEach((key) => { |
| if ( |
| traceSet[key] && |
| !tracesToRemove.includes(key) && |
| this.shouldKeepTrace(key, param, values) |
| ) { |
| updatedTraceset[key] = Trace(traceSet[key]); |
| } |
| }); |
| let headerToUse = this.getHeader(); |
| |
| // We can compare the data length of a common trace to determine which set is "better". |
| // We pick the first key from the local traceset that isn't being removed. |
| const commonKey = Object.keys(elemTraceset).find((key) => |
| this.shouldKeepTrace(key, param, values) |
| ); |
| |
| if (commonKey && traceSet[commonKey] && elemTraceset[commonKey]) { |
| if (elemTraceset[commonKey].length > traceSet[commonKey].length) { |
| // Local data is better. Filter out the removed trace from the local set. |
| const filteredLocalTraceset = TraceSet({}); |
| Object.keys(elemTraceset).forEach((key) => { |
| if (this.shouldKeepTrace(key, param, values)) { |
| filteredLocalTraceset[key] = elemTraceset[key]; |
| } |
| }); |
| updatedTraceset = filteredLocalTraceset; |
| headerToUse = elemHeader; |
| } |
| } |
| |
| const params: ParamSet = ParamSet({}); |
| Object.keys(updatedTraceset).forEach((trace) => { |
| addParamsToParamSet(params, fromKey(trace)); |
| }); |
| |
| // If we filtered the traceset but didn't update the queries (because the |
| // removal logic didn't match), we should update the queries to reflect the |
| // remaining data. This prevents future fetches from bringing back the |
| // removed trace and ensures consistency. |
| // We only do this if we are not clearing the graph. |
| if (Object.keys(updatedTraceset).length > 0 && !keysWereRemoved) { |
| // We construct a new query from the remaining params. |
| // However, we should respect the include_params defaults if possible, to |
| // avoid over-specifying. But here we want to be safe and match the data. |
| // Let's use the params derived from the traceset. |
| const newQuery = fromParamSet(params); |
| elem.state.queries = [newQuery]; |
| } |
| |
| // Check if any remaining trace is missing the parameter or has an empty value. |
| // If so, we need to disable parent trace filtering on the backend. |
| const hasMissingParam = Object.keys(updatedTraceset).some((key) => { |
| const p = fromKey(key); |
| return !p[param] || p[param] === ''; |
| }); |
| |
| // Update the graph with the new traceSet and params. |
| const updatedRequest: FrameRequest = { |
| queries: elem.state.queries, |
| request_type: this.state.request_type, |
| begin: this.state.begin, |
| end: this.state.end, |
| tz: '', |
| disable_filter_parent_traces: |
| elem.state.queries.some((q) => q.includes(MISSING_VALUE_SENTINEL)) || hasMissingParam, |
| }; |
| const updatedResponse: FrameResponse = { |
| dataframe: { |
| traceset: updatedTraceset, |
| header: headerToUse ? [...headerToUse] : null, |
| paramset: ReadOnlyParamSet(params), |
| skip: 0, |
| traceMetadata: ExploreSimpleSk.getTraceMetadataFromCommitLinks( |
| Object.keys(updatedTraceset), |
| elem.getCommitLinks() |
| ), |
| }, |
| anomalymap: this.getAnomalyMapForTraces( |
| this.getFullAnomalyMap(), |
| Object.keys(updatedTraceset) |
| ), |
| display_mode: 'display_plot', |
| msg: '', |
| }; |
| this.allFrameResponses[index] = updatedResponse; |
| this.allFrameRequests[index] = updatedRequest; |
| return elem.UpdateWithFrameResponse( |
| updatedResponse, |
| updatedRequest, |
| true, |
| null, // Force dynamic recalculation using correct timestamps for this specific graph's header |
| false, |
| true |
| ); |
| } |
| return Promise.resolve(); |
| }); |
| |
| await Promise.all(updatePromises); |
| |
| // Update the configs BEFORE removing any graphs, because removeExplore() |
| // triggers a re-render that relies on allGraphConfigs having the latest queries. |
| // If we don't update this first, removeExplore -> renderCurrentPage will overwrite |
| // the correctly updated elem.state.queries with the old ones from allGraphConfigs. |
| this.exploreElements.forEach((elem, i) => { |
| if (this.allGraphConfigs[i]) { |
| this.allGraphConfigs[i].queries = elem.state.queries ?? []; |
| } |
| }); |
| |
| // This should be outside the map, so it always runs after promises. |
| elemsToRemove.forEach((elem) => { |
| this.removeExplore(elem); |
| }); |
| |
| this.updateShortcutMultiview(); |
| |
| await this.checkDataLoaded(); |
| } catch (error) { |
| console.error(error); |
| // Ensure we reset loading state on error |
| this._dataLoading = false; |
| this.testPicker?.setReadOnly(false); |
| } |
| }; |
| |
| /** |
| * Helper to check if a trace should be kept based on the removal criteria. |
| * Returns true if the trace does NOT match the parameter and value being removed. |
| */ |
| private shouldKeepTrace(key: string, param: string, values: string[]): boolean { |
| const traceParams = fromKey(key); |
| // For info on default key behavior, see: go/pdeio-perf-default-key-doc |
| const traceVal = traceParams[param] || MISSING_VALUE_SENTINEL; |
| return !values.includes(traceVal); |
| } |
| |
| // Event listener for when the "Query Highlighted" button is clicked. |
| // It will populate the Test Picker with the keys from the highlighted |
| // trace. |
| private _onPopulateQuery = async (e: Event) => { |
| await this.populateTestPicker((e as CustomEvent).detail); |
| }; |
| |
| // Event listener for when the "Even X Axis Spacing" toggle is clicked. |
| // It will sync the state across all the graphs. |
| private _onEvenXAxisSpacingChanged = (e: Event) => { |
| const detail = (e as CustomEvent).detail; |
| localStorage.setItem(CACHE_KEY_EVEN_X_AXIS_SPACING, String(detail.value)); |
| |
| this.exploreElements.forEach((elem) => { |
| if (elem !== e.target) { |
| elem.setUseDiscreteAxis(detail.value); |
| } |
| }); |
| }; |
| |
| constructor() { |
| super(ExploreMultiSk.template); |
| } |
| |
| async connectedCallback() { |
| super.connectedCallback(); |
| |
| this._render(); |
| |
| this.graphDiv = this.querySelector('#graphContainer'); |
| this.testPicker = this.querySelector('#test-picker'); |
| |
| await this.initializeDefaults(); |
| this.stateHasChanged = stateReflector( |
| () => this.state as unknown as HintableObject, |
| this._onStateChangedInUrl, |
| /*replaceState=*/ true |
| ); |
| |
| LoggedIn() |
| .then((status: LoginStatus) => { |
| this.userEmail = status.email; |
| this._render(); |
| }) |
| .catch((msg) => |
| errorMessage(msg, 0, { |
| countMetricSource: CountMetric.FrontendErrorReported, |
| source: EXPLORE_MULTI_PAGE_SOURCE, |
| errorCode: StatusCodes.UNAUTHORIZED.toString(), |
| }) |
| ); |
| } |
| |
| private canAddFav(): boolean { |
| return this.userEmail !== null && this.userEmail !== ''; |
| } |
| |
| public async toggleV2Mode(): Promise<void> { |
| let shortcut = ''; |
| if (this.allGraphConfigs.length > 0) { |
| this.setProgress('Creating shortcut...'); |
| try { |
| shortcut = await updateShortcut(this.allGraphConfigs); |
| if (shortcut) { |
| this.state.shortcut = shortcut; |
| this.stateHasChanged!(); |
| } |
| } catch (e) { |
| console.error('Failed to create shortcut for toggleV2Mode:', e); |
| } finally { |
| this.setProgress(''); |
| } |
| } |
| |
| const urlParams = new URLSearchParams(); |
| if (shortcut) { |
| urlParams.set('shortcut', shortcut); |
| } |
| if (this.state.splitByKeys && this.state.splitByKeys.length > 0) { |
| urlParams.set('split', this.state.splitByKeys.join(',')); |
| } |
| if (this.state.begin && this.state.begin !== -1) { |
| urlParams.set('begin', this.state.begin.toString()); |
| } |
| if (this.state.end && this.state.end !== -1) { |
| urlParams.set('end', this.state.end.toString()); |
| } |
| |
| const nextSearch = urlParams.toString(); |
| localStorage.setItem('perf:use-explore-v2', 'true'); |
| this.redirect(`/e2${nextSearch ? `?${nextSearch}` : ''}`); |
| } |
| |
| // Visible for testing |
| public redirect(url: string) { |
| window.location.href = url; |
| } |
| |
| private static template = (ele: ExploreMultiSk) => html` |
| <div id="menu"> |
| <div class="title-container"> |
| <div class="title-left"> |
| <h1>MultiGraph Menu</h1> |
| <a |
| href="https://skia.googlesource.com/buildbot/+/refs/heads/main/perf/multigraph-guide.md" |
| target="_blank" |
| title="MultiGraph Guide"> |
| <help-icon-sk></help-icon-sk> |
| </a> |
| </div> |
| <div |
| class="v2-toggle-container" |
| style="display: inline-flex; align-items: center; gap: 12px; border-radius: 8px; padding: 8px 16px; border: 1px solid var(--outline, rgba(255,255,255,0.1)); background-color: rgba(128,128,128,0.05);"> |
| <span style="font-size: 12px; font-weight: 600; color: var(--on-background, #cbd5e1);" |
| >Explore Multi V2 (Prototype):</span |
| > |
| <button |
| @click=${ele.toggleV2Mode} |
| style="background: #475569; color: white; border: none; padding: 4px 16px; border-radius: 12px; font-size: 11px; font-weight: bold; cursor: pointer; transition: all 0.2s;"> |
| OPT-IN (Switch to V2) |
| </button> |
| </div> |
| </div> |
| <spinner-sk id="spinner"></spinner-sk> |
| <test-picker-sk id="test-picker" class="hidden"></test-picker-sk> |
| ${ele.progress |
| ? html` |
| <div class="progress-container"> |
| <spinner-sk id="spinner" active></spinner-sk> |
| <span class="progress">${ele.progress}</span> |
| </div> |
| ` |
| : ''} |
| </div> |
| <hr /> |
| |
| <div id="pagination"> |
| <pagination-sk |
| offset=${ele.state.pageOffset} |
| page_size=${ele.state.pageSize} |
| total=${ele.totalGraphs} |
| @page-changed=${ele.pageChanged}> |
| </pagination-sk> |
| |
| ${ele.totalGraphs < 10 |
| ? '' |
| : html` |
| <label> |
| <span class="prefix">Charts per page</span> |
| <input |
| @change=${ele.pageSizeChanged} |
| type="number" |
| .value="${ele.state.pageSize.toString()}" |
| min="1" |
| max="50" |
| title="The number of charts per page." /> |
| </label> |
| <button @click=${ele.loadAllCharts}>Load All Charts</button> |
| `} |
| |
| <div |
| id="graphContainer" |
| @range-changing-in-multi=${ele.syncExtendRange} |
| @selection-range-changed=${ele.syncChartSelection} |
| @x-axis-toggled=${ele.syncXAxisLabel} |
| @even-x-axis-spacing-changed=${ele._onEvenXAxisSpacingChanged}></div> |
| <pagination-sk |
| offset=${ele.state.pageOffset} |
| page_size=${ele.state.pageSize} |
| total=${ele.totalGraphs} |
| @page-changed=${ele.pageChanged}> |
| </pagination-sk> |
| <div id="bottom-spacer"></div> |
| </div> |
| `; |
| |
| /** |
| * Fetch defaults from backend. |
| * |
| * Defaults are used in multiple ways by downstream elements: |
| * - TestPickerSk uses include_params to initialize only the fields |
| * specified and in the given order. |
| * - ExploreSimpleSk and TestPickerSk use default_param_selections to |
| * apply default param values to queries before making backend |
| * requests. |
| */ |
| private async initializeDefaults() { |
| try { |
| this.defaults = await DataService.getInstance().getDefaults(); |
| } catch (error: unknown) { |
| console.error('Error fetching defaults:', error); |
| const e = error as { message?: string }; |
| errorMessage(`Failed to load default configuration: ${e.message || error}`, 0, { |
| countMetricSource: CountMetric.FrontendErrorReported, |
| source: EXPLORE_MULTI_PAGE_SOURCE, |
| errorCode: StatusCodes.INTERNAL_SERVER_ERROR.toString(), |
| }); |
| this.defaults = null; |
| } |
| |
| if (this.defaults !== null) { |
| if ( |
| this.defaults.default_url_values !== undefined && |
| this.defaults.default_url_values !== null |
| ) { |
| const defaultKeys = Object.keys(this.defaults.default_url_values); |
| if ( |
| defaultKeys !== null && |
| defaultKeys !== undefined && |
| defaultKeys.indexOf('useTestPicker') > -1 |
| ) { |
| const stringToBool = function (str: string): boolean { |
| return str.toLowerCase() === 'true'; |
| }; |
| this.state.useTestPicker = stringToBool(this.defaults!.default_url_values.useTestPicker); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Splits a ParamSet into multiple ParamSets based on the values of a given key. |
| * This is analogous to groupTracesByParamKey, but operates on a ParamSet |
| * instead of a list of trace IDs. |
| * |
| * For example, given a ParamSet: |
| * { |
| * "a": ["x", "y"], |
| * "b": ["z"], |
| * } |
| * and a split key of "a", the function will return an array of two ParamSets: |
| * [ |
| * { "a": ["x"], "b": ["z"] }, |
| * { "a": ["y"], "b": ["z"] } |
| * ] |
| * |
| * @param paramSet The QueryParamSet to split. |
| * @param splitByKeys The key(s) to split by. Currently, only the first key is used. |
| * @returns An array of ParamSets, each representing a group. |
| */ |
| private groupParamSetBySplitKey(paramSet: QueryParamSet, splitByKeys: string[]): QueryParamSet[] { |
| if (splitByKeys.length === 0 || Object.keys(paramSet).length === 0) { |
| return [paramSet]; |
| } |
| |
| // Only handle the first split key for now. |
| const splitKey = splitByKeys[0]; |
| const splitValues = paramSet[splitKey]; |
| if (!splitValues || splitValues.length <= 1) { |
| return [paramSet]; |
| } |
| |
| const groups: ParamSet[] = []; |
| splitValues.forEach((value) => { |
| const newGroup: ParamSet = ParamSet({ ...paramSet }); |
| // Override the split key to have only the current single value. |
| newGroup[splitKey] = [value]; |
| groups.push(newGroup); |
| }); |
| return groups; |
| } |
| |
| private mergeParamSets(paramSets: QueryParamSet[]): QueryParamSet { |
| const merged: QueryParamSet = {}; |
| for (const currentObject of paramSets) { |
| for (const key in currentObject) { |
| if (merged[key]) { |
| merged[key] = [...new Set([...merged[key], ...currentObject[key]])]; |
| } else { |
| merged[key] = currentObject[key]; |
| } |
| } |
| } |
| return merged; |
| } |
| |
| /** |
| * Groups all the traces on the current multi graph view based on the |
| * key selected in the SplitBy dropdown. |
| * @returns A map where the key is the value of the split by param and |
| * the value is a list of traceIds grouped by that value. |
| */ |
| private groupTracesBySplitKey(): Map<string, string[]> { |
| const splitKeys = this.state.splitByKeys; |
| const traceset: string[] = []; |
| this.getTracesets().forEach((ts) => { |
| traceset.push(...ts); |
| }); |
| |
| return this.groupTracesByParamKey(traceset, splitKeys); |
| } |
| |
| /** |
| * groupTracesByParamKey returns a map where the key is the paramValue (for the given param key) |
| * and the value is the group of traces matching that param value. |
| * @param traceset Set of traces to split. |
| * @param key Param key to base the split on. |
| */ |
| private groupTracesByParamKey(traceset: string[], keys: string[]): Map<string, string[]> { |
| const groupedTraces = new Map<string, string[]>(); |
| if (traceset.length > 0) { |
| // If there are no keys, then pass in empty string to group everything. |
| const keysToUse = keys.length === 0 ? [''] : keys; |
| traceset.forEach((traceId) => { |
| const traceParams = new URLSearchParams(fromKey(traceId)); |
| keysToUse.forEach((key) => { |
| const splitValue = traceParams.get(key); |
| const existingGroup = groupedTraces.get(splitValue!) ?? []; |
| if (!existingGroup.includes(traceId)) { |
| existingGroup.push(traceId); |
| } |
| groupedTraces.set(splitValue!, existingGroup); |
| }); |
| }); |
| } |
| return groupedTraces; |
| } |
| |
| /** |
| * Creates a FrameRequest object. |
| * |
| * @param traces - An optional array of trace IDs. If provided, |
| * queries will be generated from these traces. |
| * Otherwise, the queries from the first graph configuration will be used. |
| * @returns A FrameRequest object. |
| */ |
| private createFrameRequest(traces?: string[]): FrameRequest { |
| const queries: string[] = []; |
| if (traces) { |
| traces.forEach((trace) => { |
| queries.push(queryFromKey(trace)); |
| }); |
| } else { |
| // Use allGraphConfigs since it holds all graph configurations. |
| // Index 0 corresponds to the main/primary graph. |
| if (this.allGraphConfigs.length > 0 && this.allGraphConfigs[0].queries) { |
| queries.push(...this.allGraphConfigs[0].queries); |
| } |
| } |
| |
| let begin = this.state.begin; |
| let end = this.state.end; |
| if (begin === -1 || end === -1) { |
| const now = Math.floor(Date.now() / 1000); |
| begin = now - DEFAULT_RANGE_S; |
| end = now; |
| } |
| |
| const request: FrameRequest = { |
| queries: queries, |
| request_type: this.state.request_type, |
| begin: begin, |
| end: end, |
| tz: '', |
| }; |
| return request; |
| } |
| |
| /** |
| * Creates a FrameResponse object. |
| * |
| * @param traces - An optional array of trace IDs to filter the response. |
| * If not provided, the response |
| * will include all traces from all graphs. |
| * @returns A FrameResponse object. |
| */ |
| private createFrameResponse(traces?: string[]): FrameResponse { |
| const fullTraceSet = this.getCompleteTraceset(); |
| const header = this.getHeader(); |
| const mainParams: ParamSet = ParamSet({}); |
| const fullAnomalyMap = this.getFullAnomalyMap(); |
| |
| Object.keys(fullTraceSet).forEach((trace) => { |
| addParamsToParamSet(mainParams, fromKey(trace)); |
| }); |
| |
| // Use primary explore element for main chart. |
| let traceset = fullTraceSet as TraceSet; |
| let paramset = mainParams; |
| const commitLinks = this.exploreElements[0].getCommitLinks(); |
| |
| let traceMetadata: TraceMetadata[]; |
| let anomalyMap: AnomalyMap; |
| // If passing in traces, then create child specific requests per trace. |
| if (traces) { |
| const traceSet: TraceSet = TraceSet({}); |
| const paramSet: ParamSet = ParamSet({}); |
| traces.forEach((trace) => { |
| traceSet[trace] = Trace(fullTraceSet[trace]); |
| addParamsToParamSet(paramSet, fromKey(trace)); |
| }); |
| traceset = traceSet; |
| paramset = paramSet; |
| traceMetadata = ExploreSimpleSk.getTraceMetadataFromCommitLinks(traces, commitLinks); |
| anomalyMap = this.getAnomalyMapForTraces(fullAnomalyMap, traces); |
| } else { |
| // collect data for all traces. |
| traceMetadata = ExploreSimpleSk.getTraceMetadataFromCommitLinks( |
| Object.keys(fullTraceSet), |
| commitLinks |
| ); |
| anomalyMap = this.getAnomalyMapForTraces(fullAnomalyMap, Object.keys(fullTraceSet)); |
| } |
| |
| const response: FrameResponse = { |
| dataframe: { |
| traceset: traceset, |
| header: header, |
| paramset: ReadOnlyParamSet(paramset), |
| skip: 0, |
| traceMetadata: traceMetadata, |
| }, |
| anomalymap: anomalyMap, |
| display_mode: 'display_plot', |
| msg: '', |
| }; |
| return response; |
| } |
| |
| /** |
| * Splits the graphs based on the split by dropdown selection. |
| */ |
| private async splitGraphs( |
| _showErrorIfLoading: boolean = true, |
| splitIfOnlyOneGraph: boolean = false |
| ): Promise<void> { |
| const groupedTraces = this.groupTracesBySplitKey(); |
| // If no traces, we usually return. But if we are unsplitting (keys empty), |
| // we must proceed to clear the split graphs and show the single main graph. |
| if (groupedTraces.size === 0 && this.state.splitByKeys.length > 0) { |
| this.checkDataLoaded(); |
| return; |
| } |
| |
| if (this.totalGraphs === 1) { |
| // If there is only one graph with no split or only one trace, then do nothing. |
| const groupedLength = Array.from(groupedTraces.values()).reduce( |
| (sum, v) => sum + v.length, |
| 0 |
| ); |
| // We removed 'this.state.splitByKeys.length === 0' because if we are transitioning |
| // to Unsplit (length 0), we MUST proceed to clear the split graph (totalGraphs=1). |
| if (!splitIfOnlyOneGraph && groupedLength === 1) { |
| await this.checkDataLoaded(); |
| return; |
| } |
| } |
| |
| /* TODO(crbug/447196357): Remove or re-enable if unable to fix loading state bug. |
| if (this.exploreElements.length > 0 && this._dataLoading === true) { |
| if (showErrorIfLoading) { |
| errorMessage('Data is still loading, please wait...', 3000); |
| } |
| await this.exploreElements[0].requestComplete; |
| } |
| */ |
| |
| this.mainGraphSelectedRange = this.exploreElements[0].getSelectedRange(); |
| |
| // Create the main graph config containing all trace data. |
| const mainRequest: FrameRequest = this.createFrameRequest(); |
| const mainResponse: FrameResponse = this.createFrameResponse(); |
| |
| // Pre-calculate requests and responses for all groups BEFORE clearing graphs. |
| // clearGraphs() wipes this.exploreElements, which createFrameResponse depends on for data. |
| const splitData = |
| this.state.splitByKeys.length === 0 |
| ? [] |
| : Array.from(groupedTraces.values()).map((traces) => ({ |
| traces, |
| request: this.createFrameRequest(traces), |
| response: this.createFrameResponse(traces), |
| })); |
| |
| this.allFrameRequests = [mainRequest]; |
| this.allFrameResponses = [mainResponse]; |
| |
| this.clearGraphs(); |
| |
| // Add the main graph back so it occupies index 0. |
| this.addEmptyGraph(); |
| // Ensure the main graph config (index 0) has the queries for all data. |
| if (this.allGraphConfigs.length > 0) { |
| this.allGraphConfigs[0].queries = mainRequest.queries || []; |
| } |
| |
| // Create the graph configs for each group using pre-calculated data. |
| splitData.forEach(({ request, response }, i) => { |
| this.addEmptyGraph(); |
| |
| const graphConfig = new GraphConfig(); |
| graphConfig.queries = request.queries ?? []; |
| // Main graph config is always at index 0. |
| this.allGraphConfigs[i + 1] = graphConfig; |
| |
| this.allFrameRequests.push(request); |
| this.allFrameResponses.push(response); |
| }); |
| |
| // Now add the graphs that have been configured to the page. |
| this.renderCurrentPage(); |
| |
| await this.checkDataLoaded(); |
| } |
| |
| /** |
| * Initialize TestPickerSk only if include_params has been specified. |
| * |
| * If so, hide the default "Add Graph" button and display the Test Picker. |
| */ |
| private async initializeTestPicker() { |
| const testPickerParams = this.defaults?.include_params ?? null; |
| if (testPickerParams !== null) { |
| this.useTestPicker = true; |
| this.testPicker!.classList.remove('hidden'); |
| let defaultParams = this.defaults?.default_param_selections ?? {}; |
| if (window.perf.remove_default_stat_value) { |
| defaultParams = {}; |
| } |
| |
| const readOnly = this.state.manual_plot_mode ? false : this.exploreElements.length > 0; |
| |
| // Pass the manual_plot_mode flag to force manual plotting if true. |
| this.testPicker!.initializeTestPicker( |
| testPickerParams!, |
| defaultParams, |
| readOnly, |
| this.state.manual_plot_mode |
| ); |
| this._render(); |
| } |
| |
| this.removeEventListener('remove-explore', this._onRemoveExplore); |
| this.addEventListener('remove-explore', this._onRemoveExplore); |
| |
| this.removeEventListener('plot-button-clicked', this._onPlotButtonClicked); |
| this.addEventListener('plot-button-clicked', this._onPlotButtonClicked); |
| |
| this.removeEventListener('split-by-changed', this._onSplitByChanged); |
| this.addEventListener('split-by-changed', this._onSplitByChanged); |
| |
| this.removeEventListener('add-to-graph', this._onAddToGraph); |
| this.addEventListener('add-to-graph', this._onAddToGraph); |
| |
| this.removeEventListener('remove-trace', this._onRemoveTrace); |
| this.addEventListener('remove-trace', this._onRemoveTrace); |
| |
| this.removeEventListener('populate-query', this._onPopulateQuery); |
| this.addEventListener('populate-query', this._onPopulateQuery); |
| } |
| |
| private async populateTestPicker(paramSet: { [key: string]: string[] }) { |
| const paramSets: ParamSet = ParamSet({}); |
| |
| const timeoutMs = 50000; // Timeout for waiting for non-empty tracesets. |
| const pollIntervalMs = 500; // Interval to re-check. |
| |
| const backfillMissingParams = (params: any, isParamSet: boolean = false) => { |
| if (this.defaults?.include_params) { |
| this.defaults.include_params.forEach((key) => { |
| if (params[key] === undefined) { |
| params[key] = isParamSet ? [MISSING_VALUE_SENTINEL] : MISSING_VALUE_SENTINEL; |
| } |
| }); |
| } |
| return params; |
| }; |
| |
| // Create a promise that resolves when the tracesets are ready. |
| // This checks if all explore elements have reported a traceset, |
| // and at least one of those tracesets contains actual trace strings. |
| const tracesetsReadyPromise = new Promise<string[][]>((resolve) => { |
| const checkTracesets = () => { |
| const currentTracesets = this.getTracesets(); |
| if ( |
| currentTracesets.length === this.exploreElements.length && |
| currentTracesets.some((ts) => ts.length > 0) |
| ) { |
| resolve(currentTracesets); |
| } else { |
| setTimeout(checkTracesets, pollIntervalMs); |
| } |
| }; |
| checkTracesets(); // Start checking immediately |
| }); |
| |
| // Create a timeout promise. |
| const timeoutPromise = new Promise<string[][]>((_, reject) => { |
| setTimeout(() => reject(new Error('Getting Tracesets timed out.')), timeoutMs); |
| }); |
| |
| if (this.state.manual_plot_mode) { |
| addParamSet(paramSets, backfillMissingParams(paramSet, true)); |
| } else { |
| let allTracesets: string[][]; |
| try { |
| allTracesets = await Promise.race([tracesetsReadyPromise, timeoutPromise]); |
| } catch (error: unknown) { |
| const e = error as { message?: string }; |
| errorMessage(e.message || 'An unknown error occurred while getting tracesets.', 0, { |
| countMetricSource: CountMetric.FrontendErrorReported, |
| source: EXPLORE_MULTI_PAGE_SOURCE, |
| errorCode: StatusCodes.INTERNAL_SERVER_ERROR.toString(), |
| }); |
| return; |
| } |
| |
| allTracesets.forEach((traceset) => { |
| traceset.forEach((trace) => { |
| const params = fromKey(trace); |
| addParamsToParamSet(paramSets, backfillMissingParams(params)); |
| }); |
| }); |
| } |
| |
| await this.testPicker!.populateFieldDataFromParamSet( |
| paramSets, |
| paramSet, |
| this.state.splitByKeys |
| ); |
| this.testPicker!.setReadOnly(false); |
| this.testPicker!.scrollIntoView(); |
| } |
| |
| private removeExploreEvent = (e: Event) => { |
| this.removeExplore((e as CustomEvent).detail as ExploreSimpleSk); |
| this.updateShortcutMultiview(); |
| }; |
| |
| private removeExplore(elem: ExploreSimpleSk | null = null): void { |
| const indexToRemove = this.exploreElements.findIndex((e) => e === elem); |
| |
| if (indexToRemove > -1) { |
| this.exploreElements.splice(indexToRemove, 1); |
| this.allGraphConfigs.splice(indexToRemove, 1); |
| this.allFrameRequests.splice(indexToRemove, 1); |
| this.allFrameResponses.splice(indexToRemove, 1); |
| // Re-index the remaining graphs. This ensures that the graph_index property |
| // in each element's state correctly reflects its position in the exploreElements array, |
| // which is important for syncing actions between graphs. |
| this._reindexGraphs(); |
| |
| // Adjust pagination: if there are no graphs left, reset page offset to 0. |
| if (this.exploreElements.length === 0) { |
| this.state.pageOffset = 0; |
| this.totalGraphs = 0; |
| this.testPicker!.autoAddTrace = false; |
| this.resetGraphs(); |
| this.emptyCurrentPage(); |
| } else if (this.state.pageSize > 0) { |
| // If graphs remain and pageSize is valid, calculate the maximum valid page offset. |
| // This prevents being on a page that no longer exists |
| // (e.g., if the last item on the last page was removed). |
| // Note: renderCurrentPage will recalculate state.totalGraphs. |
| const effectiveTotalGraphs = this.state.manual_plot_mode |
| ? this.exploreElements.length |
| : this.exploreElements.length - 1; |
| const numPages = Math.ceil(Math.max(0, effectiveTotalGraphs) / this.state.pageSize); |
| const maxValidPageOffset = Math.max(0, (numPages - 1) * this.state.pageSize); |
| this.state.pageOffset = Math.min(this.state.pageOffset, maxValidPageOffset); |
| this.renderCurrentPage(); |
| } |
| } else { |
| if (this.stateHasChanged) this.stateHasChanged(); |
| this.renderCurrentPage(); |
| } |
| } |
| |
| private resetGraphs() { |
| this.emptyCurrentPage(); |
| this.currentPageExploreElements = []; |
| this.allGraphConfigs = []; |
| this.exploreElements = []; |
| this.allFrameRequests = []; |
| this.allFrameResponses = []; |
| } |
| |
| private clearGraphs() { |
| this.allGraphConfigs = []; |
| this.exploreElements = []; |
| } |
| |
| private emptyCurrentPage(): void { |
| this.graphDiv!.replaceChildren(); |
| this.currentPageExploreElements = []; |
| } |
| |
| private async renderCurrentPage(doNotQueryData: boolean = true): Promise<void> { |
| // Logic: In Standard Mode (not manual), if we have multiple graphs, |
| // the first one (Index 0) is the "Summary" and is hidden from pagination. |
| const isSummaryView = !this.state.manual_plot_mode && this.allGraphConfigs.length > 1; |
| |
| if (isSummaryView) { |
| this.totalGraphs = this.allGraphConfigs.length - 1; |
| } else { |
| // In manual mode, or if there is only 1 graph, we count everything. |
| this.totalGraphs = this.allGraphConfigs.length || 1; |
| } |
| |
| this.emptyCurrentPage(); |
| const indexShift = isSummaryView ? 1 : 0; |
| const startIndex = this.state.pageOffset + indexShift; |
| |
| let endIndex = startIndex + this.state.pageSize - 1; |
| if (this.allGraphConfigs.length <= endIndex) { |
| endIndex = this.allGraphConfigs.length - 1; |
| } |
| |
| const fragment = document.createDocumentFragment(); |
| |
| // Always render the main graph (index 0) if it exists, to ensure it's connected and can load |
| // data. |
| // In summary view (startIndex > 0), we hide it. |
| if (this.allGraphConfigs.length > 0 && startIndex > 0) { |
| let explore = this.exploreElements[0]; |
| if (!explore) { |
| explore = this.createExploreSimpleSk(); |
| this.exploreElements[0] = explore; |
| } |
| explore.style.display = 'none'; |
| |
| const graphConfig = this.allGraphConfigs[0]; |
| const hasResponse = |
| !!this.allFrameResponses[0] || |
| (explore && Object.keys(explore.getTraceset() || {}).length > 0); |
| // In summary view, manual_plot_mode is false, so we don't need the complex check. |
| const shouldQueryDataForThisGraph = !doNotQueryData && !hasResponse; |
| |
| this.addStateToExplore(explore, graphConfig, !shouldQueryDataForThisGraph, 0); |
| fragment.appendChild(explore); |
| } |
| |
| for (let i = startIndex; i <= endIndex; i++) { |
| const graphConfig = this.allGraphConfigs[i]; |
| if (!graphConfig) { |
| continue; |
| } |
| // Ensure exploreElements has an entry for this index. |
| let explore = this.exploreElements[i]; |
| if (!explore) { |
| explore = this.createExploreSimpleSk(); |
| this.exploreElements[i] = explore; |
| } |
| explore.style.display = ''; |
| |
| // If we already have a response for this graph, we don't need to query data. |
| const hasResponse = |
| !!this.allFrameResponses[i] || |
| (explore && Object.keys(explore.getTraceset() || {}).length > 0); |
| |
| // Logic: |
| // 1. Default: Query if we are allowed to (doNotQueryData is false) AND we don't have a |
| // response yet. |
| // 2. Manual Mode Override: If we are adding a graph (doNotQueryData is true), we DO want to |
| // query |
| // for the main graph (i === 0) because it's the new one and has no response yet. |
| const shouldQueryDataForThisGraph = this.state.manual_plot_mode |
| ? !doNotQueryData || (i === 0 && !hasResponse) |
| : !doNotQueryData && !hasResponse; |
| |
| this.currentPageExploreElements.push(explore); |
| this.addStateToExplore(explore, graphConfig, !shouldQueryDataForThisGraph, i); |
| fragment.appendChild(explore); |
| } |
| |
| this.graphDiv!.appendChild(fragment); |
| this.updateChartHeights(); |
| this._render(); |
| this.checkDataLoaded(); |
| |
| if (this.state.manual_plot_mode && doNotQueryData) { |
| return; |
| } |
| |
| // Now that the elements are in the DOM, we can update them with data. |
| // This avoids race conditions where the element is not yet connected/rendered |
| // and thus the DataFrameRepository is not available. |
| // We use requestAnimationFrame to ensure the lit-html render cycle has completed |
| // and refs are populated. |
| return await new Promise((resolve) => { |
| window.requestAnimationFrame(async () => { |
| try { |
| const updatePromises: Promise<void>[] = []; |
| |
| // Update hidden accumulator if present (startIndex > 0) |
| if (this.allGraphConfigs.length > 0 && startIndex > 0) { |
| const i = 0; |
| const explore = this.exploreElements[0]; |
| if (explore) { |
| const isDefaultResponse = !this.allFrameResponses[i]; |
| const frameResponse = this.allFrameResponses[i] || this.createFrameResponse(); |
| const frameRequest = this.allFrameRequests[i] || this.createFrameRequest(); |
| |
| if ((explore as any).updateComplete) { |
| await (explore as any).updateComplete; |
| } |
| updatePromises.push( |
| explore.UpdateWithFrameResponse( |
| frameResponse, |
| frameRequest, |
| false, |
| this.mainGraphSelectedRange, |
| false, |
| !isDefaultResponse |
| ) |
| ); |
| } |
| } |
| |
| for (let i = startIndex; i <= endIndex; i++) { |
| // Calculate the index in currentPageExploreElements (0-based for the current page) |
| const pageIndex = i - startIndex; |
| const explore = this.currentPageExploreElements[pageIndex]; |
| if (!explore) { |
| continue; |
| } |
| const isDefaultResponse = !this.allFrameResponses[i]; |
| // If we don't have a cached response and the graph is already fetching |
| // its own data, skip this update to avoid triggering "No data found" errors. |
| if (isDefaultResponse && !explore.state.doNotQueryData) { |
| continue; |
| } |
| const frameResponse = this.allFrameResponses[i] || this.createFrameResponse(); |
| const frameRequest = this.allFrameRequests[i] || this.createFrameRequest(); |
| |
| // Wait for the element to be ready. |
| if ((explore as any).updateComplete) { |
| await (explore as any).updateComplete; |
| } |
| |
| updatePromises.push( |
| explore.UpdateWithFrameResponse( |
| frameResponse, |
| frameRequest, |
| false, |
| this.mainGraphSelectedRange, |
| false, |
| !isDefaultResponse |
| ) |
| ); |
| } |
| await Promise.all(updatePromises); |
| } catch (error) { |
| console.error('Error updating charts:', error); |
| } finally { |
| this.checkDataLoaded(); |
| resolve(); |
| } |
| }); |
| }); |
| } |
| |
| private updateChartHeights(): void { |
| window.requestAnimationFrame(() => { |
| if (!this.isConnected || !this.graphDiv) { |
| return; |
| } |
| const graphs = this.graphDiv.querySelectorAll('explore-simple-sk'); |
| const visibleGraphCount = Array.from(graphs).filter( |
| (g) => (g as HTMLElement).style.display !== 'none' |
| ).length; |
| const height = visibleGraphCount === 1 ? '500px' : '250px'; |
| |
| graphs.forEach((graph) => { |
| (graph as ExploreSimpleSk).updateChartHeight(height); |
| }); |
| }); |
| } |
| |
| private async syncExtendRange(e: CustomEvent<PlotSelectionEventDetails>): Promise<void> { |
| if (this._dataLoading) { |
| return; |
| } |
| const graphs = this.exploreElements; |
| const offset = e.detail.offsetInSeconds; |
| const range = e.detail.value; |
| |
| if (!offset) { |
| return; |
| } |
| |
| const promises = graphs.map(async (graph) => { |
| try { |
| await graph.extendRange(range, offset); |
| } catch (error) { |
| errorMessage(`Error in graph.extendRange: ${error}`, 0, { |
| countMetricSource: CountMetric.FrontendErrorReported, |
| source: EXPLORE_MULTI_PAGE_SOURCE, |
| errorCode: StatusCodes.INTERNAL_SERVER_ERROR.toString(), |
| }); |
| } |
| }); |
| await Promise.allSettled(promises); |
| } |
| |
| private async syncChartSelection(e: CustomEvent<PlotSelectionEventDetails>): Promise<void> { |
| if (this._dataLoading) { |
| return; |
| } |
| const graphs = this.exploreElements; |
| if (!e.detail.value) { |
| return; |
| } |
| |
| this.mainGraphSelectedRange = e.detail.value; |
| |
| if (graphs.length > 1 && e.detail.offsetInSeconds !== undefined) { |
| await graphs[0].extendRange(e.detail.value, e.detail.offsetInSeconds); |
| } |
| // Default behavior for non-split views or for pan/zoom actions. |
| graphs.forEach((graph, i) => { |
| // only update graph that isn't selected |
| if (i !== e.detail.graphNumber && e.detail.offsetInSeconds === undefined) { |
| (graph as ExploreSimpleSk).updateSelectedRangeWithPlotSummary( |
| e.detail.value, |
| e.detail.start ?? 0, |
| e.detail.end ?? 0 |
| ); |
| } |
| }); |
| // If in multigraph view, sync the plotSummary dfRepo on other graphs. |
| graphs.forEach(async (graph, i) => { |
| if (i !== e.detail.graphNumber && e.detail.offsetInSeconds !== undefined) |
| await graph.requestComplete; // Wait for load then update |
| }); |
| |
| // Unconditionally update the parent state from the event payload. |
| // This makes ExploreMultiSk the single source of truth for the URL |
| // via stateReflector, working across split, single, manual, and non-manual modes. |
| let newBegin = e.detail.value.begin; |
| let newEnd = e.detail.value.end; |
| |
| // If the domain is commit, the selection comes as offsets (e.g. 100, 101). |
| // We need to convert these to timestamps (e.g. 1687875573, 1687875574) because the state.begin |
| // and state.end (and thus the URL) always expect timestamps for request_type=0. |
| if (e.detail.domain === 'commit') { |
| const graphIndex = this.state.manual_plot_mode ? (e.detail.graphNumber ?? 0) : 0; |
| const header = this.exploreElements[graphIndex].getHeader(); |
| if (header) { |
| if (e.detail.start !== undefined && header[e.detail.start]) { |
| newBegin = header[e.detail.start]!.timestamp; |
| } |
| if (e.detail.end !== undefined && header[e.detail.end]) { |
| newEnd = header[e.detail.end]!.timestamp; |
| } |
| } |
| } |
| |
| if (newBegin !== this.state.begin) { |
| this.state.begin = newBegin; |
| } |
| if (newEnd !== this.state.end) { |
| this.state.end = newEnd; |
| } |
| if (this.stateHasChanged) { |
| this.stateHasChanged(); |
| } |
| } |
| |
| private syncXAxisLabel(e: CustomEvent): void { |
| this.exploreElements.forEach((graph) => { |
| // Skip graph that sent the event. |
| if (graph.state.graph_index !== e.detail.index) { |
| graph.updateXAxis(e.detail.domain); |
| } |
| }); |
| } |
| |
| private addStateToExplore( |
| explore: ExploreSimpleSk, |
| graphConfig: GraphConfig, |
| doNotQueryData: boolean, |
| index: number |
| ) { |
| const newState: ExploreState = { |
| formulas: graphConfig.formulas || [], |
| queries: graphConfig.queries || [], |
| keys: graphConfig.keys || '', |
| begin: this.state.begin, |
| end: this.state.end, |
| showZero: this.state.showZero, |
| numCommits: this.state.numCommits, |
| xbaroffset: explore.state.xbaroffset, |
| requestType: this.state.request_type, |
| pivotRequest: explore.state.pivotRequest, |
| sort: explore.state.sort, |
| selected: explore.state.selected, |
| horizontal_zoom: explore.state.horizontal_zoom, |
| incremental: false, |
| domain: this.state.domain, // Always use the domain from ExploreMultiSk's state |
| labelMode: LabelMode.Date, |
| disable_filter_parent_traces: explore.state.disable_filter_parent_traces, |
| plotSummary: this.state.plotSummary, |
| highlight_anomalies: this.state.highlight_anomalies, |
| enable_chart_tooltip: this.state.enable_chart_tooltip, |
| show_remove_all: this.state.show_remove_all, |
| use_titles: this.state.use_titles, |
| useTestPicker: this.state.useTestPicker, |
| use_test_picker_query: false, |
| enable_favorites: this.canAddFav(), |
| hide_paramset: true, |
| graph_index: index, |
| doNotQueryData: doNotQueryData, |
| evenXAxisSpacing: |
| this.state.evenXAxisSpacing !== 'use_cache' |
| ? this.state.evenXAxisSpacing === 'true' |
| : localStorage.getItem(CACHE_KEY_EVEN_X_AXIS_SPACING) === 'true', |
| }; |
| explore.state = newState; |
| } |
| |
| private _reindexGraphs(): void { |
| this.exploreElements.forEach((elem, index) => { |
| elem.state.graph_index = index; |
| }); |
| } |
| |
| private addEmptyGraph(unshift?: boolean): ExploreSimpleSk { |
| const explore = this.createExploreSimpleSk(); |
| const graphConfig = new GraphConfig(); |
| if (unshift) { |
| this.exploreElements.unshift(explore); |
| this.allGraphConfigs.unshift(graphConfig); |
| } else { |
| this.exploreElements.push(explore); |
| this.allGraphConfigs.push(graphConfig); |
| } |
| this._reindexGraphs(); |
| return explore; |
| } |
| |
| private createExploreSimpleSk(): ExploreSimpleSk { |
| const explore: ExploreSimpleSk = new ExploreSimpleSk(this.useTestPicker); |
| explore.defaults = this.defaults; |
| explore.openQueryByDefault = false; |
| explore.navOpen = false; |
| // If multi chart has user email, set it for the explore. |
| if (this.userEmail) { |
| explore.user = this.userEmail; |
| } |
| explore.addEventListener('data-loaded', () => { |
| this.checkDataLoaded(); |
| }); |
| |
| return explore; |
| } |
| |
| private checkDataLoaded(): void { |
| if (this.progress) { |
| return; |
| } |
| |
| if (this.testPicker) { |
| // CHANGE: Only sync graph state back to picker if NOT manual_plot_mode |
| if (!this.state.manual_plot_mode) { |
| const triggeredByPicker = |
| this.loadTrigger === 'plot_button_clicked' || this.loadTrigger === 'picker_add_trace'; |
| if (!this.testPicker.isLoaded() && this.exploreElements.length > 0 && !triggeredByPicker) { |
| this.populateTestPicker(this.exploreElements[0].getParamSet()); |
| } |
| } |
| |
| if (this.exploreElements.length === 0) { |
| this._dataLoading = false; |
| } |
| if (this.exploreElements.some((e) => e.dataLoading)) { |
| this._dataLoading = true; |
| this.testPicker.setReadOnly(true); |
| } else { |
| // Only record telemetry if a load was explicitly triggered (direct link or plot button). |
| // Resetting initialLoadStartTime and loadTrigger prevents duplicate telemetry reports. |
| if (this.initialLoadStartTime > 0 && this.loadTrigger) { |
| telemetry.recordSummary( |
| SummaryMetric.MultiGraphDataLoadTime, |
| (performance.now() - this.initialLoadStartTime) / 1000, |
| { url: window.location.href } |
| ); |
| telemetry.increaseCounter(CountMetric.MultiGraphVisit, { |
| trigger: this.loadTrigger, |
| total_graphs: this.totalGraphs.toString(), |
| }); |
| this.initialLoadStartTime = 0; |
| this.loadTrigger = ''; |
| } |
| this._dataLoading = false; |
| this.testPicker.setReadOnly(false); |
| } |
| } |
| } |
| |
| public get state(): State { |
| return this._state; |
| } |
| |
| public set state(v: State) { |
| this._state = v; |
| } |
| |
| /** |
| * Get the trace keys for each graph formatted in a 2D string array. |
| * |
| * In the case of formulas, we extract the base key, since all the operations |
| * we will do on these keys (adding to shortcut store, merging into single graph, etc.) |
| * are not applicable to function strings. Currently does not support query-based formulas |
| * (e.g. count(filter("a=b"))). |
| * |
| * TODO(@eduardoyap): add support for query-based formulas. |
| * |
| * Example output given that we're displaying 2 graphs: |
| * |
| * [ |
| * [ |
| * ",a=1,b=2,c=3,", |
| * ",a=1,b=2,c=4," |
| * ], |
| * [ |
| * ",a=1,b=2,c=5," |
| * ] |
| * ] |
| * |
| * @returns {string[][]} - Trace keys. |
| */ |
| private getTracesets(): string[][] { |
| const tracesets: string[][] = []; |
| |
| this.exploreElements.forEach((elem) => { |
| const traceset: string[] = []; |
| |
| const formula_regex = new RegExp(/\((,[^)]+,)\)/); |
| // Tracesets include traces from Queries and Keys. Traces |
| // from formulas are wrapped around a formula string. |
| const elemTraceSet = elem.getTraceset(); // This returns { [key: string]: number[] } | null |
| if (elemTraceSet) { |
| Object.keys(elemTraceSet).forEach((key) => { |
| if (key[0] === ',') { |
| traceset.push(key); |
| } else { |
| const match = formula_regex.exec(key); |
| if (match) { |
| // If it's a formula, extract the base key |
| traceset.push(match[1]); |
| } |
| } |
| }); |
| } |
| // Always push the traceset for this element, even if it's empty. |
| // This ensures that the length of 'tracesets' matches the number of 'exploreElements' |
| // once all elements have reported their tracesets (even if empty). |
| tracesets.push(traceset); |
| }); |
| return tracesets; |
| } |
| |
| /** |
| * getHeader returns the columnheader header of the first explore element. |
| * @returns |
| */ |
| private getHeader(): (ColumnHeader | null)[] | null { |
| if (this.exploreElements.length === 0) { |
| return null; |
| } |
| return this.exploreElements[0].getHeader(); |
| } |
| |
| /** |
| * getCompleteTraceset returns the full traceset consisting of all the tracesets |
| * in all explore elements in the current page. |
| * @returns |
| */ |
| private getCompleteTraceset(): { [key: string]: number[] } { |
| const fullTraceSet: { [key: string]: number[] } = {}; |
| |
| this.exploreElements.forEach((elem) => { |
| const headerLength = elem.getHeader()?.length; |
| // Check that header lengths are the same, otherwise ignore. |
| if (headerLength === this.getHeader()?.length) { |
| const exploreTraceSet = elem.getTraceset(); |
| if (!exploreTraceSet) { |
| return; |
| } |
| for (const [key, trace] of Object.entries(exploreTraceSet)) { |
| fullTraceSet[key] = trace; |
| } |
| } |
| }); |
| |
| return fullTraceSet; |
| } |
| |
| /** |
| * getAllCommitLinks returns all commit links across all explore elements. |
| * @returns |
| */ |
| private getAllCommitLinks(): (CommitLinks | null)[] { |
| const commitLinks: (CommitLinks | null)[] = []; |
| this.exploreElements.forEach((elem) => { |
| const elemLinks = elem.getCommitLinks(); |
| if (elemLinks.length > 0) { |
| commitLinks.push(...elemLinks); |
| } |
| }); |
| |
| return commitLinks; |
| } |
| |
| private getFullAnomalyMap(): AnomalyMap { |
| const anomalyMap: AnomalyMap = {}; |
| this.exploreElements.forEach((elem) => { |
| const anomalies = elem.getAnomalyMap(); |
| Object.keys(anomalies!).forEach((traceId) => { |
| const existingEntry = anomalyMap[traceId]; |
| if (!existingEntry) { |
| anomalyMap[traceId] = {}; |
| } |
| |
| const commitMap = anomalies![traceId]; |
| Object.keys(commitMap!).forEach((commitnumber) => { |
| anomalyMap[traceId]![parseInt(commitnumber!)] = commitMap![parseInt(commitnumber!)]; |
| }); |
| }); |
| }); |
| return anomalyMap; |
| } |
| |
| private getAnomalyMapForTraces(fullAnomalyMap: AnomalyMap, traces: string[]): AnomalyMap { |
| const anomalyMap: AnomalyMap = {}; |
| traces.forEach((trace) => { |
| anomalyMap[trace] = fullAnomalyMap![trace]; |
| }); |
| |
| return anomalyMap; |
| } |
| |
| /** |
| * Fetches the Graph Configs in the DB for a given shortcut ID. |
| * |
| * @param {string} shortcut - shortcut ID to look for in the GraphsShortcut table. |
| * @returns - List of Graph Configs matching the shortcut ID in the GraphsShortcut table |
| * or undefined if the ID doesn't exist. |
| */ |
| private async getConfigsFromShortcut(shortcut: string): Promise<GraphConfig[]> { |
| return (await DataService.getInstance() |
| .getShortcut(shortcut) |
| .catch((msg) => { |
| if (msg instanceof DataServiceError) { |
| errorMessage(msg.message, 0, { |
| countMetricSource: CountMetric.FrontendErrorReported, |
| source: EXPLORE_MULTI_PAGE_SOURCE, |
| errorCode: msg.status!.toString(), |
| }); |
| } else { |
| errorMessage(msg, 0, { |
| countMetricSource: CountMetric.FrontendErrorReported, |
| source: EXPLORE_MULTI_PAGE_SOURCE, |
| errorCode: StatusCodes.INTERNAL_SERVER_ERROR.toString(), |
| }); |
| } |
| return []; |
| })) as unknown as GraphConfig[]; |
| } |
| |
| /** |
| * Creates a shortcut ID for the current Graph Configs and updates the state. |
| * |
| */ |
| private updateShortcutMultiview() { |
| updateShortcut(this.allGraphConfigs) |
| .then((shortcut: string) => { |
| if (shortcut === '') { |
| this.state.shortcut = ''; |
| this.stateHasChanged!(); |
| return; |
| } |
| this.state.shortcut = shortcut; |
| this.stateHasChanged!(); |
| }) |
| .catch((err: unknown) => { |
| if (err instanceof DataServiceError) { |
| errorMessage(err.message, 0, { |
| countMetricSource: CountMetric.FrontendErrorReported, |
| source: EXPLORE_MULTI_PAGE_SOURCE, |
| errorCode: err.status!.toString(), |
| }); |
| } else { |
| errorMessage(err as string, 0, { |
| countMetricSource: CountMetric.FrontendErrorReported, |
| source: EXPLORE_MULTI_PAGE_SOURCE, |
| errorCode: StatusCodes.INTERNAL_SERVER_ERROR.toString(), |
| }); |
| } |
| }); |
| } |
| |
| private pageChanged(e: CustomEvent<PaginationSkPageChangedEventDetail>) { |
| this._dataLoading = true; |
| this.testPicker?.setReadOnly(true); |
| this.state.pageOffset = Math.max( |
| 0, |
| this.state.pageOffset + e.detail.delta * this.state.pageSize |
| ); |
| this.stateHasChanged!(); |
| this.renderCurrentPage(); |
| } |
| |
| private pageSizeChanged(e: MouseEvent) { |
| this._dataLoading = true; |
| this.testPicker?.setReadOnly(true); |
| this.state.pageSize = +(e.target! as HTMLInputElement).value; |
| this.stateHasChanged!(); |
| this.renderCurrentPage(); |
| } |
| |
| private async loadAllCharts() { |
| if ( |
| window.confirm( |
| 'Loading all charts at once may cause performance issues or page crashes. Proceed?' |
| ) |
| ) { |
| const pageSize = this.exploreElements.length > 0 ? this.exploreElements.length - 1 : 1; |
| this.state.pageSize = pageSize; |
| this.state.pageOffset = 0; |
| this.stateHasChanged!(); |
| await this.splitGraphs(); |
| } |
| } |
| } |
| |
| define('explore-multi-sk', ExploreMultiSk); |