blob: f9050125316a023090303cd00cb951b8adaeed0d [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';
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 '../../../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/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 '../pivot-query-sk';
import '../pivot-table-sk';
import '../plot-simple-sk';
import '../query-count-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 {
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 { join, timestampBounds } from '../dataframe';
import { TryJob_Status } from '../../../autoroll/modules/rpc';
import { CollapseSk } from '../../../elements-sk/modules/collapse-sk/collapse-sk';
import {
TraceFormatter,
GetTraceFormatter,
} from '../trace-details-formatter/traceformatter';
import { fixTicksLength, tick, ticks } from '../plot-simple-sk/ticks';
/** 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;
const STATISTIC_VALUES = ['avg', 'count', 'max', 'min', 'std', 'sum'];
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.
}
// 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 logEntry: HTMLPreElement | null = null;
private paramset: ParamSetSk | null = null;
private percent: HTMLSpanElement | null = null;
private plot: PlotSimpleSk | null = null;
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;
private pinpointJobToast: ToastSk | null = null;
private closeToastButton: HTMLButtonElement | null = null;
private bisectButton: HTMLButtonElement | null = null;
private collapseButton: HTMLButtonElement | null = null;
private collapseDetails: CollapseSk | null = null;
private traceDetails: HTMLSpanElement | null = null;
private traceFormatter: TraceFormatter | null = null;
private originalTraceSet: TraceSet = TraceSet({});
private scrollable: boolean = false;
constructor(scrollable: boolean) {
super(ExploreSimpleSk.template);
this.scrollable = scrollable;
this.traceFormatter = GetTraceFormatter();
}
private static template = (ele: ExploreSimpleSk) => html`
<div id=explore class=${ele.displayMode}>
<div id=buttons>
<button id=open_query_dialog @click=${ele.openQuery}>Query</button>
<div id=traceButtons class="hide_on_query_only hide_on_pivot_table hide_on_spinner">
<button
@click=${() => ele.removeAll(false)}
title='Remove all the traces.'>
Remove All
</button>
<button
@click=${ele.removeHighlighted}
?hidden=${!(ele.plot && ele.plot!.highlight.length)}
title='Remove all the highlighted traces.'>
Remove Highlighted
</button>
<button
@click=${ele.highlightedOnly}
?hidden=${!(ele.plot && ele.plot!.highlight.length)}
title='Remove all but the highlighted traces.'
id=highlighted-only
>
Highlighted Only
</button>
<span
title='Number of commits skipped between each point displayed.'
?hidden=${ele.isZero(ele._dataframe.skip)}
id=skip>
${ele._dataframe.skip}
</span>
<checkbox-sk
name=zero
@change=${ele.zeroChangeHandler}
?checked=${ele._state.showZero}
label='Zero'
title='Toggle the presence of the zero line.'>
</checkbox-sk>
<checkbox-sk
name=summary
@change=${ele.summaryChangeHandler}
?checked=${ele._state.summary}
label='Summary'
title='Toggle the presence of the summary pane.'>
</checkbox-sk>
<checkbox-sk
name=dots
@change=${ele.toggleDotsHandler}
?checked=${ele._state.dots}
label='Dots'
title='Toggle the presence of dots at each commit.'>
</checkbox-sk>
<checkbox-sk
name=auto
@change=${ele.autoRefreshHandler}
?checked=${ele._state.autoRefresh}
label='Auto-refresh'
title='Auto-refresh the data displayed in the graph.'>
</checkbox-sk>
<checkbox-sk
name=auto
@change=${ele.enableIncrementalDataFrameFetchHandler}
?checked=${ele._state._incremental}
label='Incremental data fetch'
title='Only fetch deltas when panning left or right.'>
</checkbox-sk>
<checkbox-sk
name=auto
@change=${ele.enableCommitLabel}
?checked=${ele._state.labelMode === LabelMode.CommitPosition}
label='Commit Label'
title='Show Commit Numbers on x axis.'
?hidden=${!window.perf.fetch_chrome_perf_anomalies}>
</checkbox-sk>
<div
id=calcButtons
class="hide_on_query_only">
<button
@click=${() => ele.applyFuncToTraces('norm')}
title='Apply norm() to all the traces.'>
Normalize
</button>
<button
@click=${() => ele.applyFuncToTraces('scale_by_avg')}
title='Apply scale_by_avg() to all the traces.'>
Scale By Avg
</button>
<button
@click=${() => {
ele.applyFuncToTraces('iqrr');
}}
title='Apply iqrr() to all the traces.'>
Remove outliers
</button>
<button
@click=${ele.csv}
title='Download all displayed data as a CSV file.'>
CSV
</button>
<a href='' target=_blank download='traces.csv' id=csv_download></a>
<button
?hidden=${!window.perf.fetch_chrome_perf_anomalies}
@click=${ele.openBisect}>
Bisect
</button>
<h3>Zoom/Pan:<h3>
<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 id=spin-overlay>
<plot-simple-sk
.summary=${ele._state.summary}
id=plot
@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>
<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>
<span id=traceDetails />
</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>
<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>
<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></commit-range-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>
<toast-sk id="pinpoint-job-toast" duration=10000>
Pinpoint bisection started: <a href=${ele.jobUrl} target=_blank>${
ele.jobId
}</a>.
<button id="hide-toast" class="action">Close</button>
</toast-sk>
`;
connectedCallback(): void {
super.connectedCallback();
if (this._initialized) {
return;
}
this._initialized = true;
this._setDefaults();
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.logEntry = this.querySelector('#logEntry');
this.paramset = this.querySelector('#paramset');
this.percent = this.querySelector('#percent');
this.plot = this.querySelector('#plot');
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-sk');
this.pinpointJobToast = this.querySelector('#pinpoint-job-toast');
this.closeToastButton = this.querySelector('#hide-toast');
this.bisectButton = this.querySelector('#bisect-button');
this.collapseButton = this.querySelector('#collapseButton');
this.collapseDetails = this.querySelector('#collapseDetails');
this.traceDetails = this.querySelector('#traceDetails');
// 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.closeToastButton!.addEventListener(
'click',
() => this.pinpointJobToast?.hide()
);
}
// 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.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;
}
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;
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.plot!.zoom;
if (zoom === null) {
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;
if (!this._state._incremental) {
this._state.requestType = 0;
}
this._stateHasChanged();
this.rangeChangeImpl();
})
.catch(errorMessage);
} else {
this.plot!.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();
}
/** Open the bisect dialog box. */
private openBisect() {
this._render();
this._dialogOn = true;
this.bisectDialog!.showModal();
}
private _setDefaults(): void {
if (this.defaults === null) {
fetch(`/_/defaults/`, {
method: 'GET',
})
.then(jsonOrThrow)
.then((json) => {
this.defaults = json;
});
}
}
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 = 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(e: CustomEvent<PlotSimpleSkTraceEventDetails>) {
this.paramset!.highlight = fromKey(e.detail.name);
this.commitTime!.textContent = new Date(
this._dataframe.header![e.detail.x]!.timestamp * 1000
).toLocaleString();
const formattedTrace = this.traceFormatter!.formatTrace(
fromKey(e.detail.name)
);
this.traceDetails!.textContent = formattedTrace;
}
/** 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] : '';
}
/** Highlight a trace when it is clicked on. */
traceSelected(e: CustomEvent<PlotSimpleSkTraceEventDetails>): void {
this.plot!.highlight = [e.detail.name];
this.plot!.xbar = e.detail.x;
this.commits!.details = [];
const x = e.detail.x;
if (x < 0) {
return;
}
// loop backwards from x until you get the next
// non MISSING_DATA_SENTINEL point.
const commit: CommitNumber = this._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._dataframe.traceset[e.detail.name];
let prevCommit: CommitNumber = CommitNumber(-1);
for (let i = x - 1; i >= 0; i--) {
if (trace![i] !== MISSING_DATA_SENTINEL) {
prevCommit = this._dataframe.header![i]!.offset as CommitNumber;
break;
}
}
// Populate the commit-range-sk element.
this.commitRangeSk!.trace = trace;
this.commitRangeSk!.commitIndex = e.detail.x;
this.commitRangeSk!.header = this._dataframe.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;
if (e.detail.name in this.plot!.anomalyDataMap) {
const anomalyData = this.plot!.anomalyDataMap[e.detail.name];
for (let i = 0; i < anomalyData.length; i++) {
if (anomalyData[i].x === e.detail.x) {
selected_anomaly = anomalyData[i].anomaly;
break;
}
}
}
const paramset = ParamSet({});
this.simpleParamset!.paramsets = [];
if (validKey(e.detail.name)) {
// Convert the trace id into a paramset to display.
const params: { [key: string]: string } = fromKey(e.detail.name);
Object.keys(params).forEach((key) => {
paramset[key] = [params[key]];
});
this.simpleParamset!.paramsets = [paramset as CommonSkParamSet];
}
this._render();
this._state.selected.name = e.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 = e.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);
}
})
.catch(errorMessage);
// Open the details section if it is currently collapsed
if (!this.navOpen) {
this.collapseButton?.click();
}
}
private clearSelectedState() {
// 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.plot!.highlight = [];
this.plot!.xbar = -1;
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.plot!.addLines(traceSet, []);
}
}
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);
}
});
// Additively highlight if the ctrl key is pressed.
if (e.detail.ctrl) {
this.plot!.highlight = this.plot!.highlight.concat(keys);
} else {
this.plot!.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 {
// Make this optional until we iron out any bugs that surface in production.
if (!this._state._incremental) {
return this.requestFrameBodyFullFromState();
}
// If there's nothing loaded in the current dataframe, go ahead and fetch the entire
// timestamp range in this._state.begin/end.
if (this._dataframe === null || this._dataframe!.header!.length === 0) {
return this.requestFrameBodyFullFromState();
}
// If the queries have changed, just fetch a full dataframe rather than try to merge
// dataframes with different queries.
const existingQueries = fromParamSet(this._dataframe.paramset)
.split('&')
.sort();
const stateQueries = this._state.queries.sort();
if (
existingQueries.length !== stateQueries.length ||
!existingQueries.every((val, i) => stateQueries[i] === val)
) {
return this.requestFrameBodyFullFromState();
}
// Now compare the existing dataframe's bounds to the begin and end values in this._state
// to determine what the fetchable difference is, if there is any.
const existingBounds = timestampBounds(this._dataframe);
const newBounds = [this._state.begin, this._state.end];
if (newBounds[0] < existingBounds[0] && newBounds[1] > existingBounds[1]) {
// Zoom out. newBounds completely contains existingBounds.
// We'd have to make two separate dataframe fetches for the data missing on either side.
// That's more complex than we want to handle yet, so just do a full data fetch instead.
return this.requestFrameBodyFullFromState();
}
if (newBounds[0] > existingBounds[1] || newBounds[1] < existingBounds[0]) {
// No overlap. newBounds is entirely outside of existingBounds.
return this.requestFrameBodyFullFromState();
}
const fetchRange = [0, 0];
if (newBounds[0] < existingBounds[0]) {
// Pan left.
fetchRange[0] = Math.min(existingBounds[0], newBounds[0]);
fetchRange[1] = Math.max(existingBounds[0], newBounds[0]);
} else if (newBounds[1] > existingBounds[1]) {
// Pan right.
fetchRange[0] = Math.min(existingBounds[1], newBounds[1]);
fetchRange[1] = Math.max(existingBounds[1], newBounds[1]);
} else {
console.error(
'unexpected new vs existing bounds condition',
existingBounds,
newBounds
);
}
return {
begin: fetchRange[0],
end: fetchRange[1],
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,
};
}
/** Create a FrameRequest that will re-create the current state of the page. */
private requestFrameBodyFullFromState(): FrameRequest {
return {
begin: this._state.begin,
end: this._state.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,
};
}
/** 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) {
errorMessage('Failed to find any matching traces.');
return;
}
// TODO(seanmccullough): Verify that the following removeAll() call isn't necessary:
// this.plot!.removeAll();
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(e: MouseEvent) {
this._state.showZero = (e.target! as HTMLInputElement).checked;
this._stateHasChanged();
this.zeroChanged();
}
private summaryChangeHandler(e: MouseEvent) {
this._state.summary = (e.target! as HTMLInputElement).checked;
this._userSpecifiedCustomizationParams.add('summary');
this._stateHasChanged();
this._render();
}
private toggleDotsHandler() {
this._state.dots = !this._state.dots;
this._stateHasChanged();
this.plot!.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.plot!.addLines(lines, []);
} else {
this.plot!.deleteLines([ZERO_NAME]);
}
}
private autoRefreshHandler(e: MouseEvent) {
this._state.autoRefresh = (e.target! as HTMLInputElement).checked;
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.plot!.removeAll();
this.addTraces(json, switchToTab);
});
}
private enableIncrementalDataFrameFetchHandler(e: MouseEvent) {
this._state._incremental = (e.target! as HTMLInputElement).checked;
this._stateHasChanged();
}
private enableCommitLabel(e: MouseEvent) {
const isCommitLabelEnabled = (e.target! as HTMLInputElement).checked;
if (isCommitLabelEnabled) {
this._state.labelMode = LabelMode.CommitPosition;
} else {
this._state.labelMode = LabelMode.Date;
}
this.plot!.removeAll();
this.plot!.addLines(
this._dataframe.traceset,
this.getLabels(this._dataframe)
);
this._stateHasChanged();
}
/**
* Wrapper for reShortcut and addTraces. It takes in a list of trace keys, creates
* a shortcut, and renders the traces into the display. If there's already traces
* being displayed, the graph will conain the union of the existing traces and the
* traces from keys.
*
* @param {string[]} keys - The list of keys to display in the frame.
*/
public async addTracesFromList(keys: string[]) {
await this.reShortCut(keys);
await this.requestFrame(this.requestFrameBodyFullFromState(), (json) => {
this.addTraces(json, false);
});
}
/**
* 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) {
// If this is data returned from a panning-triggered dataframe fetch, then
// we want to try to preserve the existing zoom *size* so the zoomed data range
// only pans left or right, without re-sizing the zoomed range.
const previousZoom = this.plot!.zoom;
const dataframe = json.dataframe!;
if (
dataframe.traceset === null ||
Object.keys(dataframe.traceset).length === 0
) {
this.displayMode = 'display_query_only';
this._render();
return;
}
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 exsitingBounds = timestampBounds(this._dataframe);
const newBounds = timestampBounds(dataframe);
let mergedDataframe = dataframe;
if (this._dataframe !== null && this._state._incremental) {
mergedDataframe = join(this._dataframe, dataframe);
}
// Note: this.plot.removeAll() was also getting called by rateChangeImpl(), immediately
// before it called this method. Why does it do this twice? Is it a bug?
this.plot!.removeAll();
const labels = this.getLabels(mergedDataframe);
// TODO(seanmccullough): verify the order of addLines and setting the zoom on this.plot.
// Turns out that with real-life dataframe sizes, this "empty the plot and add all the
// data again" generates a lot of visual noise. For the case of zoomed plots, this
// is really noticeable. It first renders the entire merged dataframe, flashes all those
// series squished into the main plot, then zooms it back in to just the previously-set
// zoom window. I'm sure this is also quite inneficient compared to just having
// the plot render only the zoomed portion on the first try here.
this.plot!.addLines(mergedDataframe.traceset, labels);
this.originalTraceSet = deepCopy(mergedDataframe.traceset);
// If there was a previously-selected zoom window, re-apply it to the new data, adjusted
// for panning.
if (this._state._incremental && previousZoom !== null) {
if (
exsitingBounds[0] > newBounds[0] &&
exsitingBounds[1] >= newBounds[1]
) {
// Pan left: Just re-use previousZoom, since it's indexed starting from 0 on the
// left and should be the same when you pan that direction.
this.plot!.zoom = previousZoom;
} else if (
exsitingBounds[1] < newBounds[1] &&
exsitingBounds[0] <= newBounds[0]
) {
// Pan right. Adjust the start and end zoom bounds because the size of dataframe
// into which we are zooming has changed, and we'll need to push the zoom window
// indexes back out to the right edge of the newly merged dataframe.
this.plot!.zoom = [
this.plot!.zoom![1] - (previousZoom![1] - previousZoom![0]),
this.plot!.zoom![1],
];
}
}
// TODO(seanmccullough): merge logic for anomalymap - without merging, the incremental
// fetch might invalidate or erase already loaded data from whatever is in anomalymap.
if (json.anomalymap !== null) {
const anomalyDataMap = getAnomalyDataMap(
mergedDataframe.traceset,
mergedDataframe.header!,
json.anomalymap
);
this.plot!.anomalyDataMap = anomalyDataMap;
}
// Normalize bands to be just offsets.
const bands: number[] = [];
mergedDataframe.header!.forEach((h, i) => {
if (json.skps!.indexOf(h!.offset) !== -1) {
bands.push(i);
}
});
this.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 (xbar !== -1) {
this.plot!.xbar = xbar;
} else {
this.plot!.xbar = -1;
}
} else {
this.plot!.xbar = -1;
}
// Populate the paramset element.
this.paramset!.paramsets = [mergedDataframe.paramset as CommonSkParamSet];
if (tab) {
this.detailTab!.selected = PARAMS_TAB_INDEX;
}
this._dataframe = mergedDataframe;
this._renderedTraces();
}
/**
* 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(dataframe: DataFrame): tick[] {
let labels: tick[] = [];
const dates: Date[] = [];
switch (this.state.labelMode) {
case LabelMode.CommitPosition:
dataframe.header!.forEach((header, i) => {
labels.push({
x: i,
text: header!.offset.toString(),
});
});
labels = fixTicksLength(labels);
break;
case LabelMode.Date:
dataframe.header!.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.
*/
private 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.addTraces(json, true);
});
}
// 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) => {
const paramSet = toParamSet(query);
for (const defaultParamKey in this.defaults?.default_param_selections) {
if (!(defaultParamKey in paramSet)) {
paramSet[defaultParamKey] =
this.defaults!.default_param_selections![defaultParamKey]!;
}
}
updatedQueries.push(fromParamSet(paramSet));
});
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) {
if (this._userSpecifiedCustomizationParams.has(urlKey) === false) {
if (urlKey === 'summary') {
this._state.summary = Boolean(
this.defaults!.default_url_values![urlKey]
);
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.plot!.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._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): 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)).toString()];
} else {
if (key.startsWith('special')) {
return;
}
conf.formulas = [key];
}
graphConfigs.push(conf);
});
return graphConfigs;
}
public async viewMultiGraph(): Promise<void> {
const pageSize = 11;
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=${pageSize}&shortcut=${newShortcut}` +
`&totalGraphs=${graphConfigs.length}`,
'_self'
);
}
private removeHighlighted() {
const ids = this.plot!.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];
}
});
this.plot!.deleteLines(keysToRemove);
this.plot!.highlight = [];
if (!this.hasData()) {
this.displayMode = 'display_query_only';
this._render();
}
if (updateShortcut) {
this.reShortCut(toShortcut);
}
}
private highlightedOnly() {
const ids = this.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];
});
this.plot!.deleteLines(toremove);
this.plot!.highlight = [];
if (!this.hasData()) {
this.displayMode = 'display_query_only';
this._render();
}
this.reShortCut(toShortcut);
}
/** 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) {
body.tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (this._requestId !== '') {
errorMessage('There is a pending query already running.');
return;
}
this._requestId = 'About to make request';
this.spinning = true;
try {
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);
} catch (msg) {
this.catch(msg);
} finally {
this.spinning = false;
this._requestId = '';
}
}
// 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();
this.plot!.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]),
];
}
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;
}
}
define('explore-simple-sk', ExploreSimpleSk);