blob: 23259dd29693b2b99a779638d783ca71ef41c42e [file] [log] [blame]
/**
* @module module/explore-simple-sk
* @description <h2><code>explore-simple-sk</code></h2>
*
* Element for exploring data.
*/
import { html } from 'lit/html.js';
import { when } from 'lit/directives/when.js';
import { ref, createRef, Ref } from 'lit/directives/ref.js';
import { MdDialog } from '@material/web/dialog/dialog.js';
import { MdSwitch } from '@material/web/switch/switch.js';
import { define } from '../../../elements-sk/modules/define';
import { jsonOrThrow } from '../../../infra-sk/modules/jsonOrThrow';
import { deepCopy } from '../../../infra-sk/modules/object';
import { toParamSet, fromParamSet } from '../../../infra-sk/modules/query';
import { TabsSk } from '../../../elements-sk/modules/tabs-sk/tabs-sk';
import { ToastSk } from '../../../elements-sk/modules/toast-sk/toast-sk';
import { ParamSet as CommonSkParamSet } from '../../../infra-sk/modules/query';
import { SpinnerSk } from '../../../elements-sk/modules/spinner-sk/spinner-sk';
import { errorMessage } from '../errorMessage';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import { escapeAndLinkifyToString } from '../../../infra-sk/modules/linkify';
import '@material/web/button/outlined-button.js';
import '@material/web/icon/icon.js';
import '@material/web/iconbutton/outlined-icon-button.js';
import '@material/web/switch/switch.js';
import '@material/web/dialog/dialog.js';
import '../../../elements-sk/modules/checkbox-sk';
import '../../../elements-sk/modules/collapse-sk';
import '../../../elements-sk/modules/icons/expand-less-icon-sk';
import '../../../elements-sk/modules/icons/expand-more-icon-sk';
import '../../../elements-sk/modules/icons/help-icon-sk';
import '../../../elements-sk/modules/icons/close-icon-sk';
import '../../../elements-sk/modules/spinner-sk';
import '../../../elements-sk/modules/tabs-panel-sk';
import '../../../elements-sk/modules/tabs-sk';
import '../../../elements-sk/modules/toast-sk';
import '../../../infra-sk/modules/query-sk';
import '../../../infra-sk/modules/paramset-sk';
import '../anomaly-sk';
import '../commit-detail-panel-sk';
import '../commit-range-sk';
import '../domain-picker-sk';
import '../json-source-sk';
import '../ingest-file-links-sk';
import '../picker-field-sk';
import '../pivot-query-sk';
import '../pivot-table-sk';
import '../plot-google-chart-sk';
import '../plot-simple-sk';
import '../plot-summary-sk';
import '../point-links-sk';
import '../split-chart-menu-sk';
import '../query-count-sk';
import '../graph-title-sk';
import '../new-bug-dialog-sk';
import '../existing-bug-dialog-sk';
import '../window/window';
import {
CommitNumber,
CreateBisectRequest,
CreateBisectResponse,
Anomaly,
DataFrame,
RequestType,
ParamSet,
FrameRequest,
FrameResponse,
ShiftRequest,
ShiftResponse,
progress,
pivot,
FrameResponseDisplayMode,
ColumnHeader,
CIDHandlerResponse,
QueryConfig,
TraceSet,
Commit,
Trace,
ReadOnlyParamSet,
} from '../json';
import {
AnomalyData,
PlotSimpleSk,
PlotSimpleSkTraceEventDetails,
} from '../plot-simple-sk/plot-simple-sk';
import { CommitDetailPanelSk } from '../commit-detail-panel-sk/commit-detail-panel-sk';
import { JSONSourceSk } from '../json-source-sk/json-source-sk';
import {
ParamSetSk,
ParamSetSkCheckboxClickEventDetail,
ParamSetSkClickEventDetail,
ParamSetSkPlusClickEventDetail,
ParamSetSkRemoveClickEventDetail,
} from '../../../infra-sk/modules/paramset-sk/paramset-sk';
import { AnomalySk, getAnomalyDataMap } from '../anomaly-sk/anomaly-sk';
import {
QuerySk,
QuerySkQueryChangeEventDetail,
} from '../../../infra-sk/modules/query-sk/query-sk';
import { QueryCountSk } from '../query-count-sk/query-count-sk';
import { DomainPickerSk } from '../domain-picker-sk/domain-picker-sk';
import {
messageByName,
messagesToErrorString,
messagesToPreString,
startRequest,
} from '../progress/progress';
import { IngestFileLinksSk } from '../ingest-file-links-sk/ingest-file-links-sk';
import { validatePivotRequest } from '../pivotutil';
import { PivotQueryChangedEventDetail, PivotQuerySk } from '../pivot-query-sk/pivot-query-sk';
import { PivotTableSk, PivotTableSkChangeEventDetail } from '../pivot-table-sk/pivot-table-sk';
import { fromKey, paramsToParamSet, validKey } from '../paramtools';
import { dataFrameToCSV } from '../csv';
import { CommitRangeSk } from '../commit-range-sk/commit-range-sk';
import { MISSING_DATA_SENTINEL } from '../const/const';
import { LoggedIn } from '../../../infra-sk/modules/alogin-sk/alogin-sk';
import { Status as LoginStatus } from '../../../infra-sk/modules/json';
import { findAnomalyInRange, findSubDataframe, range } from '../dataframe';
import { TraceFormatter, GetTraceFormatter } from '../trace-details-formatter/traceformatter';
import { fixTicksLength, tick, ticks } from '../plot-simple-sk/ticks';
import {
PlotSummarySk,
PlotSummarySkSelectionEventDetails,
} from '../plot-summary-sk/plot-summary-sk';
import { PickerFieldSk } from '../picker-field-sk/picker-field-sk';
import '../chart-tooltip-sk/chart-tooltip-sk';
import '../dataframe/dataframe_context';
import { ChartTooltipSk } from '../chart-tooltip-sk/chart-tooltip-sk';
import { NudgeEntry } from '../triage-menu-sk/triage-menu-sk';
import { $$ } from '../../../infra-sk/modules/dom';
import { PointLinksSk } from '../point-links-sk/point-links-sk';
import { GraphTitleSk } from '../graph-title-sk/graph-title-sk';
import { NewBugDialogSk } from '../new-bug-dialog-sk/new-bug-dialog-sk';
import {
PlotGoogleChartSk,
PlotSelectionEventDetails,
PlotShowTooltipEventDetails,
} from '../plot-google-chart-sk/plot-google-chart-sk';
import { DataFrameRepository, DataTable } from '../dataframe/dataframe_context';
import { ExistingBugDialogSk } from '../existing-bug-dialog-sk/existing-bug-dialog-sk';
import { generateSubDataframe } from '../dataframe/index';
import { SplitChartSelectionEventDetails } from '../split-chart-menu-sk/split-chart-menu-sk';
import { getLegend, getTitle, isSingleTrace } from '../dataframe/traceset';
/** The type of trace we are adding to a plot. */
type addPlotType = 'query' | 'formula' | 'pivot';
// The trace id of the zero line, a trace of all zeros.
const ZERO_NAME = 'special_zero';
// A list of all special trace names.
const SPECIAL_TRACE_NAMES = [ZERO_NAME];
// How often to refresh if the auto-refresh checkmark is checked.
const REFRESH_TIMEOUT = 30 * 1000; // milliseconds
// The default query range in seconds.
export const DEFAULT_RANGE_S = 24 * 60 * 60; // 2 days in seconds.
// The index of the params tab.
const PARAMS_TAB_INDEX = 0;
// The index of the commit detail info tab.
const COMMIT_TAB_INDEX = 1;
// The percentage of the current zoom window to pan or zoom on a keypress.
const ZOOM_JUMP_PERCENT = CommitNumber(0.1);
// When we are zooming around and bump into the edges of the graph, how much
// should we widen the range of commits, as a percentage of the currently
// displayed commit.
const RANGE_CHANGE_ON_ZOOM_PERCENT = 0.5;
// The minimum length [right - left] of a zoom range.
const MIN_ZOOM_RANGE = 0.1;
// The max number of points a user can nudge an anomaly by.
const NUDGE_RANGE = 2;
const STATISTIC_VALUES = ['avg', 'count', 'max', 'min', 'std', 'sum'];
const monthInSec = 30 * 24 * 60 * 60;
// max number of charts to show on a page
const chartsPerPage = 11;
type RequestFrameCallback = (frameResponse: FrameResponse) => void;
export interface ZoomWithDelta {
zoom: CommitRange;
delta: CommitNumber;
}
// Even though pivot.Request sent to the server can be null, we don't want to
// put use a null in state, as that won't let stateReflector figure out the
// right types inside pivot.Request, so we default to an invalid value here.
const defaultPivotRequest = (): pivot.Request => ({
group_by: [],
operation: 'avg',
summary: [],
});
export type CommitRange = [CommitNumber, CommitNumber];
// Stores the trace name and commit number of a single point on a trace.
export interface PointSelected {
commit: CommitNumber;
name: string;
}
export enum LabelMode {
Date = 0,
CommitPosition = 1,
}
/** Returns true if the PointSelected is valid. */
export const isValidSelection = (p: PointSelected): boolean => p.name !== '';
/** Converts a PointSelected into a CustomEvent<PlotSimpleSkTraceEventDetails>,
* so that it can be passed into traceSelected().
*
* Note that we need the _dataframe.header to convert the commit back into an
* offset. Also note that might fail, in which case the 'x' value will be set to
* -1.
*/
export const selectionToEvent = (
p: PointSelected,
header: (ColumnHeader | null)[] | null
): CustomEvent<PlotSimpleSkTraceEventDetails> => {
let x = -1;
if (header !== null) {
// Find the index of the ColumnHeader that matches the commit.
x = header.findIndex((h: ColumnHeader | null) => {
if (h === null) {
return false;
}
return h.offset === p.commit;
});
}
return new CustomEvent<PlotSimpleSkTraceEventDetails>('', {
detail: {
x: x,
y: 0,
name: p.name,
},
});
};
/** Returns a default value for PointSelected. */
export const defaultPointSelected = (): PointSelected => ({
commit: CommitNumber(0),
name: '',
});
export class GraphConfig {
formulas: string[] = []; // Formulas
queries: string[] = []; // Queries
keys: string = ''; // Keys
}
/**
* Creates a shortcut ID for the given Graph Configs.
*
*/
export const updateShortcut = async (graphConfigs: GraphConfig[]): Promise<string> => {
if (graphConfigs.length === 0) {
return '';
}
const body = {
graphs: graphConfigs,
};
return fetch('/_/shortcut/update', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
},
})
.then(jsonOrThrow)
.then((json) => json.id)
.catch(errorMessage);
};
// State is reflected to the URL via stateReflector.
export class State {
begin: number = Math.floor(Date.now() / 1000 - DEFAULT_RANGE_S);
end: number = Math.floor(Date.now() / 1000);
formulas: string[] = [];
queries: string[] = [];
keys: string = ''; // The id of the shortcut to a list of trace keys.
xbaroffset: number = -1; // The offset of the commit in the repo.
showZero: boolean = true;
dots: boolean = true; // Whether to show dots when plotting traces.
autoRefresh: boolean = false;
numCommits: number = 250;
requestType: RequestType = 1; // TODO(jcgregorio) Use constants in domain-picker-sk.
pivotRequest: pivot.Request = defaultPivotRequest();
sort: string = ''; // Pivot table sort order.
summary: boolean = false; // Whether to show the zoom/summary area.
selected: PointSelected = defaultPointSelected(); // The point on a trace that was clicked on.
labelMode: LabelMode = LabelMode.Date;
_incremental: boolean = false; // Enables a data fetching optimization.
disable_filter_parent_traces: boolean = false;
plotSummary: boolean = false;
disableMaterial?: boolean = false;
highlight_anomalies: string[] = [];
enable_chart_tooltip: boolean = false;
show_remove_all: boolean = true;
use_titles: boolean = false;
useTestPicker: boolean = false;
use_test_picker_query: boolean = false;
show_google_plot = false;
}
// TODO(jcgregorio) Move to a 'key' module.
// Returns true if paramName=paramValue appears in the given structured key.
function _matches(key: string, paramName: string, paramValue: string): boolean {
return key.indexOf(`,${paramName}=${paramValue},`) >= 0;
}
interface RangeChange {
/**
* If true then do a range change with the provided offsets, otherwise just
* do a zoom.
*/
rangeChange: boolean;
newOffsets?: [CommitNumber, CommitNumber];
}
// clamp ensures a number is not negative.
function clampToNonNegative(x: number): number {
if (x < 0) {
return 0;
}
return x;
}
/**
* Determines if a range change is needed based on a zoom request, and if so
* calculates what the new range change should be.
*
* @param zoom is the requested zoom.
* @param clampedZoom is the requested zoom clamped to the current dataframe.
* @param offsets are the commit offset of the first and last value in the
* dataframe.
*/
export function calculateRangeChange(
zoom: CommitRange,
clampedZoom: CommitRange,
offsets: [CommitNumber, CommitNumber]
): RangeChange {
// How much we will change the offset if we zoom beyond an edge.
const offsetDelta = Math.floor(
(offsets[1] - offsets[0]) * RANGE_CHANGE_ON_ZOOM_PERCENT
) as CommitNumber;
const exceedsLeftEdge = zoom[0] !== clampedZoom[0];
const exceedsRightEdge = zoom[1] !== clampedZoom[1];
if (exceedsLeftEdge && exceedsRightEdge) {
// shift both
return {
rangeChange: true,
newOffsets: [
clampToNonNegative(offsets[0] - offsetDelta) as CommitNumber,
(offsets[1] + offsetDelta) as CommitNumber,
],
};
}
if (exceedsLeftEdge) {
// shift left
return {
rangeChange: true,
newOffsets: [clampToNonNegative(offsets[0] - offsetDelta) as CommitNumber, offsets[1]],
};
}
if (exceedsRightEdge) {
// shift right
return {
rangeChange: true,
newOffsets: [offsets[0], (offsets[1] + offsetDelta) as CommitNumber],
};
}
return {
rangeChange: false,
};
}
export class ExploreSimpleSk extends ElementSk {
private _dataframe: DataFrame = {
traceset: TraceSet({}),
header: [],
paramset: ReadOnlyParamSet({}),
skip: 0,
};
// The state that does into the URL.
private _state: State = new State();
// Set of customization params that have been explicitly specified
// by the user.
private _userSpecifiedCustomizationParams: Set<string> = new Set();
// Controls the mode of the display. See FrameResponseDisplayMode.
private displayMode: FrameResponseDisplayMode = 'display_query_only';
// Are we waiting on data from the server.
private _spinning: boolean = false;
private _dialogOn: boolean = false;
// The id of the current frame request. Will be the empty string if there
// is no pending request.
private _requestId = '';
// The id of the interval timer if we are refreshing.
private _refreshId = -1;
// All the data converted into a CVS blob to download.
private _csvBlobURL: string = '';
private fromParamsKey: string = '';
private testPath: string = '';
private startCommit: string = '';
private endCommit: string = '';
private story: string = '';
private bugId: string = '';
private jobUrl: string = '';
private jobId: string = '';
private user: string = '';
private _defaults: QueryConfig | null = null;
private _initialized: boolean = false;
private anomalyTable: AnomalySk | null = null;
private commits: CommitDetailPanelSk | null = null;
private commitsTab: HTMLButtonElement | null = null;
private detailTab: TabsSk | null = null;
private formula: HTMLTextAreaElement | null = null;
private jsonsource: JSONSourceSk | null = null;
private ingestFileLinks: IngestFileLinksSk | null = null;
private pointLinks: PointLinksSk | null = null;
private logEntry: HTMLPreElement | null = null;
private paramset: ParamSetSk | null = null;
private percent: HTMLSpanElement | null = null;
private plotSimple = createRef<PlotSimpleSk>();
private googleChartPlot = createRef<PlotGoogleChartSk>();
private plotSummary = createRef<PlotSummarySk>();
private query: QuerySk | null = null;
private fromParamsQuery: QuerySk | null = null;
private fromParamsQueryCount: QueryCountSk | null = null;
private queryCount: QueryCountSk | null = null;
private range: DomainPickerSk | null = null;
private simpleParamset: ParamSetSk | null = null;
private spinner: SpinnerSk | null = null;
private summary: ParamSetSk | null = null;
private commitTime: HTMLSpanElement | null = null;
private csvDownload: HTMLAnchorElement | null = null;
private pivotControl: PivotQuerySk | null = null;
private pivotTable: PivotTableSk | null = null;
private pivotDisplayButton: HTMLButtonElement | null = null;
private queryDialog: HTMLDialogElement | null = null;
private bisectDialog: HTMLDialogElement | null = null;
private fromParamsQueryDialog: HTMLDialogElement | null = null;
private helpDialog: HTMLDialogElement | null = null;
private commitRangeSk: CommitRangeSk | null = null;
// TODO(b/372694234): consolidate the pinpoint and triage toasts.
private pinpointJobToast: ToastSk | null = null;
private triageResultToast: ToastSk | null = null;
private closePinpointToastButton: HTMLButtonElement | null = null;
private closeTriageToastButton: HTMLButtonElement | null = null;
private bisectButton: HTMLButtonElement | null = null;
private collapseButton: HTMLButtonElement | null = null;
private traceDetails: HTMLSpanElement | null = null;
private traceDetailsCopy: HTMLDivElement | null = null;
private traceFormatter: TraceFormatter | null = null;
private originalTraceSet: TraceSet = TraceSet({});
private scrollable: boolean = false;
private traceKeyForSummary: string = '';
chartTooltip: ChartTooltipSk | null = null;
useTestPicker: boolean = false;
private summaryOptionsField: Ref<PickerFieldSk> = createRef();
// Map with displayed summary bar option as key and trace key
// as value. For example, {'...k1=v1,k2=v2' => ',ck1=>cv1,ck2=>cv2,k1=v1,k2=v2,'}
private summaryOptionTraceMap: Map<string, string> = new Map();
// Map with displayed trace key as key and summary bar option
// as value. For example, {',ck1=>cv1,ck2=>cv2,k1=v1,k2=v2,' => '...k1=v1,k2=v2'}
private traceIdSummaryOptionMap: Map<string, string> = new Map();
private pointToCommitDetailMap = new Map();
// tooltipSelected tracks whether someone has turned on the tooltip
// by selecting a data point. A new tooltip will not be created on
// mouseover unless the current selected tooltip is closed.
// true - the tooltip is selected
// false - the tooltip is not selected but could be on via mouse hover
private tooltipSelected = false;
private graphTitle: GraphTitleSk | null = null;
private showRemoveAll = true;
private tracesRendered = false;
// material UI
private settingsDialog: MdDialog | null = null;
private dfRepo = createRef<DataFrameRepository>();
constructor(scrollable: boolean, useTestPicker?: boolean) {
super(ExploreSimpleSk.template);
this.scrollable = scrollable;
this.traceFormatter = GetTraceFormatter();
this.useTestPicker = useTestPicker ?? false;
}
private static template = (ele: ExploreSimpleSk) => html`
<dataframe-repository-sk ${ref(ele.dfRepo)}>
<div id=explore class=${ele.displayMode}>
<div id=buttons>
<button
id=open_query_dialog
?hidden=${ele.useTestPicker}
@click=${ele.openQuery}>
Query
</button>
<div id=traceButtons class="hide_on_query_only hide_on_pivot_table hide_on_spinner">
<split-chart-menu-sk
@split-chart-selection=${ele.splitByAttribute}>
</split-chart-menu-sk>
<md-outlined-button
?disabled=${!(
ele.plotSimple.value &&
ele.plotSimple.value!.highlight.length &&
ele.useTestPicker
)}
@click=${ele.queryHighlighted}>
Query Highlighted
</md-outlined-button>
<md-outlined-button
@click=${ele.removeHighlighted}
?disabled=${!(ele.plotSimple.value && ele.plotSimple.value!.highlight.length)}>
Remove Highlighted
</md-outlined-button>
<md-outlined-button
@click=${ele.highlightedOnly}
?disabled=${!(ele.plotSimple.value && ele.plotSimple.value!.highlight.length)}>
Highlighted Only
</md-outlined-button>
<span
title='Number of commits skipped between each point displayed.'
?hidden=${ele.isZero(ele._dataframe.skip)}
id=skip>
${ele._dataframe.skip}
</span>
<md-outlined-button
?hidden=${!ele.showRemoveAll}
@click=${() => ele.removeAll(false)}>
Remove All
</md-outlined-button>
<div
id=calcButtons
class="hide_on_query_only">
<md-outlined-button
@click=${() => ele.applyFuncToTraces('norm')}>
Normalize
</md-outlined-button>
<md-outlined-button
@click=${() => ele.applyFuncToTraces('scale_by_avg')}>
Scale By Avg
</md-outlined-button>
<md-outlined-button
@click=${() => {
ele.applyFuncToTraces('iqrr');
}}>
Remove outliers
</md-outlined-button>
<md-outlined-button
@click=${ele.csv}>
CSV
</md-outlined-button>
<a href='' target=_blank download='traces.csv' id=csv_download></a>
<md-outlined-button
?hidden=${!window.perf.fetch_chrome_perf_anomalies}
@click=${ele.openBisect}>
Bisect
</md-outlined-button>
<div id="zoomPan" ?hidden=${ele.plotSummary}>
<h3>Zoom/Pan:</h3>
<div id="btnContainer">
<button
class=navigation
@click=${ele.zoomInKey}
title='Zoom In'>
<span class=icon-sk>add</span>
</button>
<button
class=navigation
@click=${ele.zoomOutKey}
title='Zoom Out'>
<span class=icon-sk>remove</span>
</button>
<button
class=navigation
@click=${ele.zoomLeftKey}
title='Pan Left'>
<span class=icon-sk>arrow_back</span>
</button>
<button
class=navigation
@click=${ele.zoomRightKey}
title='Pan Right'>
<span class=icon-sk>arrow_forward</span>
</button>
</div>
</div>
</div>
</div>
<md-outlined-icon-button
class="hide_on_query_only hide_on_pivot_table hide_on_spinner"
@click=${ele.showSettingsDialog}>
<md-icon id="settings-button">settings</md-icon>
</md-outlined-icon-button>
<md-dialog
aria-label='Settings dialog'
id='settings-dialog'>
<form id="form" slot="content" method="dialog">
</form>
<div slot="actions">
<ul style="list-style-type:none; padding-left: 0;">
<li>
<label>
<md-switch
form="form"
id="commit-switch"
?selected=${ele._state.labelMode === LabelMode.CommitPosition}
@change=${(e: InputEvent) => ele.switchXAxis(e.target as MdSwitch)}></md-switch>
X-Axis as Commit Positions
</label>
<li>
<li>
<label>
<md-switch
form="form"
id="dots-switch"
?selected=${ele._state!.dots}
@change=${() => ele.toggleDotsHandler()}></md-switch>
Dots on graph
</label>
</li>
<li>
<label>
<md-switch
form="form"
id="zero-switch"
?selected=${ele._state.showZero}
@change=${(e: InputEvent) =>
ele.zeroChangeHandler(e.target as MdSwitch)}></md-switch>
Draw against "zero" line
</label>
</li>
<li>
<label>
<md-switch
form="form"
id="auto-refresh-switch"
?selected=${ele._state.autoRefresh}
@change=${(e: InputEvent) =>
ele.autoRefreshHandler(e.target as MdSwitch)}></md-switch>
Auto refresh data
</label>
</li>
<li ?hidden=${ele._state.plotSummary}>
<label>
<md-switch
form="form"
id="summary-bar-switch"
?selected=${ele._state.summary}
@change=${(e: InputEvent) =>
ele.summaryChangeHandler(e.target as MdSwitch)}></md-switch>
Summary Bar at Top of Plot
</label>
</li>
</ul>
</div>
</md-dialog>
<button
class="hide_on_query_only hide_on_pivot_table hide_on_spinner"
id="removeAll"
@click=${() => ele.removeAll(false)}
title='Remove all the traces.'>
<close-icon-sk></close-icon-sk>
</button>
</div>
<graph-title-sk id=graphTitle></graph-title-sk>
<div id=spin-overlay @mouseleave=${ele.mouseLeave}>
<div class="chart-container">
<plot-google-chart-sk
style="${ele._state.show_google_plot ? '' : 'display: none'}"
${ref(ele.googleChartPlot)}
@google-chart-select=${ele.onChartSelect}
@plot-data-mouseover=${ele.onChartOver}
@plot-chart-mousedown=${ele.onChartMouseDown}
@selection-changing=${ele.OnSelectionRange}
@selection-changed=${ele.OnSelectionRange}>
<md-icon slot="untriage">question_exchange</md-icon>
<md-icon slot="regression">report</md-icon>
<md-icon slot="improvement">check</md-icon>
</plot-google-chart-sk>
<plot-simple-sk
style="${!ele._state.show_google_plot ? '' : 'display: none'}"
.summary=${ele._state.summary}
${ref(ele.plotSimple)}
@trace_selected=${ele.traceSelected}
@zoom=${ele.plotZoom}
@trace_focused=${ele.plotTraceFocused}
class="hide_on_pivot_table hide_on_query_only hide_on_spinner"
.scrollable=${ele.scrollable}>
</plot-simple-sk>
<chart-tooltip-sk></chart-tooltip-sk>
</div>
<div id="trace-details-container">
<div id="traceDetailsCopy" class="icon-sk copy-content">content_copy</div>
<span id=traceDetails></span>
</div>
${when(ele._state.plotSummary && ele.tracesRendered, () => ele.plotSummaryTemplate())}
<div id=spin-container class="hide_on_query_only hide_on_pivot_table hide_on_pivot_plot hide_on_plot">
<spinner-sk id=spinner active></spinner-sk>
<pre id=percent></pre>
</div>
</div>
<pivot-table-sk
@change=${ele.pivotTableSortChange}
disable_validation
class="hide_on_plot hide_on_pivot_plot hide_on_query_only hide_on_spinner">
</pivot-table-sk>
<dialog id='query-dialog'>
<h2>Query</h2>
<div class=query-parts>
<query-sk
id=query
@query-change=${ele.queryChangeHandler}
@query-change-delayed=${ele.queryChangeDelayedHandler}
> </query-sk>
<div id=selections>
<h3>Selections</h3>
<button id="closeQueryIcon" @click=${ele.closeQueryDialog}>
<close-icon-sk></close-icon-sk>
</button>
<paramset-sk id=summary removable_values @paramset-value-remove-click=${
ele.paramsetRemoveClick
}></paramset-sk>
<div class=query-counts>
Matches: <query-count-sk url='/_/count/' @paramset-changed=${ele.paramsetChanged}>
</query-count-sk>
</div>
</div>
</div>
<details id=time-range-summary>
<summary>Time Range</summary>
<domain-picker-sk id=range>
</domain-picker-sk>
</details>
<tabs-sk>
<button>Plot</button>
<button>Calculations</button>
<button>Pivot</button>
</tabs-sk>
<tabs-panel-sk>
<div>
<button @click=${() => ele.add(true, 'query')} class=action>Plot</button>
<button @click=${() => ele.add(false, 'query')}>Add to Plot</button>
</div>
<div>
<div class=formulas>
<label>
Enter a formula:
<textarea id=formula rows=3 cols=80></textarea>
</label>
<div>
<button @click=${() => ele.add(true, 'formula')} class=action>Plot</button>
<button @click=${() => ele.add(false, 'formula')}>Add to Plot</button>
<a href=/help/ target=_blank>
<help-icon-sk></help-icon-sk>
</a>
</div>
</div>
</div>
<div>
<pivot-query-sk
@pivot-changed=${ele.pivotChanged}
.pivotRequest=${ele._state.pivotRequest}
>
</pivot-query-sk>
<div>
<button
id=pivot-display-button
@click=${() => ele.add(true, 'pivot')}
class=action
.disabled=${validatePivotRequest(ele._state.pivotRequest) !== ''}
>Display</button>
</div>
</div>
</tabs-panel-sk>
<div class=footer>
<button @click=${ele.closeQueryDialog} id='close_query_dialog'>Close</button>
</div>
</dialog>
<dialog id='bisect-dialog'>
<h2>Bisect</h2>
<button id="bisectCloseIcon" @click=${ele.closeBisectDialog}>
<close-icon-sk></close-icon-sk>
</button>
<h3>Test Path</h3>
<input id="testpath" type="text" value=${ele.testPath} readonly></input>
<h3>Bug ID</h3>
<input id="bug-id" type="text" value=${ele.bugId}></input>
<h3>Start Commit</h3>
<input id="start-commit" type="text" value=${ele.startCommit}></input>
<h3>End Commit</h3>
<input id="end-commit" type="text" value=${ele.endCommit}></input>
<h3>Story</h3>
<input id="story" type="text" value=${ele.story}></input>
<h3>Patch to apply to the entire job(optional)</h3>
<input id="patch" type="text"></input>
<div class=footer>
<button id="bisect-button" @click=${ele.postBisect}>Bisect</button>
<button @click=${ele.closeBisectDialog}>Close</button>
</div>
</dialog>
<!--
This is the quick-add dialog that appears when you click the '+' sign on any of
the Params rows displayed in the details tab (See #simple_paramset).
-->
<dialog id='from-params-query-dialog'>
<h2>Query</h2>
<div class=query-parts>
<query-sk
id=from-params-query
values_only
hide_invert
hide_regex
>
</query-sk>
</div>
<div class=query-counts>
Matches: <query-count-sk
id=from-params-query-count
url='/_/count/'
@paramset-changed=${ele.fromParamsParamsetChanged}
>
</query-count-sk>
</div>
<div class=footer>
<button class=action @click=${ele.fromParamsOKQueryDialog}>Plot</button>
<button @click=${ele.fromParamsCloseQueryDialog}>Close</button>
</div>
</dialog>
<dialog id=help>
<h2>Perf Help</h2>
<table>
<tr><td colspan=2><h3>Mouse Controls</h3></td></tr>
<tr><td class=mono>Hover</td><td>Snap crosshair to closest point.</td></tr>
<tr><td class=mono>Shift + Hover</td><td>Highlight closest trace.</td></tr>
<tr><td class=mono>Click</td><td>Select closest point.</td></tr>
<tr><td class=mono>Drag</td><td>Zoom into rectangular region.</td></tr>
<tr><td class=mono>Wheel</td><td>Remove rectangular zoom.</td></tr>
<tr><td colspan=2><h3>Keyboard Controls</h3></td></tr>
<tr><td class=mono>'w'/'s'</td><td>Zoom in/out.<sup>1</sup></td></tr>
<tr><td class=mono>'a'/'d'</td><td>Pan left/right.<sup>1</sup></td></tr>
<tr><td class=mono>'?'</td><td>Show help.</td></tr>
<tr><td class=mono>Esc</td><td>Stop showing help.</td></tr>
</table>
<div class=footnote>
<sup>1</sup> And Dvorak equivalents.
</div>
<div class=help-footer>
<button class=action @click=${ele.closeHelp}>Close</button>
</div>
</dialog>
<div id=tabs class="hide_on_query_only hide_on_spinner hide_on_pivot_table">
<button class="collapser" id="collapseButton" @click=${(_e: Event) => ele.toggleDetails()}>
${
ele.navOpen
? html`<expand-less-icon-sk></expand-less-icon-sk>`
: html`<expand-more-icon-sk></expand-more-icon-sk>`
}
</button>
<collapse-sk id=collapseDetails .closed=${!ele.navOpen}>
<tabs-sk id=detailTab>
<button>Params</button>
<button id=commitsTab disabled>Details</button>
</tabs-sk>
<tabs-panel-sk>
<div>
<p>
<b>Time</b>: <span title='Commit Time' id=commit_time></span>
</p>
<paramset-sk
id=paramset
clickable_values
checkbox_values
@paramset-key-value-click=${(e: CustomEvent<ParamSetSkClickEventDetail>) => {
ele.paramsetKeyValueClick(e);
}}
@paramset-checkbox-click=${ele.paramsetCheckboxClick}
>
</paramset-sk>
</div>
<div id=details>
<div id=params_and_logentry>
<paramset-sk
id=simple_paramset
clickable_plus
clickable_values
copy_content
@paramset-key-value-click=${(e: CustomEvent<ParamSetSkClickEventDetail>) => {
ele.paramsetKeyValueClick(e);
}}
@plus-click=${ele.plusClick}
>
</paramset-sk>
<code><pre id=logEntry></pre></code>
<anomaly-sk id=anomaly></anomaly-sk>
</div>
<div>
<commit-range-sk id="commit-range-link"></commit-range-sk>
<point-links-sk id="point-links"></point-links-sk>
<commit-detail-panel-sk id=commits selectable .hide=${
window.perf.hide_list_of_commits_on_explore
}></commit-detail-panel-sk>
<ingest-file-links-sk class="hide_on_pivot_plot" id=ingest-file-links>
</ingest-file-links-sk>
<json-source-sk class="hide_on_pivot_plot" id=jsonsource></json-source-sk>
</div>
</div>
</tabs-panel-sk>
</collapse-sk>
</div>
</div>
</dataframe-repository-sk>
<toast-sk id="pinpoint-job-toast" duration=10000>
Pinpoint bisection started: <a href=${ele.jobUrl} target=_blank>${ele.jobId}</a>.
<button id="hide-pinpoint-toast" class="action">Close</button>
</toast-sk>
<toast-sk id="triage-result-toast" duration=0>
<span id="triage-result-text"></span><a id="triage-result-link"></a>
<button id="hide-triage-toast" class="action">Close</button>
</toast-sk>
`;
private plotSummaryTemplate() {
return html`<plot-summary-sk
${ref(this.plotSummary)}
@summary_selected=${this.summarySelected}
selectionType=${!this._state.disableMaterial ? 'material' : 'canvas'}
?hasControl=${!this._state.disableMaterial}
class="hide_on_pivot_table hide_on_query_only hide_on_spinner">
</plot-summary-sk>
<div id="summaryPicker">
<picker-field-sk
${ref(this.summaryOptionsField)}
@value-changed=${this.onSummaryPickerChanged}>
</picker-field-sk>
<label>${this.getPlotSummaryTraceLabel()}</label>
</div>`;
}
private onSummaryPickerChanged(e: CustomEvent) {
const selectedTrace = e.detail.value;
this.traceKeyForSummary = this.summaryOptionTraceMap.get(selectedTrace) || '';
const plot = this.plotSummary.value;
if (plot) {
plot.selectedTrace = this.traceKeyForSummary;
}
}
// onChartSelect shows the tooltip whenever a user clicks on a data
// point and the tooltip will lock in place until it is closed.
private onChartSelect(e: CustomEvent) {
const chart = this.googleChartPlot!.value!;
const selection = e.detail.chart.getSelection()[0];
// TODO(b/370804498): Clicking on the same data point to show tooltip
// and then clicking on it again to close it will create errors because
// selection.row will be empty. Handle this use case.
const index = {
row: selection.row,
col: selection.column,
};
const commitPos = chart.getCommitPosition(index.row);
const position = chart.getPositionByIndex(index);
const key = JSON.stringify([chart.getTraceName(index.col), commitPos]);
const commit = this.pointToCommitDetailMap.get(key) || null;
this.enableTooltip(
{
x: index.row - (this.selectedRange?.begin || 0),
y: chart.getYValue(index),
xPos: position.x,
yPos: position.y,
name: chart.getTraceName(index.col),
},
commit,
false,
true
);
this.tooltipSelected = true;
}
// if the tooltip is opened and the user is not shift-clicking,
// close it when clicking on the chart
// i.e. clicking away from the tooltip closes it
private onChartMouseDown(): void {
const tooltipElem = $$<ChartTooltipSk>('chart-tooltip-sk', this);
this.tooltipSelected = false;
tooltipElem?.moveTo(null);
}
// onChartOver shows the tooltip whenever a user hovers their mouse
// over a data point in the google chart
private onChartOver({ detail }: CustomEvent<PlotShowTooltipEventDetails>): void {
// do not show tooltip if tooltip is selected
if (this.tooltipSelected) {
return;
}
const chart = this.googleChartPlot!.value!;
const index = detail;
const commitPos = chart.getCommitPosition(index.row);
const position = chart.getPositionByIndex(index);
const key = JSON.stringify([chart.getTraceName(index.col), commitPos]);
const commit = this.pointToCommitDetailMap.get(key) || null;
this.enableTooltip(
{
x: index.row - (this.selectedRange?.begin || 0),
y: chart.getYValue(index),
xPos: position.x,
yPos: position.y,
name: chart.getTraceName(index.col),
},
commit,
false,
true
);
}
connectedCallback(): void {
super.connectedCallback();
if (this._initialized) {
return;
}
this._initialized = true;
this._render();
this.anomalyTable = this.querySelector('#anomaly');
this.commits = this.querySelector('#commits');
this.commitsTab = this.querySelector('#commitsTab');
this.detailTab = this.querySelector('#detailTab');
this.formula = this.querySelector('#formula');
this.jsonsource = this.querySelector('#jsonsource');
this.ingestFileLinks = this.querySelector('#ingest-file-links');
this.pointLinks = this.querySelector<PointLinksSk>('#point-links');
this.logEntry = this.querySelector('#logEntry');
this.paramset = this.querySelector('#paramset');
this.percent = this.querySelector('#percent');
this.pivotControl = this.querySelector('pivot-query-sk');
this.pivotDisplayButton = this.querySelector('#pivot-display-button');
this.pivotTable = this.querySelector('pivot-table-sk');
this.query = this.querySelector('#query');
this.fromParamsQueryCount = this.querySelector('#from-params-query-count');
this.fromParamsQuery = this.querySelector('#from-params-query');
this.queryCount = this.querySelector('query-count-sk');
this.range = this.querySelector('#range');
this.simpleParamset = this.querySelector('#simple_paramset');
this.spinner = this.querySelector('#spinner');
this.summary = this.querySelector('#summary');
this.commitTime = this.querySelector('#commit_time');
this.csvDownload = this.querySelector('#csv_download');
this.queryDialog = this.querySelector('#query-dialog');
this.bisectDialog = this.querySelector('#bisect-dialog');
this.fromParamsQueryDialog = this.querySelector('#from-params-query-dialog');
this.helpDialog = this.querySelector('#help');
this.commitRangeSk = this.querySelector('#commit-range-link');
this.pinpointJobToast = this.querySelector('#pinpoint-job-toast');
this.closePinpointToastButton = this.querySelector('#hide-pinpoint-toast');
this.triageResultToast = this.querySelector('#triage-result-toast');
this.closeTriageToastButton = this.querySelector('#hide-triage-toast');
this.bisectButton = this.querySelector('#bisect-button');
this.collapseButton = this.querySelector('#collapseButton');
this.traceDetails = this.querySelector('#traceDetails');
this.traceDetailsCopy = this.querySelector('#traceDetailsCopy');
this.graphTitle = this.querySelector<GraphTitleSk>('#graphTitle');
// material UI stuff
this.settingsDialog = this.querySelector<MdDialog>('#settings-dialog');
// Populate the query element.
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
LoggedIn()
.then((status: LoginStatus) => {
this.user = status.email;
})
.catch(errorMessage);
fetch(`/_/initpage/?tz=${tz}`, {
method: 'GET',
})
.then(jsonOrThrow)
.then((json) => {
const now = Math.floor(Date.now() / 1000);
this._state.begin = now - 60 * 60 * 24;
this._state.end = now;
this.range!.state = {
begin: this._state.begin,
end: this._state.end,
num_commits: this._state.numCommits,
request_type: this._state.requestType,
};
this.query!.key_order = window.perf.key_order || [];
this.query!.paramset = json.dataframe.paramset;
this.pivotControl!.paramset = json.dataframe.paramset;
// Remove the paramset so it doesn't get displayed in the Params tab.
json.dataframe.paramset = {};
})
.catch(errorMessage);
this.closePinpointToastButton!.addEventListener('click', () => this.pinpointJobToast?.hide());
this.closeTriageToastButton!.addEventListener('click', () => this.triageResultToast?.hide());
// Add an event listener for when a new bug is filed or an existing bug is submitted in the tooltip.
this.addEventListener('anomaly-changed', (e) => {
this.plotSimple.value?.redrawOverlayCanvas();
const detail = (e as CustomEvent).detail;
if (!detail) {
this.triageResultToast?.hide();
return;
}
const bugId = (e as CustomEvent).detail.bugId;
const newBug = (e as CustomEvent).detail.newBug;
const toastText = document.getElementById('triage-result-text')! as HTMLSpanElement;
const toastLink = document.getElementById('triage-result-link')! as HTMLAnchorElement;
if (newBug === true) {
toastText.textContent = `New bug created: `;
} else {
toastText.textContent = `Anomalies associated with: `;
}
const link = `https://issues.chromium.org/issues/${bugId}`;
toastLink.setAttribute('href', `${link}`);
toastLink.setAttribute('target', '_blank');
toastLink.innerText = `${bugId}`;
this.triageResultToast?.show();
});
}
render(): void {
this._render();
}
showSettingsDialog(_event: Event) {
this.settingsDialog!.show();
}
switchXAxis(target: MdSwitch | null) {
// TODO(b/362831653): It's probably better to toggle the domain
// with an event listener than to interact with the chart elements
const googleChart = this.googleChartPlot.value;
const plotSummary = this.plotSummary.value;
if (plotSummary) {
plotSummary.domain = target!.selected ? 'commit' : 'date';
}
if (googleChart) {
googleChart.domain = target!.selected ? 'commit' : 'date';
}
const plot = this.plotSimple.value;
if (!plot) {
return;
}
if (target!.selected) {
this._state.labelMode = LabelMode.CommitPosition;
} else {
this._state.labelMode = LabelMode.Date;
}
const anomalyMap = plot.anomalyDataMap;
plot.removeAll();
this.AddPlotLines(this._dataframe.traceset, this.getLabels(this._dataframe.header!));
plot.anomalyDataMap = anomalyMap;
this._stateHasChanged();
}
private summaryChangeHandler(target: MdSwitch | null) {
this._state.summary = target!.selected;
this._userSpecifiedCustomizationParams.add('summary');
this._stateHasChanged();
this._render();
}
// Call this anytime something in private state is changed. Will be replaced
// with the real function once stateReflector has been setup.
// eslint-disable-next-line @typescript-eslint/no-empty-function
private _stateHasChanged = () => {
this.showRemoveAll = this._state.show_remove_all;
// If chart tooltip is enabled do not show crosshair label
if (this.plotSimple.value) {
this.plotSimple.value.showCrosshairLabel = !this._state.enable_chart_tooltip;
}
this.dispatchEvent(new CustomEvent('state_changed', {}));
};
private _renderedTraces = () => {
this.dispatchEvent(new CustomEvent('rendered_traces', {}));
};
private closeQueryDialog(): void {
this.queryDialog!.close();
this._dialogOn = false;
}
private closeBisectDialog(): void {
this.bisectDialog!.close();
this._dialogOn = false;
}
private postBisect(): void {
this.bisectButton!.disabled = true;
const parts: string[] = this.simpleParamset!.paramsets[0]!.test[0].split('_');
const tail: string = parts.pop()!;
const chart = STATISTIC_VALUES.includes(tail)
? parts.join('_')
: this.simpleParamset!.paramsets[0]!.test[0];
const statistic = STATISTIC_VALUES.includes(tail) ? tail : '';
const bugId = document.getElementById('bug-id')! as HTMLInputElement;
const startCommit = document.getElementById('start-commit')! as HTMLInputElement;
const endCommit = document.getElementById('end-commit')! as HTMLInputElement;
const story = document.getElementById('story')! as HTMLInputElement;
const patch = document.getElementById('patch')! as HTMLInputElement;
const req: CreateBisectRequest = {
comparison_mode: 'performance',
start_git_hash: startCommit.value,
end_git_hash: endCommit.value,
configuration: this.testPath.split('/')[1],
benchmark: this.testPath.split('/')[2],
story: story.value,
chart: chart,
statistic: statistic,
comparison_magnitude: '',
pin: patch.value,
project: 'chromium',
bug_id: bugId.value,
user: this.user,
};
fetch('/_/bisect/create', {
method: 'POST',
body: JSON.stringify(req),
headers: {
'Content-Type': 'application/json',
},
})
.then(jsonOrThrow)
.then((json: CreateBisectResponse) => {
this.jobUrl = json.jobUrl;
this.jobId = json.jobId;
this._render();
this.pinpointJobToast?.show();
})
.catch(errorMessage)
.finally(() => {
this.bisectButton!.disabled = false;
this.closeBisectDialog();
});
}
keyDown(e: KeyboardEvent) {
// Ignore IME composition events.
if (this._dialogOn || e.isComposing || e.keyCode === 229) {
return;
}
// Allow user to type and not pan graph if the Existing Bug Dialog is showing.
const existing_bug_dialog = $$<ExistingBugDialogSk>('existing-bug-dialog-sk', this);
if (existing_bug_dialog && existing_bug_dialog.isActive) {
return;
}
// Allow user to type and not pan graph if the New Bug Dialog is showing.
const new_bug_dialog = $$<NewBugDialogSk>('new-bug-dialog-sk', this);
if (new_bug_dialog && new_bug_dialog.opened) {
return;
}
// Allow user to type and not pan graph if an input box is active.
const activeElement = document.activeElement;
if (activeElement instanceof HTMLInputElement) {
return;
}
switch (e.key) {
case '?':
this.helpDialog!.showModal();
break;
case ',': // dvorak
case 'w':
this.zoomInKey();
break;
case 'o': // dvorak
case 's':
this.zoomOutKey();
break;
case 'a':
this.zoomLeftKey();
break;
case 'e': // dvorak
case 'd':
this.zoomRightKey();
break;
case `Escape`:
this.disableTooltip();
break;
case `Esc`:
this.disableTooltip();
break;
default:
break;
}
}
/**
* The current zoom and the length between the left and right edges of
* the zoom as an object of the form:
*
* {
* zoom: [2.0, 12.0],
* delta: 10.0,
* }
*/
private getCurrentZoom(): ZoomWithDelta {
let zoom = this.plotSimple.value?.zoom;
if (!zoom) {
zoom = [0, this._dataframe.header!.length - 1];
}
let delta = zoom[1] - zoom[0];
if (delta < MIN_ZOOM_RANGE) {
const mid = (zoom[0] + zoom[1]) / 2;
zoom[0] = mid - MIN_ZOOM_RANGE / 2;
zoom[1] = mid + MIN_ZOOM_RANGE / 2;
delta = MIN_ZOOM_RANGE;
}
return {
zoom: zoom as CommitRange,
delta: delta as CommitNumber,
};
}
/**
* Clamp a single zoom endpoint.
*/
private clampZoomIndexToDataFrame(z: CommitNumber): CommitNumber {
if (z < 0) {
z = CommitNumber(0);
}
if (z > this._dataframe.header!.length - 1) {
z = (this._dataframe.header!.length - 1) as CommitNumber;
}
return z as CommitNumber;
}
/**
* Fixes up the zoom range so it always make sense.
*
* @param {Array<Number>} zoom - The zoom range.
* @returns {Array<Number>} The zoom range.
*/
private rationalizeZoom(zoom: CommitRange): CommitRange {
if (zoom[0] > zoom[1]) {
const left = zoom[0];
zoom[0] = zoom[1];
zoom[1] = left;
}
return zoom;
}
/**
* Zooms to the desired range, or changes the range of commits being displayed
* if the zoom range extends past either end of the current commits.
*
* @param zoom is the desired zoom range. Each number is an index into the
* dataframe.
*/
private zoomOrRangeChange(zoom: CommitRange) {
zoom = this.rationalizeZoom(zoom);
const clampedZoom: CommitRange = [
this.clampZoomIndexToDataFrame(zoom[0]),
this.clampZoomIndexToDataFrame(zoom[1]),
];
const offsets: [CommitNumber, CommitNumber] = [
this._dataframe.header![0]!.offset,
this._dataframe.header![this._dataframe.header!.length - 1]!.offset,
];
const result = calculateRangeChange(zoom, clampedZoom, offsets);
if (result.rangeChange) {
// Convert the offsets into timestamps, which are needed when building
// dataframes.
const req: ShiftRequest = {
begin: result.newOffsets![0],
end: result.newOffsets![1],
};
fetch('/_/shift/', {
method: 'POST',
body: JSON.stringify(req),
headers: {
'Content-Type': 'application/json',
},
})
.then(jsonOrThrow)
.then((json: ShiftResponse) => {
this._state.begin = json.begin;
this._state.end = json.end;
this._state.requestType = 0;
this._stateHasChanged();
this.rangeChangeImpl();
})
.catch(errorMessage);
} else {
if (this.plotSimple.value) {
this.plotSimple.value.zoom = zoom;
}
}
}
private pivotChanged(e: CustomEvent<PivotQueryChangedEventDetail>): void {
// Only enable the Display button if we have a valid pivot.Request and a
// query.
this.pivotDisplayButton!.disabled =
validatePivotRequest(e.detail) !== '' || this.query!.current_query.trim() === '';
if (!e.detail || e.detail.summary!.length === 0) {
this.pivotDisplayButton!.textContent = 'Display';
} else {
this.pivotDisplayButton!.textContent = 'Display Table';
}
}
private zoomInKey() {
const cz = this.getCurrentZoom();
const zoom: CommitRange = [
(cz.zoom[0] + ZOOM_JUMP_PERCENT * cz.delta) as CommitNumber,
(cz.zoom[1] - ZOOM_JUMP_PERCENT * cz.delta) as CommitNumber,
];
this.zoomOrRangeChange(zoom);
}
private zoomOutKey() {
const cz = this.getCurrentZoom();
const zoom: CommitRange = [
(cz.zoom[0] - ZOOM_JUMP_PERCENT * cz.delta) as CommitNumber,
(cz.zoom[1] + ZOOM_JUMP_PERCENT * cz.delta) as CommitNumber,
];
this.zoomOrRangeChange(zoom);
}
private zoomLeftKey() {
const cz = this.getCurrentZoom();
const zoom: CommitRange = [
(cz.zoom[0] - ZOOM_JUMP_PERCENT * cz.delta) as CommitNumber,
(cz.zoom[1] - ZOOM_JUMP_PERCENT * cz.delta) as CommitNumber,
];
this.zoomOrRangeChange(zoom);
}
private zoomRightKey() {
const cz = this.getCurrentZoom();
const zoom: CommitRange = [
(cz.zoom[0] + ZOOM_JUMP_PERCENT * cz.delta) as CommitNumber,
(cz.zoom[1] + ZOOM_JUMP_PERCENT * cz.delta) as CommitNumber,
];
this.zoomOrRangeChange(zoom);
}
/** Returns true if we have any traces to be displayed. */
private hasData() {
// We have data if at least one traceID isn't a special name.
return Object.keys(this._dataframe.traceset).some(
(traceID) => !SPECIAL_TRACE_NAMES.includes(traceID)
);
}
/** Open the query dialog box. */
openQuery() {
this._render();
this._dialogOn = true;
this.queryDialog!.showModal();
// If there is a query already plotted, update the counts on the query dialog.
if (this._state.queries.length > 0) {
this.queryCount!.current_query = this.applyDefaultsToQuery(this.query!.current_query);
}
}
/** Open the bisect dialog box. */
private openBisect() {
this._render();
this._dialogOn = true;
this.bisectDialog!.showModal();
}
private paramsetChanged(e: CustomEvent<ParamSet>) {
this.query!.paramset = e.detail;
this.pivotControl!.paramset = e.detail;
this._render();
}
/** Called when the query-count-sk element has finished querying the server
* for an updated ParamSet. */
private fromParamsParamsetChanged(e: CustomEvent<ParamSet>) {
this.fromParamsQuery!.paramset = e.detail;
this.fromParamsQuery!.selectKey(this.fromParamsKey);
this._render();
}
private fromParamsCloseQueryDialog() {
this.fromParamsQueryDialog!.close();
}
private fromParamsOKQueryDialog() {
// This query only contains the key this.fromParamsKey and it's values, so we need
// to construct the full query using the traceID.
// Note: toParamSet(s: string) returns CommonSkParamSet, not ParamSet. Hence the cast.
const updatedParamValues = ParamSet(toParamSet(this.fromParamsQuery!.current_query));
const traceIDAsQuery: ParamSet = paramsToParamSet(fromKey(this._state.selected.name));
// Merge the two ParamSets.
const newQuery: ParamSet = Object.assign(traceIDAsQuery, updatedParamValues);
this.addFromQueryOrFormula(false, 'query', fromParamSet(newQuery), '');
this.fromParamsQueryDialog!.close();
}
/** Handles clicks on the '+' icons on the Details tab Params. */
plusClick(e: CustomEvent<ParamSetSkPlusClickEventDetail>): void {
// Record the Params key that was clicked on.
this.fromParamsKey = e.detail.key;
// Convert the traceID into a ParamSet.
const keyAsParamSet: ParamSet = paramsToParamSet(fromKey(this._state.selected.name));
// And remove the Params key that was clicked on.
keyAsParamSet[this.fromParamsKey] = [];
// Convert the ParamSet back into a query to pass to
// this.fromParamsQueryCount, which will query the server for the number of
// traces that match the new query, and also return a ParamSet we can use to
// populate the query-sk control.
this.fromParamsQueryCount!.current_query = fromParamSet(keyAsParamSet);
// To avoid the dialog displaying state data we populate the ParamSet
// and select our key which will display and empty set of value choices
// until this.fromParamsQueryCount is done.
this.fromParamsQuery!.paramset = keyAsParamSet;
this.fromParamsQuery!.selectKey(this.fromParamsKey);
this.fromParamsQueryDialog?.showModal();
}
private queryChangeDelayedHandler(e: CustomEvent<QuerySkQueryChangeEventDetail>) {
this.queryCount!.current_query = this.applyDefaultsToQuery(e.detail.q);
}
/** Reflect the current query to the query summary. */
private queryChangeHandler(e: CustomEvent<QuerySkQueryChangeEventDetail>) {
const query = e.detail.q;
this.summary!.paramsets = [toParamSet(query)];
const formula = this.formula!.value;
if (formula === '') {
this.formula!.value = `filter("${query}")`;
} else if ((formula.match(/"/g) || []).length === 2) {
// Only update the filter query if there's one string in the formula.
this.formula!.value = formula.replace(/".*"/, `"${query}"`);
}
}
private pivotTableSortChange(e: CustomEvent<PivotTableSkChangeEventDetail>): void {
this._state.sort = e.detail;
this._stateHasChanged();
}
/** Reflect the focused trace in the paramset. */
private plotTraceFocused({ detail }: CustomEvent<PlotSimpleSkTraceEventDetails>) {
const header = this.dfRepo.value?.dataframe.header;
const selected = header![(this.selectedRange?.begin || 0) + detail.x]!;
this.paramset!.highlight = fromKey(detail.name);
this.commitTime!.textContent = new Date(selected.timestamp * 1000).toLocaleString();
const formattedTrace = this.traceFormatter!.formatTrace(fromKey(detail.name));
this.traceDetails!.textContent = formattedTrace;
this.traceDetailsCopy!.onclick = () => {
navigator.clipboard.writeText(formattedTrace);
};
this.traceDetailsCopy!.style.display = 'block';
if (this._state.enable_chart_tooltip && !this.tooltipSelected) {
// if the commit details for a point is already loaded then
// show those commit details on hover
let c = null;
const commitPos = selected.offset;
const key = JSON.stringify([detail.name, commitPos]);
if (this.pointToCommitDetailMap.has(key)) {
c = this.pointToCommitDetailMap.get(key) || null;
}
this.enableTooltip(detail, c, false, false);
}
}
/** User has zoomed in on the graph. */
private plotZoom() {
this._render();
}
/** get story name for pinpoint. */
private getLastSubtest(d: any) {
const tmp =
d.subtest_7 ||
d.subtest_6 ||
d.subtest_5 ||
d.subtest_4 ||
d.subtest_3 ||
d.subtest_2 ||
d.subtest_1;
return tmp ? tmp[0] : '';
}
private selectedRange?: range;
/**
* React to the summary_selected event.
* @param e Event object.
*/
summarySelected({ detail }: CustomEvent<PlotSummarySkSelectionEventDetails>): void {
this.updateSelectedRangeWithUpdatedDataframe(detail.value, detail.domain);
}
private extendRange(range: range) {
const dfRepo = this.dfRepo.value;
const header = dfRepo?.dataframe?.header;
if (!dfRepo || !header || dfRepo.loading) {
return;
}
if (range.begin < header[0]!.offset) {
dfRepo.extendRange(-monthInSec);
} else if (range.end > header[header.length - 1]!.offset) {
dfRepo.extendRange(monthInSec);
}
}
private OnSelectionRange({ type, detail }: CustomEvent<PlotSelectionEventDetails>): void {
if (type === 'selection-changed') {
this.extendRange(detail.value);
}
if (this.plotSummary.value) {
this.plotSummary.value.selectedValueRange = detail.value;
}
}
private updateSelectedRangeWithUpdatedDataframe(
range: range,
domain: 'commit' | 'date',
replot = true
) {
if (this.googleChartPlot.value) {
this.googleChartPlot.value.selectedRange = range;
}
const plot = this.plotSimple.value;
const df = this.dfRepo.value?.dataframe;
const header = df?.header || [];
const selected = findSubDataframe(header!, range, domain);
this.selectedRange = selected;
const subDataframe = generateSubDataframe(df!, selected);
const anomalyMap = findAnomalyInRange(this.dfRepo.value?.anomaly || {}, {
begin: header[Math.min(selected.begin, header.length - 1)]!.offset,
end: header[Math.min(selected.end, header.length - 1)]!.offset,
});
// Update the current dataframe to reflect the selection.
this._dataframe.traceset = subDataframe.traceset;
this._dataframe.header = subDataframe.header;
if (!plot) {
return;
}
if (replot) {
plot.removeAll();
this.AddPlotLines(subDataframe.traceset, this.getLabels(subDataframe.header!));
}
if (anomalyMap) {
plot.anomalyDataMap = getAnomalyDataMap(
subDataframe.traceset,
subDataframe.header!,
anomalyMap,
this.state.highlight_anomalies
);
}
}
enableTooltip(
pointDetails: PlotSimpleSkTraceEventDetails,
commit: Commit | null,
displayFileLinks: boolean,
fixTooltip: boolean
): void {
// explore-simple-sk is used multiple times on the multi-graph view. To
// make sure that appropriate chart-tooltip-sk element is selected, we
// start the search from the explore-simple-sk that the user is hovering/
// clicking on
const tooltipElem = $$<ChartTooltipSk>('chart-tooltip-sk', this);
const x = (this.selectedRange?.begin || 0) + pointDetails.x;
const testName = pointDetails.name;
const commitPosition = this.dfRepo.value!.dataframe.header![x]!.offset;
const anomaly = this.dfRepo.value?.getAnomaly(testName, commitPosition) || null;
// TODO(b/370804498): To be refactored into google plot / dataframe.
// The anomaly data is indirectly referenced from simple-plot, and the anomaly data gets
// updated in place in triage popup. This may cause the data inconsistency to manipulate
// data in several places.
// Ideally, dataframe_context should nudge anomaly data.
const anomalyDataMap = this.plotSimple.value?.anomalyDataMap;
let anomalyDataInPlot: AnomalyData | null = null;
if (anomalyDataMap) {
const traceAnomalies = anomalyDataMap[testName];
if (traceAnomalies) {
for (let i = 0; i < traceAnomalies.length; i++) {
if (pointDetails.x === traceAnomalies[i].x) {
anomalyDataInPlot = traceAnomalies[i];
break;
}
}
}
}
// -- to be refactored. see above--
// Map an anomaly ID to a list of Nudge Entries.
// TODO(b/375678060): Reflect anomaly coordinate changes unto summary bar.
const nudgeList: NudgeEntry[] = [];
if (anomaly) {
// This is only to be backward compatible with anomaly data in simple-plot.
const anomalyData = this.plotSimple.value
? anomalyDataInPlot
: {
anomaly: anomaly,
x: commitPosition,
y: pointDetails.y,
highlight: false,
};
const headerLength = this.dfRepo.value!.dataframe.header!.length;
for (let i = -NUDGE_RANGE; i <= NUDGE_RANGE; i++) {
if (x + i <= 0 || x + i >= headerLength) {
continue;
}
const start_revision = this.dfRepo.value!.dataframe.header![x + i - 1]!.offset + 1;
const end_revision = this.dfRepo.value!.dataframe.header![x + i]!.offset;
const y = this.dfRepo.value!.dataframe.traceset![testName][x + i];
nudgeList.push({
display_index: i,
anomaly_data: anomalyData,
selected: i === 0,
start_revision: start_revision,
end_revision: end_revision,
x: pointDetails.x + i,
y: y,
});
}
}
tooltipElem!.moveTo({ x: pointDetails.xPos!, y: pointDetails.yPos! });
const closeBtnAction = fixTooltip
? () => {
this.tooltipSelected = false;
tooltipElem!.moveTo(null);
}
: () => {};
tooltipElem!.load(
this.traceFormatter!.formatTrace(fromKey(testName)),
testName,
pointDetails.y,
commitPosition,
anomaly,
nudgeList,
commit,
displayFileLinks,
fixTooltip,
closeBtnAction
);
}
// if the user's cursor leaves the graph, close the tooltip
// unless the tooltip is 'fixed', meaning the user has anchored it.
mouseLeave(): void {
if (this.tooltipSelected) {
return;
}
this.disableTooltip();
}
/** Hides the tooltip. Generally called when mouse moves out of the graph */
disableTooltip(): void {
const tooltipElem = $$<ChartTooltipSk>('chart-tooltip-sk', this);
tooltipElem!.moveTo(null);
this._render();
}
/** Highlight a trace when it is clicked on. */
traceSelected({ detail }: CustomEvent<PlotSimpleSkTraceEventDetails>): void {
this.plotSimple.value!.highlight = [detail.name];
this.plotSimple.value!.xbar = detail.x;
this.commits!.details = [];
// If traces are rendered and summary bar is enabled, show
// summary for the trace clicked on the graph.
if (this.summaryOptionsField.value) {
const option = this.traceIdSummaryOptionMap.get(detail.name) || '';
this.summaryOptionsField.value!.setValue(option);
}
const selected = this.selectedRange?.begin || 0;
const x = selected + detail.x;
if (x < 0) {
return;
}
// loop backwards from x until you get the next
// non MISSING_DATA_SENTINEL point.
const commit: CommitNumber = this.dfRepo.value?.dataframe.header![x]?.offset as CommitNumber;
if (!commit) {
return;
}
const commits: CommitNumber[] = [commit];
// Find all the commit ids between the commit that was clicked on, and the
// previous commit on the display, inclusive of the commit that was clicked,
// and non-inclusive of the previous commit.
// We always do this, but the response may not contain all the commit info
// if alerts.DefaultSparse==true, in which case only info for the first
// commit is returned.
// First skip back to the next point with data.
const trace = this.dfRepo.value?.dataframe.traceset[detail.name] || [];
const header = this.dfRepo.value?.header || [];
let prevCommit: CommitNumber = CommitNumber(-1);
for (let i = x - 1; i >= 0; i--) {
if (trace![i] !== MISSING_DATA_SENTINEL) {
prevCommit = header[i]!.offset as CommitNumber;
break;
}
}
// Populate the commit-range-sk element.
this.commitRangeSk!.trace = trace;
this.commitRangeSk!.commitIndex = detail.x;
this.commitRangeSk!.header = header;
if (prevCommit !== -1) {
for (let c = commit - 1; c > prevCommit; c--) {
commits.push(c as CommitNumber);
}
}
// Find if selected point is an anomaly.
let selected_anomaly: Anomaly | null = null;
// TODO(b/362831653) - Update this to Google Chart once plot-simple-sk is deprecated.
if (detail.name in this.plotSimple.value!.anomalyDataMap) {
const anomalyData = this.plotSimple.value!.anomalyDataMap[detail.name];
for (let i = 0; i < anomalyData.length; i++) {
if (anomalyData[i].x === detail.x) {
selected_anomaly = anomalyData[i].anomaly;
break;
}
}
}
const paramset = ParamSet({});
this.simpleParamset!.paramsets = [];
// If the trace name is a function key like norm(,a=1,b=2,)
// extract the actual trace name ,a=1,b=1, from it to display
// the params and values on the paramset table
const funcKeyRegex = new RegExp(/\w+\(,.*,\)/);
const isFuncKey = detail.name.match(funcKeyRegex);
// Convert the trace id (if valid) into a paramset to display.
let tName = '';
if (isFuncKey) {
const keyRegex = new RegExp(/(?<=\()(.*?)(?=\))/);
tName = detail.name.match(keyRegex)![0];
} else if (validKey(detail.name)) {
tName = detail.name;
}
if (tName !== '') {
const params: { [key: string]: string } = fromKey(tName);
Object.keys(params).forEach((key) => {
paramset[key] = [params[key]];
});
this.simpleParamset!.paramsets = [paramset as CommonSkParamSet];
}
this._render();
this._state.selected.name = detail.name;
this._state.selected.commit = commit;
this._stateHasChanged();
// Request populated commits from the server.
fetch('/_/cid/', {
method: 'POST',
body: JSON.stringify(commits),
headers: {
'Content-Type': 'application/json',
},
})
.then(jsonOrThrow)
.then((json: CIDHandlerResponse) => {
this.commits!.details = json.commitSlice || [];
this.commitsTab!.disabled = false;
this.simpleParamset!.paramsets = [paramset as CommonSkParamSet];
this.logEntry!.innerHTML = escapeAndLinkifyToString(json.logEntry);
this.anomalyTable!.anomaly = selected_anomaly;
this.anomalyTable!.bugHostUrl = window.perf.bug_host_url;
this.detailTab!.selected = COMMIT_TAB_INDEX;
const cid = commits[0]!;
const traceid = detail.name;
const parts = [];
this.story = this.getLastSubtest(this.simpleParamset!.paramsets[0]!);
if (
this.simpleParamset!.paramsets[0]!.master &&
this.simpleParamset!.paramsets[0]!.master.length > 0
) {
parts.push(this.simpleParamset!.paramsets[0]!.master[0]);
}
if (
this.simpleParamset!.paramsets[0]!.bot &&
this.simpleParamset!.paramsets[0]!.bot.length > 0
) {
parts.push(this.simpleParamset!.paramsets[0]!.bot[0]);
}
if (
this.simpleParamset!.paramsets[0]!.benchmark &&
this.simpleParamset!.paramsets[0]!.benchmark.length > 0
) {
parts.push(this.simpleParamset!.paramsets[0]!.benchmark[0]);
}
if (
this.simpleParamset!.paramsets[0]!.test &&
this.simpleParamset!.paramsets[0]!.test.length > 0
) {
parts.push(this.simpleParamset!.paramsets[0]!.test[0]);
}
parts.push(this.story);
this.testPath = parts.join('/');
this.startCommit = prevCommit.toString();
this.endCommit = commit.toString();
if (selected_anomaly !== null && selected_anomaly.bug_id !== -1) {
this.bugId = selected_anomaly.bug_id.toString();
} else {
this.bugId = '';
}
if (this.displayMode === 'display_plot') {
this.jsonsource!.cid = cid;
this.jsonsource!.traceid = traceid;
this.ingestFileLinks!.load(cid, traceid);
// Populate the point links element.
this.pointLinks!.load(
commit,
prevCommit,
detail.name,
window.perf.keys_for_commit_range!
);
}
// when the commit details are loaded, add those info to
// pointToCommitDetailMap map which can be used to fetch commit
// info on hover without making an API call
this.pointToCommitDetailMap.set(JSON.stringify([detail.name, cid]), json.commitSlice![0]);
const tooltipEnabled = this._state.enable_chart_tooltip;
const hasValidTooltipPos = detail.xPos !== undefined && detail.yPos !== undefined;
if (tooltipEnabled && hasValidTooltipPos) {
this.tooltipSelected = true;
this.enableTooltip(detail, json.commitSlice![0], true, true);
}
})
.catch(errorMessage);
// Open the details section if it is currently collapsed
if (!this.navOpen) {
this.collapseButton?.click();
}
}
private clearSelectedState() {
const plot = this.plotSimple.value;
if (plot) {
plot.highlight = [];
plot.xbar = -1;
}
// Switch back to the params tab since we are about to hide the details tab.
this.detailTab!.selected = PARAMS_TAB_INDEX;
this.commitsTab!.disabled = true;
this.logEntry!.textContent = '';
this._state.selected = defaultPointSelected();
this._stateHasChanged();
}
/**
* Fixes up the time ranges in the state that came from query values.
*
* It is possible for the query URL to specify just the begin or end time,
* which may end up giving us an inverted time range, i.e. end < begin.
*/
private rationalizeTimeRange(state: State): State {
if (state.end <= state.begin) {
// If dense then just make sure begin is before end.
if (state.requestType === 1) {
state.begin = state.end - DEFAULT_RANGE_S;
} else if (this._state.begin !== state.begin) {
state.end = state.begin + DEFAULT_RANGE_S;
} else {
// They set 'end' in the URL.
state.begin = state.end - DEFAULT_RANGE_S;
}
}
return state;
}
/**
* Handler for the event when the remove paramset value button is clicked
* @param e Remove event
*/
private paramsetRemoveClick(e: CustomEvent<ParamSetSkRemoveClickEventDetail>) {
// Remove the specified value from the query
this.query?.removeKeyValue(e.detail.key, e.detail.value);
}
/**
* Handler for the event when the paramset checkbox is clicked.
* @param e Checkbox click event
*/
private paramsetCheckboxClick(e: CustomEvent<ParamSetSkCheckboxClickEventDetail>) {
if (!e.detail.selected) {
// Find the matching traces and remove them from the dataframe's traceset.
const keys: string[] = [];
Object.keys(this._dataframe.traceset).forEach((key) => {
if (_matches(key, e.detail.key, e.detail.value!)) {
keys.push(key);
}
});
this.removeKeys(keys, false);
} else {
// Adding is slightly more involved. The current dataframe may have matching traces removed,
// so we need to look at the original trace set to find matching traces. If we find any
// match, we add it to the current dataframe and then add the lines to the rendered plot.
const traceSet = TraceSet({});
Object.keys(this.originalTraceSet).forEach((key) => {
if (_matches(key, e.detail.key, e.detail.value!)) {
if (!(key in this._dataframe.traceset)) {
this._dataframe.traceset[key] = this.originalTraceSet[key];
}
traceSet[key] = this.originalTraceSet[key];
}
});
this.AddPlotLines(traceSet, []);
}
}
/**
* Adds the plot lines to the plot-simple-sk module.
* @param traceSet The traceset input.
* @param labels The xAxis labels.
*/
private AddPlotLines(traceSet: { [key: string]: number[] }, labels: tick[]) {
this.plotSimple.value?.addLines(traceSet, labels);
const dt = this.dfRepo.value?.data;
const df = this.dfRepo.value?.dataframe;
const shouldAddSummaryOptions = this._state.plotSummary && df !== undefined && dt !== undefined;
if (shouldAddSummaryOptions) {
this.addPlotSummaryOptions(df, dt);
}
}
/**
* Returns the label for the selected plot summary trace.
*/
private getPlotSummaryTraceLabel(): string {
if (this.traceKeyForSummary !== '') {
return this.traceFormatter!.formatTrace(fromKey(this.traceKeyForSummary));
}
return '';
}
/**
* Adds the option list for the plot summary selection.
* @param df The dataframe object from dataframe context.
* @param df The dataTable object from dataframe context.
*/
private addPlotSummaryOptions(df: DataFrame, dt: DataTable) {
if (!this.summaryOptionsField.value) {
return;
}
if (isSingleTrace(dt)) {
this.summaryOptionsField.value!.style.display = 'none';
return;
}
const titleObj = getTitle(dt);
let commonTitle = '';
for (const [key, value] of Object.entries(titleObj)) {
commonTitle += `${key}=${value},`;
}
// getLegend returns trace Ids which are not common in all the graphs.
// Since it is an object we convert it to the standard key format a=A,b=B,
// so that it could be fed to the traceFormatter.
//
// Also note that some values in the legend object has "-" as value which is
// used to signify a trace not having any values for a particular param.
// We ignore this.
const shortTraceIds: string[] = [];
getLegend(dt).forEach((traceObject: { [key: string]: any }) => {
let shortTraceId = '';
Object.keys(traceObject).forEach((k) => {
const v = String(traceObject[k]);
shortTraceId += v !== '-' ? `${k}=${v},` : '';
});
let formattedShortTrace = this.traceFormatter!.formatTrace(fromKey(shortTraceId));
// Since this text is just the uncommon part of trace Id we want to remove
// the label from the formatted trace id so the user doesn't get confused.
formattedShortTrace = formattedShortTrace.replace('Trace ID: ', '');
shortTraceIds.push(formattedShortTrace);
});
const displayOptions: string[] = [];
const traceIds = Object.keys(df.traceset);
shortTraceIds.forEach((shortTraceId, i) => {
const traceId = traceIds[i];
const op = `...${shortTraceId}`;
// These maps are useful to find summary drop down options from the trace key
// and vice versa. The trace key's format is constant across the tool.
// Hence these maps come in handy for that purpose. They're used primarily
// when the summary drop down changes and when a trace is selected from
// the graph.
this.summaryOptionTraceMap.set(op, traceId);
this.traceIdSummaryOptionMap.set(traceId, op);
displayOptions.push(op);
});
const formattedTitle = this.traceFormatter!.formatTrace(fromKey(commonTitle));
this.summaryOptionsField.value!.helperText = formattedTitle;
this.summaryOptionsField.value!.options = displayOptions;
this.summaryOptionsField.value!.label = 'Trace for summary bar';
}
private paramsetKeyValueClick(e: CustomEvent<ParamSetSkClickEventDetail>) {
const keys: string[] = [];
Object.keys(this._dataframe.traceset).forEach((key) => {
if (_matches(key, e.detail.key, e.detail.value!)) {
keys.push(key);
}
});
if (this.plotSimple.value) {
// Additively highlight if the ctrl key is pressed.
if (e.detail.ctrl) {
this.plotSimple.value!.highlight = this.plotSimple.value!.highlight.concat(keys);
} else {
this.plotSimple.value!.highlight = keys;
}
}
this._render();
}
private closeHelp() {
this.helpDialog!.close();
}
/** Create a FrameRequest that will fill in data specified by this._state.begin and end,
* but is not yet present in this._dataframe.
*/
private requestFrameBodyDeltaFromState(): FrameRequest {
return this.requestFrameBodyFullFromState();
}
/** Create a FrameRequest that will re-create the current state of the page. */
private requestFrameBodyFullFromState(): FrameRequest {
return this.requestFrameBodyFullForRange(this._state.begin, this._state.end);
}
/**
* Create a FrameRequest that recreates the current state of the page
* for the given range.
* @param begin Start time.
* @param end End time.
* @returns FrameRequest object.
*/
private requestFrameBodyFullForRange(begin: number, end: number): FrameRequest {
return {
begin: begin,
end: end,
num_commits: this._state.numCommits,
request_type: this._state.requestType,
formulas: this._state.formulas,
queries: this._state.queries,
keys: this._state.keys,
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
pivot:
validatePivotRequest(this._state.pivotRequest) === '' ? this._state.pivotRequest : null,
disable_filter_parent_traces: this._state.disable_filter_parent_traces,
};
}
/** Reload all the queries/formulas on the given time range. */
private rangeChangeImpl() {
if (!this._state) {
return;
}
if (
this._state.formulas.length === 0 &&
this._state.queries.length === 0 &&
this._state.keys === ''
) {
return;
}
const body = this.requestFrameBodyDeltaFromState();
if (body.begin === body.end) {
console.log('skipped fetching this dataframe because it would be empty anyways');
return;
}
this.commitTime!.textContent = '';
const switchToTab = body.formulas!.length > 0 || body.queries!.length > 0 || body.keys !== '';
this.requestFrame(body, (json) => {
if (json === null || json === undefined) {
errorMessage('Failed to find any matching traces.');
return;
}
this.dfRepo.value
?.resetWithDataframeAndRequest(json.dataframe!, json.anomalymap, body)
.then(() => {
this.addTraces(json, switchToTab);
this._render();
if (isValidSelection(this._state.selected)) {
const e = selectionToEvent(this._state.selected, this._dataframe.header);
// If the range has moved to no longer include the selected commit then
// clear the selection.
if (e.detail.x === -1) {
this.clearSelectedState();
} else {
this.traceSelected(e);
}
}
});
});
}
private zeroChangeHandler(target: MdSwitch | null) {
this._state.showZero = target!.selected;
this._stateHasChanged();
this.zeroChanged();
}
private toggleDotsHandler() {
this._state.dots = !this._state.dots;
this._stateHasChanged();
if (this.plotSimple.value) {
this.plotSimple.value.dots = this._state.dots;
}
}
private zeroChanged() {
if (!this._dataframe.header) {
return;
}
if (this._state.showZero) {
const lines: { [key: string]: number[] } = {};
lines[ZERO_NAME] = Array(this._dataframe.header.length).fill(0);
this.AddPlotLines(lines, []);
} else {
this.plotSimple.value?.deleteLines([ZERO_NAME]);
}
}
private autoRefreshHandler(target: MdSwitch | null) {
this._state.autoRefresh = target!.selected;
this._stateHasChanged();
this.autoRefreshChanged();
}
private autoRefreshChanged() {
if (!this._state.autoRefresh) {
if (this._refreshId !== -1) {
clearInterval(this._refreshId);
}
} else {
this._refreshId = window.setInterval(() => this.autoRefresh(), REFRESH_TIMEOUT);
}
}
private autoRefresh() {
// Update end to be now.
this._state.end = Math.floor(Date.now() / 1000);
const body = this.requestFrameBodyFullFromState();
const switchToTab = body.formulas!.length > 0 || body.queries!.length > 0 || body.keys !== '';
this.requestFrame(body, (json) => {
this.dfRepo.value
?.resetWithDataframeAndRequest(json.dataframe!, json.anomalymap, body)
.then(() => {
this.plotSimple.value?.removeAll();
this.addTraces(json, switchToTab);
});
});
}
/**
* Add traces to the display. Always called from within the
* this._requestFrame() callback.
*
* There are three distinct cases of incoming FrameResponse to handle in this method:
* - user panned left to incrementally load older data points to an existing query
* - user panned right to incrementally load newer data points to an existing query
* - a new page load, user made an entirely new query, or made a zoom-out request that
* includes both newer and older data points than are currently loaded.
* The first two cases mean we can merge the existing dataframe with the incoming dataframe.
* The third case means we need to replace the existing dataframe with the incoming dataframe.
*
* @param {Object} json - The parsed JSON returned from the server.
* otherwise replace them all with the new ones.
* @param {Boolean} tab - If true then switch to the Params tab.
*/
private addTraces(json: FrameResponse, tab: boolean) {
const dataframe = json.dataframe!;
if (dataframe.traceset === null || Object.keys(dataframe.traceset).length === 0) {
this.displayMode = 'display_query_only';
this._render();
return;
}
this.tracesRendered = true;
this.displayMode = json.display_mode;
this._render();
if (this.displayMode === 'display_pivot_table') {
this.pivotTable!.removeAttribute('disable_validation');
this.pivotTable!.set(
dataframe,
this.pivotControl!.pivotRequest!,
this._state.queries[0],
this._state.sort
);
return;
}
// Add in the 0-trace.
if (this._state.showZero) {
dataframe.traceset[ZERO_NAME] = Trace(Array(dataframe.header!.length).fill(0));
}
const mergedDataframe = dataframe;
this.traceKeyForSummary = '';
this.originalTraceSet = deepCopy(mergedDataframe.traceset);
const header = dataframe.header;
const selectedRange = range(header![0]!.offset, header![header!.length - 1]!.offset);
this.updateSelectedRangeWithUpdatedDataframe(selectedRange, 'commit');
// Normalize bands to be just offsets.
const bands: number[] = [];
mergedDataframe.header!.forEach((h, i) => {
if (json.skps!.indexOf(h!.offset) !== -1) {
bands.push(i);
}
});
const plot = this.plotSimple.value;
if (plot) {
plot.bands = bands;
}
// Populate the xbar if present.
if (this._state.xbaroffset !== -1) {
const xbaroffset = this._state.xbaroffset;
let xbar = -1;
mergedDataframe.header!.forEach((h, i) => {
if (h!.offset === xbaroffset) {
xbar = i;
}
});
if (plot) {
plot.xbar = xbar;
}
} else {
if (plot) {
plot.xbar = -1;
}
}
if (this.state.use_titles) {
this.updateTitle();
}
// Populate the paramset element.
this.paramset!.paramsets = [mergedDataframe.paramset as CommonSkParamSet];
if (tab) {
this.detailTab!.selected = PARAMS_TAB_INDEX;
}
this._renderedTraces();
if (this._state.plotSummary) {
const header = dataframe.header!;
this.plotSummary.value?.Select(header![0]!, header[header.length - 1]!);
this.dfRepo.value?.extendRange(-3 * monthInSec).then(() => {
// Already plotted, just need to update the data.
this.updateSelectedRangeWithUpdatedDataframe(selectedRange, 'commit', false);
});
}
}
/**
* Plot the traces that match either the current query or the current formula,
* depending on the value of plotType.
*
* @param replace - If true then replace all the traces with ones
* that match this query, otherwise add them to the current traces being
* displayed.
*
* @param plotType - The type of traces being added.
*/
add(replace: boolean, plotType: addPlotType): void {
const q = this.query!.current_query;
const f = this.formula!.value;
this.addFromQueryOrFormula(replace, plotType, q, f);
}
/**
* Returns the labels for the plot
* @param dataframe The dataframe to use for generating labels
* @returns a list of labels
*/
private getLabels(columnHeader: (ColumnHeader | null)[]): tick[] {
let labels: tick[] = [];
const dates: Date[] = [];
switch (this.state.labelMode) {
case LabelMode.CommitPosition:
columnHeader.forEach((header, i) => {
labels.push({
x: i,
text: header!.offset.toString(),
});
});
labels = fixTicksLength(labels);
break;
case LabelMode.Date:
columnHeader.forEach((header) => {
dates.push(new Date(header!.timestamp * 1000));
});
labels = ticks(dates);
break;
default:
break;
}
return labels;
}
/**
* Plot the traces that match either the given query or the given formula,
* depending on the value of plotType.
*
* @param replace - If true then replace all the traces with ones that match
* this query, otherwise add them to the current traces being displayed.
*
* @param plotType - The type of traces being added.
*/
addFromQueryOrFormula(replace: boolean, plotType: addPlotType, q: string, f: string) {
this.queryDialog!.close();
this._dialogOn = false;
if (plotType === 'query') {
if (!q || q.trim() === '') {
errorMessage('The query must not be empty.');
return;
}
} else if (plotType === 'formula') {
if (f.trim() === '') {
errorMessage('The formula must not be empty.');
return;
}
} else if (plotType === 'pivot') {
if (!q || q.trim() === '') {
errorMessage('The query must not be empty.');
return;
}
const pivotMsg = validatePivotRequest(this.pivotControl!.pivotRequest!);
if (pivotMsg !== '') {
errorMessage(pivotMsg);
return;
}
} else {
errorMessage('Unknown plotType');
return;
}
this._state.begin = this.range!.state.begin;
this._state.end = this.range!.state.end;
this._state.numCommits = this.range!.state.num_commits;
this._state.requestType = this.range!.state.request_type;
this._state.sort = '';
if (replace || plotType === 'pivot') {
this.removeAll(true);
}
this._state.pivotRequest = defaultPivotRequest();
if (plotType === 'query') {
if (this._state.queries.indexOf(q) === -1) {
this._state.queries.push(q);
}
} else if (plotType === 'formula') {
if (this._state.formulas.indexOf(f) === -1) {
this._state.formulas.push(f);
}
} else if (plotType === 'pivot') {
if (this._state.queries.indexOf(q) === -1) {
this._state.queries.push(q);
}
this._state.pivotRequest = this.pivotControl!.pivotRequest!;
}
this.applyQueryDefaultsIfMissing();
this._stateHasChanged();
const body = this.requestFrameBodyFullFromState();
this.requestFrame(body, (json) => {
this.dfRepo.value
?.resetWithDataframeAndRequest(json.dataframe!, json.anomalymap, body)
.then(() => {
this.addTraces(json, true);
});
});
}
// take a query string, and update the parameters with default values if needed
private applyDefaultsToQuery(queryString: string): string {
const paramSet = toParamSet(queryString);
for (const defaultParamKey in this._defaults?.default_param_selections) {
if (!(defaultParamKey in paramSet)) {
paramSet[defaultParamKey] = this._defaults!.default_param_selections![defaultParamKey]!;
}
}
return fromParamSet(paramSet);
}
// applyQueryDefaultsIfMissing updates the fields in the state object to
// specify the default values provided for the instance if they haven't
// been specified by the user explicitly.
private applyQueryDefaultsIfMissing() {
const updatedQueries: string[] = [];
// Check the current query to see if the default params have been specified.
// If not, add them with the default value in the instance config.
this._state.queries.forEach((query) => {
updatedQueries.push(this.applyDefaultsToQuery(query));
});
this._state.queries = updatedQueries;
// Check if the user has specified the params provided in the default url config.
// If not, add them to the state object
for (const urlKey in this._defaults?.default_url_values) {
const stringToBool = function (str: string): boolean {
return str.toLowerCase() === 'true';
};
if (this._userSpecifiedCustomizationParams.has(urlKey) === false) {
const paramValue = stringToBool(this._defaults!.default_url_values![urlKey]);
switch (urlKey) {
case 'summary':
this._state.summary = paramValue;
break;
case 'plotSummary':
this._state.plotSummary = paramValue;
break;
case 'disableMaterial':
this._state.disableMaterial = paramValue;
break;
case 'showZero':
this._state.showZero = paramValue;
break;
case 'useTestPicker':
this.useTestPicker = paramValue;
break;
case 'use_test_picker_query':
this._state.use_test_picker_query = paramValue;
this.openQueryByDefault = !paramValue;
break;
case 'enable_chart_tooltip':
this._state.enable_chart_tooltip = paramValue;
break;
case 'use_titles':
this._state.use_titles = paramValue;
break;
case 'show_google_plot':
this._state.show_google_plot = paramValue;
break;
default:
break;
}
}
}
}
/**
* Removes all traces.
*
* @param skipHistory If true then don't update the URL. Used
* in calls like _normalize() where this is just an intermediate state we
* don't want in history.
*/
private removeAll(skipHistory: boolean) {
this._state.formulas = [];
this._state.queries = [];
this._state.keys = '';
this.plotSimple.value?.removeAll();
this._dataframe.header = [];
this._dataframe.traceset = TraceSet({});
this.paramset!.paramsets = [];
this.commitTime!.textContent = '';
this.detailTab!.selected = PARAMS_TAB_INDEX;
this.displayMode = 'display_query_only';
this.tracesRendered = false;
this.graphTitle!.set(null, 0);
this.traceDetailsCopy!.style.display = 'none';
this.traceDetails!.textContent = '';
this.tooltipSelected = false;
this.disableTooltip();
this.dispatchEvent(new CustomEvent('remove-all', { bubbles: true }));
// Remove the explore object from the list in `explore-multi-sk.ts`.
const detail = { elem: this };
this.dispatchEvent(new CustomEvent('remove-explore', { detail: detail, bubbles: true }));
// force unset autorefresh so that it doesn't re-appear when we remove all the chart.
// the removeAll button from "remove all" or "X" will call invoke removeAll()
// with skipHistory = false, so state should be updated.
this._state.autoRefresh = false;
this.autoRefreshChanged();
this._render();
if (!skipHistory) {
this.clearSelectedState();
this._stateHasChanged();
}
}
/**
* When Remove Highlighted or Highlighted Only are pressed then create a
* shortcut for just the traces that are displayed.
*
* Note that removing a trace doesn't work if the trace came from a
* formula that returns multiple traces. This is a known issue that
* isn't currently worth solving.
*
* Returns the Promise that's creating the shortcut, or undefined if
* there isn't a shortcut to create.
*/
private reShortCut(keys: string[]): Promise<void> | undefined {
if (keys.length === 0) {
this._state.keys = '';
this._state.queries = [];
return undefined;
}
const state = {
keys,
};
return fetch('/_/keys/', {
method: 'POST',
body: JSON.stringify(state),
headers: {
'Content-Type': 'application/json',
},
})
.then(jsonOrThrow)
.then((json) => {
this._state.keys = json.id;
this._state.queries = [];
this.clearSelectedState();
this._stateHasChanged();
this._render();
})
.catch(errorMessage);
}
/**
* Create a shortcut for all of the traces currently being displayed.
*
* Returns the Promise that's creating the shortcut, or undefined if
* there isn't a shortcut to create.
*/
private shortcutAll(): Promise<void> | undefined {
const toShortcut: string[] = [];
Object.keys(this._dataframe.traceset).forEach((key) => {
if (key[0] === ',') {
toShortcut.push(key);
}
});
return this.reShortCut(toShortcut);
}
private async applyFuncToTraces(funcName: string) {
// Move all non-formula traces into a shortcut.
await this.shortcutAll();
// Apply the func to the shortcut traces.
let updatedFormulas: string[] = [];
if (this._state.keys !== '') {
updatedFormulas.push(`${funcName}(shortcut("${this._state.keys}"))`);
}
// Also apply the func to any existing formulas.
updatedFormulas = updatedFormulas.concat(this._state.formulas.map((f) => `${funcName}(${f})`));
this.removeAll(true);
this._state.formulas = updatedFormulas;
this._stateHasChanged();
await this.requestFrame(this.requestFrameBodyFullFromState(), (json) => {
this.addTraces(json, false);
});
}
public createGraphConfigs(traceSet: TraceSet, attribute?: string): GraphConfig[] {
const graphConfigs = [] as GraphConfig[];
Object.keys(traceSet).forEach((key) => {
const conf: GraphConfig = {
keys: '',
formulas: [],
queries: [],
};
if (key[0] === ',') {
conf.queries = [new URLSearchParams(fromKey(key, attribute)).toString()];
} else {
if (key.startsWith('special')) {
return;
}
conf.formulas = [key];
}
graphConfigs.push(conf);
});
return graphConfigs;
}
public toggleGoogleChart() {
this.state.show_google_plot = !this.state.show_google_plot;
this._render();
}
// TODO(b/377772220): When splitting a chart with multiple traces,
// this function will perform the split operation on every trace.
// If a trace is selected, the chart should only split on that trace.
// If no trace is selected, then default to splitting on every trace.
public async splitByAttribute({
detail,
}: CustomEvent<SplitChartSelectionEventDetails>): Promise<void> {
const graphConfigs: GraphConfig[] = this.createGraphConfigs(
this._dataframe.traceset,
detail.attribute
);
const newShortcut = await updateShortcut(graphConfigs);
if (newShortcut === '') {
return;
}
window.open(
`/m/?begin=${this._state.begin}&end=${this._state.end}` +
`&pageSize=${chartsPerPage}&shortcut=${newShortcut}` +
`&totalGraphs=${graphConfigs.length}` +
(this.state.show_google_plot ? `&show_google_plot=true` : ``),
'_self'
);
}
public async viewMultiGraph(): Promise<void> {
const graphConfigs: GraphConfig[] = this.createGraphConfigs(this._dataframe.traceset);
const newShortcut = await updateShortcut(graphConfigs);
if (newShortcut === '') {
return;
}
window.open(
`/m/?begin=${this._state.begin}&end=${this._state.end}` +
`&pageSize=${chartsPerPage}&shortcut=${newShortcut}` +
`&totalGraphs=${graphConfigs.length}` +
(this.state.show_google_plot ? `&show_google_plot=true` : ``),
'_self'
);
}
private queryHighlighted() {
const detail = {
key: this.plotSimple.value!.highlight[0],
};
this.dispatchEvent(new CustomEvent('populate-query', { detail: detail, bubbles: true }));
}
private removeHighlighted() {
const ids = this.plotSimple.value!.highlight;
this.removeKeys(ids, true);
}
private removeKeys(keysToRemove: string[], updateShortcut: boolean) {
const toShortcut: string[] = [];
Object.keys(this._dataframe.traceset).forEach((key) => {
if (keysToRemove.indexOf(key) !== -1) {
// Detect if it is a formula being removed.
if (this._state.formulas.indexOf(key) !== -1) {
this._state.formulas.splice(this._state.formulas.indexOf(key), 1);
}
return;
}
if (updateShortcut) {
if (key[0] === ',') {
toShortcut.push(key);
}
}
});
// Remove the traces from the traceset so they don't reappear.
keysToRemove.forEach((key) => {
if (this._dataframe.traceset[key] !== undefined) {
delete this._dataframe.traceset[key];
}
});
const plot = this.plotSimple.value;
if (plot) {
plot.deleteLines(keysToRemove);
plot.highlight = [];
}
if (!this.hasData()) {
this.displayMode = 'display_query_only';
this._render();
}
if (updateShortcut) {
this.reShortCut(toShortcut);
}
}
private highlightedOnly() {
const plot = this.plotSimple.value!;
const ids = plot.highlight;
const toremove: string[] = [];
const toShortcut: string[] = [];
let totalNonSpecialKeys = 0;
Object.keys(this._dataframe.traceset).forEach((key) => {
if (!key.startsWith('special')) {
totalNonSpecialKeys++;
}
if (ids.indexOf(key) === -1 && !key.startsWith('special')) {
// Detect if it is a formula being removed.
if (this._state.formulas.indexOf(key) !== -1) {
this._state.formulas.splice(this._state.formulas.indexOf(key), 1);
} else {
toremove.push(key);
}
return;
}
if (key[0] === ',') {
toShortcut.push(key);
}
});
if (toremove.length === totalNonSpecialKeys) {
errorMessage("At least one trace much be selected for 'Highlighted Only' to work.");
return;
}
// Remove the traces from the traceset so they don't reappear.
toremove.forEach((key) => {
delete this._dataframe.traceset[key];
});
plot.deleteLines(toremove);
plot.highlight = [];
if (!this.hasData()) {
this.displayMode = 'display_query_only';
this._render();
}
this.reShortCut(toShortcut);
}
/**
* If there are tracesets in the Dataframe and IncludeParams config has been specified, we
* update the title using only the common parameters of all present traces.
*
* If there are less than 3 common parameters, we use the default title.
*/
private updateTitle() {
const traceset = this.dfRepo.value?.dataframe.traceset;
if (traceset === null || traceset === undefined) {
return;
}
// If the params are not included in the json config key "include_params",
// we pull the paramset from the dataframe response.
// https://skia.googlesource.com/buildbot/+/refs/heads/main/perf/configs/v8-perf.json
let params = this._defaults?.include_params;
if (params === null || params === undefined) {
const paramset = this.dfRepo.value?.dataframe.paramset;
if (paramset === null || paramset === undefined) {
return;
}
params = Object.keys(paramset).sort();
}
const numTraces = Object.keys(traceset).length;
const titleEntries = new Map();
// For each param, we found out the unique values in each trace. If there's only 1 unique value,
// that means that they all share a value in common and we can add this to the title.
params!.forEach((param) => {
const uniqueValues = new Set(Object.keys(traceset).map((traceId) => fromKey(traceId)[param]));
if (uniqueValues.size === 1) {
const value = uniqueValues.values().next().value;
if (value) {
titleEntries.set(param, value);
}
}
});
if (titleEntries.size >= 3) {
this.graphTitle!.set(titleEntries, numTraces);
} else {
this.graphTitle!.set(new Map(), numTraces);
}
}
/** Common catch function for _requestFrame and _checkFrameRequestStatus. */
private catch(msg: any) {
this._requestId = '';
if (msg) {
errorMessage(msg);
}
this.percent!.textContent = '';
this.spinning = false;
}
/** @prop spinning - True if we are waiting to retrieve data from
* the server.
*/
set spinning(b: boolean) {
this._spinning = b;
if (b) {
this.displayMode = 'display_spinner';
}
this._render();
}
get spinning(): boolean {
return this._spinning;
}
/**
* Requests a new dataframe, where body is a serialized FrameRequest:
*
* {
* begin: 1448325780,
* end: 1476706336,
* formulas: ["ave(filter("name=desk_nytimes.skp&sub_result=min_ms"))"],
* queries: [
* "name=AndroidCodec_01_original.jpg_SampleSize8",
* "name=AndroidCodec_1.bmp_SampleSize8"],
* tz: "America/New_York"
* };
*
* The 'cb' callback function will be called with the decoded JSON body
* of the response once it's available.
*/
private async requestFrame(body: FrameRequest, cb: RequestFrameCallback) {
if (this._requestId !== '') {
errorMessage('There is a pending query already running.');
return;
}
this._requestId = 'About to make request';
this.spinning = true;
try {
await this.sendFrameRequest(body, cb);
} catch (msg) {
this.catch(msg);
} finally {
this.spinning = false;
this._requestId = '';
}
}
private async sendFrameRequest(body: FrameRequest, cb: RequestFrameCallback) {
body.tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const finishedProg = await startRequest(
'/_/frame/start',
body,
200,
this.spinner!,
(prog: progress.SerializedProgress) => {
this.percent!.textContent = messagesToPreString(prog.messages);
}
);
if (finishedProg.status !== 'Finished') {
throw new Error(messagesToErrorString(finishedProg.messages));
}
const msg = messageByName(finishedProg.messages, 'Message');
if (msg) {
errorMessage(msg);
}
cb(finishedProg.results as FrameResponse);
}
// Download all the displayed data as a CSV file.
private csv() {
if (this._csvBlobURL) {
URL.revokeObjectURL(this._csvBlobURL);
this._csvBlobURL = '';
}
const csvBody = dataFrameToCSV(this._dataframe);
const blob = new Blob([csvBody], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
this.csvDownload!.href = url;
this._csvBlobURL = url;
this.csvDownload!.click();
}
private isZero(n: number) {
return n === 0;
}
get state(): State {
return this._state;
}
set state(state: State) {
state = this.rationalizeTimeRange(state);
this._state = state;
this.range!.state = {
begin: this._state.begin,
end: this._state.end,
num_commits: this._state.numCommits,
request_type: this._state.requestType,
};
this._render();
if (this.plotSimple.value) {
this.plotSimple.value!.dots = this._state.dots;
}
// If there is at least one query, the use the last one to repopulate the
// query-sk dialog.
const numQueries = this._state.queries.length;
if (numQueries >= 1) {
this.query!.paramset = toParamSet(this._state.queries[numQueries - 1]);
this.query!.current_query = this._state.queries[numQueries - 1];
this.summary!.paramsets = [toParamSet(this._state.queries[numQueries - 1])];
}
this.applyQueryDefaultsIfMissing();
if (
numQueries === 0 &&
this._state.keys === '' &&
this._state.formulas.length === 0 &&
this.openQueryByDefault
) {
this.openQuery();
}
this.zeroChanged();
this.autoRefreshChanged();
this.rangeChangeImpl();
}
get openQueryByDefault(): boolean {
return this.hasAttribute('open-query-by-default');
}
set openQueryByDefault(val: boolean) {
if (val) {
this.setAttribute('open-query-by-default', '');
} else {
this.removeAttribute('open-query-by-default');
}
}
get navOpen(): boolean {
return this.hasAttribute('nav-open');
}
set navOpen(val: boolean) {
if (val) {
this.setAttribute('nav-open', '');
} else {
this.removeAttribute('nav-open');
}
}
private toggleDetails() {
this.navOpen = !this.navOpen;
this._render();
}
getTraceset(): { [key: string]: number[] } {
return this._dataframe.traceset;
}
set defaults(val: QueryConfig | null) {
this._defaults = val;
}
}
define('explore-simple-sk', ExploreSimpleSk);