blob: 4fbed719a314430e8e5ec25e88c148ccc944bc30 [file] [log] [blame]
/**
* @module module/explore-sk
* @description <h2><code>explore-sk</code></h2>
*
* Main page of Perf, for exploring data.
*/
import { define } from 'elements-sk/define';
import { errorMessage } from 'elements-sk/errorMessage';
import { html } from 'lit-html';
import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow';
import { stateReflector } from 'common-sk/modules/stateReflector';
import { toParamSet } from 'common-sk/modules/query';
import dialogPolyfill from 'dialog-polyfill';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import 'elements-sk/checkbox-sk';
import 'elements-sk/icon/help-icon-sk';
import 'elements-sk/spinner-sk';
import 'elements-sk/styles/buttons';
import 'elements-sk/tabs-panel-sk';
import 'elements-sk/tabs-sk';
import '../../../infra-sk/modules/query-sk';
import '../../../infra-sk/modules/paramset-sk';
import '../commit-detail-panel-sk';
import '../domain-picker-sk';
import '../json-source-sk';
import '../plot-simple-sk';
import '../query-count-sk';
// MISSING_DATA_SENTINEL signifies a missing sample value.
//
// JSON doesn't support NaN or +/- Inf, so we need a valid float32 to signal
// missing data that also has a compact JSON representation.
//
// The mirror Go definition is in infra/go/vec32.
const MISSING_DATA_SENTINEL = 1e32;
// The trace id of the zero line, a trace of all zeros.
const ZERO_NAME = 'special_zero';
// How often to refresh if the auto-refresh checkmark is checked.
const REFRESH_TIMEOUT = 30 * 1000; // milliseconds
// The default query range in seconds.
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 = 0.1;
// The minimum length [right - left] of a zoom range.
const MIN_ZOOM_RANGE = 0.1;
// TODO(jcgregorio) Move to a 'key' module.
// Returns true if paramName=paramValue appears in the given structured key.
function _matches(key, paramName, paramValue) {
return key.indexOf(`,${paramName}=${paramValue},`) >= 0;
}
// TODO(jcgregorio) Move to a 'key' module.
// Parses the structured key and returns a populated object with all
// the param names and values.
function toObject(key) {
const ret = {};
key.split(',').forEach((s, i) => {
if (i === 0) {
return;
}
if (s === '') {
return;
}
const parts = s.split('=');
if (parts.length !== 2) {
return;
}
ret[parts[0]] = parts[1];
});
return ret;
}
const template = (ele) => html`
<div id=buttons>
<button @click=${ele._openQuery}>Query</button>
<div id=traceButtons ?hide_if_no_data=${!ele._hasData()}>
<button @click=${() => ele._removeAll(false)} title='Remove all the traces.'>Remove All</button>
<button @click=${ele._removeHighlighted} title='Remove all the highlighted traces.'>Remove Highlighted</button>
<button @click=${ele._highlightedOnly} title='Remove all but the highlighted traces.'>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.show_zero} label='Zero' title='Toggle the presence of the zero line.'>Zero</checkbox-sk>
<checkbox-sk name=auto @change=${ele._autoRefreshHandler} ?checked=${ele.state.auto_refresh} label='Auto-refresh' title='Auto-refresh the data displayed in the graph.'>Auto-Refresh</checkbox-sk>
<button @click=${ele._zoomToRange} ?disabled=${ele._zoomRange === null} title="Fit the time range to the current zoom window.">Zoom Range</button>
</div>
</div>
<div id=spin-overlay>
<plot-simple-sk
summary
width=1200
height=400
id=plot
?spinning=${ele.spinning}
@trace_selected=${ele._traceSelected}
@zoom=${ele._plotZoom}
@trace_focused=${ele._plotTraceFocused}
?hide_if_no_data=${!ele._hasData()}
>
</plot-simple-sk>
<div id=spin-container ?spinning=${ele.spinning}>
<spinner-sk id=spinner ?active=${ele.spinning}></spinner-sk>
<span id=percent></span>
</div>
</div>
<div id=bottomButtons>
<div id=shiftButtons ?hide_if_no_data=${!ele._hasData()}>
<button @click=${ele._shiftLeft} title='Move ${ele._numShift} commits in the past.'>&lt;&lt; ${ele._numShift}</button>
<button @click=${ele._shiftBoth} title='Expand the display ${ele._numShift} commits in both directions.'>&lt;&lt; ${+ele._numShift} &gt;&gt;</button>
<button @click=${ele._shiftRight} title='Move ${ele._numShift} commits in the future.'>${+ele._numShift} &gt;&gt;</button>
</div>
<div id=calcButtons ?hide_if_no_data=${!ele._hasData()}>
<button @click=${ele._normalize} title='Apply norm() to all the traces.'>Normalize</button>
<button @click=${ele._scale_by_avg} title='Apply scale_by_avg() to all the traces.'>Scale By Avg</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>
</div>
</div>
<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></paramset-sk>
<div class=query-counts>
Matches: <query-count-sk url='/_/count/' @paramset-changed=${ele._paramsetChanged}>
</query-count-sk>
</div>
<button @click=${() => ele._add(true)} class=action>Plot</button>
<button @click=${() => ele._add(false)}>Add to Plot</button>
</div>
</div>
<details>
<summary><h2>Time Range</h2></summary>
<domain-picker-sk id=range .state=${ele.state}>
</domain-picker-sk>
</details>
<details>
<summary><h2>Calculated Traces</h2></summary>
<div class=formulas>
<textarea id=formula rows=3 cols=80></textarea>
<button @click=${() => ele._addCalculated(true)}>Plot</button>
<button @click=${() => ele._addCalculated(false)}>Add to Plot</button>
<a href=/help/ target=_blank>
<help-icon-sk></help-icon-sk>
</a>
</div>
</details>
</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 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>
</dialog>
<div id=tabs ?hide_if_no_data=${!ele._hasData()}>
<tabs-sk id=detailTab>
<button>Params</button>
<button id=commitsTab disabled>Details</button>
</tabs-sk>
<tabs-panel-sk>
<div>
<p>
<b>Trace ID</b>: <span title='Trace ID' id=trace_id></span>
</p>
<paramset-sk id=paramset clickable_values @paramset-key-value-click=${ele._paramsetKeyValueClick}></paramset-sk>
</div>
<div id=details>
<paramset-sk id=simple_paramset clickable_values @paramset-key-value-click=${ele._paramsetKeyValueClick}></paramset-sk>
<div>
<commit-detail-panel-sk id=commits selectable></commit-detail-panel-sk>
<json-source-sk id=jsonsource></json-source-sk>
</div>
</div>
</tabs-panel-sk>
</div>
`;
define('explore-sk', class extends ElementSk {
constructor() {
super(template);
// The data being displayed. This is a serialized dataframe.DataFrame.
this._dataframe = {
traceset: {},
};
// The state that goes into the URL.
this.state = {
begin: Math.floor(Date.now() / 1000 - DEFAULT_RANGE_S),
end: Math.floor(Date.now() / 1000),
formulas: [],
queries: [],
keys: '', // The id of the shortcut to a list of trace keys.
xbaroffset: -1, // The offset of the commit in the repo.
show_zero: true,
auto_refresh: false,
num_commits: 50,
request_type: 1, // TODO(jcgregorio) Use constants in domain-picker-sk.
};
// Are we waiting on data from the server.
this._spinning = false;
// The id of the current frame request. Will be the empty string if there
// is no pending request.
this._requestId = '';
this._numShift = window.sk.perf.num_shift;
// The id of the interval timer if we are refreshing.
this._refreshId = -1;
// All the data converted into a CVS blob to download.
this._csvBlob = null;
// Either null if the user hasn't zoomed, or {xBegin: Date(), xEnd: Date()}.
this._zoomRange = null;
// Call this anytime something in this.state is changed. Will be replaced
// with the real function once stateReflector has been setup.
this._stateHasChanged = () => { };
}
connectedCallback() {
super.connectedCallback();
if (this._initialized) {
return;
}
this._initialized = true;
this._render();
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._paramset = this.querySelector('#paramset');
this._percent = this.querySelector('#percent');
this._plot = this.querySelector('#plot');
this._query = this.querySelector('#query');
this._query_count = this.querySelector('query-count-sk');
this._range = this.querySelector('#range');
this._simple_paramset = this.querySelector('#simple_paramset');
this._spinner = this.querySelector('#spinner');
this._summary = this.querySelector('#summary');
this._trace_id = this.querySelector('#trace_id');
this._csv_download = this.querySelector('#csv_download');
this._queryDialog = this.querySelector('#query-dialog');
dialogPolyfill.registerDialog(this._queryDialog);
this._helpDialog = this.querySelector('#help');
dialogPolyfill.registerDialog(this._helpDialog);
// Populate the query element.
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
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 = this.state;
this._query.key_order = window.sk.perf.key_order;
this._query.paramset = json.dataframe.paramset;
// Remove the paramset so it doesn't get displayed in the Params tab.
json.dataframe.paramset = {};
// From this point on reflect the state to the URL.
this._startStateReflector();
}).catch(errorMessage);
document.addEventListener('keydown', (e) => this._keyDown(e));
}
_keyDown(e) {
// Ignore IME composition events.
if (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;
}
}
/**
* @returns {Object} 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,
* }
*/
_getCurrentZoom() {
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,
delta: delta,
};
}
/**
* Clamp a single zoom endpoint.
*
* @param {Number} z - One end of a zoom range.
* @returns {Number} The value of z clamped to valid values.
*/
_clampZoomEnd(z) {
if (z < 0) {
z = 0;
}
if (z > this._dataframe.header.length - 1) {
z = this._dataframe.header.length - 1;
}
return z;
}
/**
* Fixes up the zoom range so it always make sense.
*
* @param {Array<Number>} zoom - The zoom range.
* @returns {Array<Number>} The zoom range.
*/
_rationalizeZoom(zoom) {
zoom[0] = this._clampZoomEnd(zoom[0]);
zoom[1] = this._clampZoomEnd(zoom[1]);
if (zoom[0] > zoom[1]) {
const left = zoom[0];
zoom[0] = zoom[1];
zoom[1] = left;
}
return zoom;
}
_zoomInKey() {
const cz = this._getCurrentZoom();
const zoom = [
cz.zoom[0] + ZOOM_JUMP_PERCENT * cz.delta,
cz.zoom[1] - ZOOM_JUMP_PERCENT * cz.delta,
];
this._plot.zoom = this._rationalizeZoom(zoom);
}
_zoomOutKey() {
const cz = this._getCurrentZoom();
const zoom = [
cz.zoom[0] - ZOOM_JUMP_PERCENT * cz.delta,
cz.zoom[1] + ZOOM_JUMP_PERCENT * cz.delta,
];
this._plot.zoom = this._rationalizeZoom(zoom);
}
_zoomLeftKey() {
const cz = this._getCurrentZoom();
const zoom = [
cz.zoom[0] - ZOOM_JUMP_PERCENT * cz.delta,
cz.zoom[1] - ZOOM_JUMP_PERCENT * cz.delta,
];
this._plot.zoom = this._rationalizeZoom(zoom);
}
_zoomRightKey() {
const cz = this._getCurrentZoom();
const zoom = [
cz.zoom[0] + ZOOM_JUMP_PERCENT * cz.delta,
cz.zoom[1] + ZOOM_JUMP_PERCENT * cz.delta,
];
this._plot.zoom = this._rationalizeZoom(zoom);
}
// Returns true if we have any traces to be displayed.
_hasData() {
return Object.keys(this._dataframe.traceset).length > 0 || this._spinning;
}
// Open the query dialog box.
_openQuery() {
this._queryDialog.showModal();
}
_paramsetChanged(e) {
this._query.paramset = e.detail;
}
_queryChangeDelayedHandler(e) {
this._query_count.current_query = e.detail.q;
}
// Reflect the current query to the query summary.
_queryChangeHandler(e) {
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}"`);
}
}
// Reflect the focused trace in the paramset.
_plotTraceFocused(e) {
this._paramset.highlight = toObject(e.detail.name);
this._trace_id.textContent = e.detail.name;
}
// User has zoomed in on the graph.
_plotZoom(e) {
const shouldRender = this._zoomRange === null;
this._zoomRange = e.detail;
if (shouldRender) {
this._render();
}
}
// Fit the time range to the zoom being displayed.
// Reload all the queries/formulas on the new time range.
_zoomToRange() {
this.state.begin = this._zoomRange.xBegin / 1000;
this.state.end = this._zoomRange.xEnd / 1000;
this._zoomRange = null;
this._rangeChangeImpl();
}
// Highlight a trace when it is clicked on.
_traceSelected(e) {
this._plot.highlight = [e.detail.name];
this._commits.details = [];
const x = e.detail.x;
// loop backwards from x until you get the next
// non MISSING_DATA_SENTINEL point.
const commits = [this._dataframe.header[x]];
const trace = this._dataframe.traceset[e.detail.name];
for (let i = x - 1; i >= 0; i--) {
if (trace[i] !== MISSING_DATA_SENTINEL) {
break;
}
commits.push(this._dataframe.header[i]);
}
// Convert the trace id into a paramset to display.
const params = toObject(e.detail.name);
const paramset = {};
Object.keys(params).forEach((key) => {
paramset[key] = [params[key]];
});
// Request populated commits from the server.
fetch('/_/cid/', {
method: 'POST',
body: JSON.stringify(commits),
headers: {
'Content-Type': 'application/json',
},
}).then(jsonOrThrow).then((json) => {
this._commits.details = json;
this._commitsTab.disabled = false;
this._simple_paramset.paramsets = [paramset];
this._detailTab.selected = COMMIT_TAB_INDEX;
this._jsonsource.cid = commits[0];
this._jsonsource.traceid = e.detail.name;
}).catch(errorMessage);
}
_startStateReflector() {
this._stateHasChanged = stateReflector(() => this.state, (state) => {
state = this._rationalizeTimeRange(state);
this.state = state;
this._range.state = this.state;
this._render();
// 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.current_query = this.state.queries[numQueries - 1];
this._summary.paramsets = [toParamSet(this.state.queries[numQueries - 1])];
}
this._zeroChanged();
this._autoRefreshChanged();
this._rangeChangeImpl();
});
}
/**
* 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.
*/
_rationalizeTimeRange(state) {
if (state.end <= state.begin) {
// If dense then just make sure begin is before end.
if (state.request_type === 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;
}
_paramsetKeyValueClick(e) {
const keys = [];
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;
}
}
_shiftBoth() {
this._shiftImpl(-this._numShift, this._numShift);
}
_shiftRight() {
this._shiftImpl(this._numShift, this._numShift);
}
_shiftLeft() {
this._shiftImpl(-this._numShift, -this._numShift);
}
// Change the current range by the following +/- offsets.
_shiftImpl(beginOffset, endOffset) {
const body = {
begin: this.state.begin,
begin_offset: beginOffset,
end: this.state.end,
end_offset: endOffset,
num_commits: this.state.num_commits,
request_type: this.state.request_type,
};
fetch('/_/shift/', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
},
}).then(jsonOrThrow).then((json) => {
this.state.begin = json.begin;
this.state.end = json.end;
this.state.num_commits = json.num_commits;
this._rangeChangeImpl();
}).catch(errorMessage);
}
// Create a FrameRequest that will re-create the current state of the page.
_requestFrameBodyFullFromState() {
return {
begin: this.state.begin,
end: this.state.end,
num_commits: this.state.num_commits,
request_type: this.state.request_type,
formulas: this.state.formulas,
queries: this.state.queries,
keys: this.state.keys,
};
}
// Reload all the queries/formulas on the given time range.
_rangeChangeImpl() {
if (!this.state) {
return;
}
if (this.state.formulas.length === 0 && this.state.queries.length === 0 && this.state.keys === '') {
return;
}
if (this._trace_id) {
this._trace_id.textContent = '';
}
const body = this._requestFrameBodyFullFromState();
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;
}
this._plot.removeAll();
this._addTraces(json, switchToTab);
this._render();
});
}
_zeroChangeHandler(e) {
this.state.show_zero = e.target.checked;
this._stateHasChanged();
this._zeroChanged();
}
_zeroChanged() {
if (!this._dataframe.header) {
return;
}
if (this.state.show_zero) {
const lines = {};
lines[ZERO_NAME] = Array(this._dataframe.header.length).fill(0);
this._plot.addLines(lines);
} else {
this._plot.deleteLines([ZERO_NAME]);
}
}
_autoRefreshHandler(e) {
this.state.auto_refresh = e.target.checked;
this._stateHasChanged();
this._autoRefreshChanged();
}
_autoRefreshChanged() {
if (!this.state.auto_refresh) {
if (this._refreshId !== -1) {
clearInterval(this._refreshId);
}
} else {
this._refreshId = setInterval(() => this._autoRefresh(), REFRESH_TIMEOUT);
}
}
_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);
});
}
/**
* Add traces to the display. Always called from within the
* this._requestFrame() callback.
*
* @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.
*/
_addTraces(json, tab) {
const dataframe = json.dataframe;
if (dataframe.traceset === null) {
return;
}
// Add in the 0-trace.
if (this.state.show_zero) {
dataframe.traceset[ZERO_NAME] = Array(dataframe.header.length).fill(0);
}
this._dataframe = dataframe;
this._plot.removeAll();
const labels = [];
dataframe.header.forEach((header) => {
labels.push(new Date(header.timestamp * 1000));
});
this._plot.addLines(dataframe.traceset, labels);
this._plot.bands = json.skps;
// Populate the xbar if present.
if (this.state.xbaroffset !== -1) {
const xbaroffset = this.state.xbaroffset;
let xbar = -1;
this._dataframe.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 = [dataframe.paramset];
if (tab) {
this._detailTab.selected = PARAMS_TAB_INDEX;
}
}
/**
* Plot the traces that match this._query.current_query.
*
* @param {Boolean} replace - If true then replace all the traces with ones
* that match this query, otherwise add them to the current traces being
* displayed.
*/
_add(replace) {
this._queryDialog.close();
const q = this._query.current_query;
if (!q || q.trim() === '') {
errorMessage('The query must not be empty.');
return;
}
this.state = Object.assign({}, this.state, this._range.state);
if (replace) {
this._removeAll(true);
}
if (this.state.queries.indexOf(q) === -1) {
this.state.queries.push(q);
}
const body = this._requestFrameBodyFullFromState();
this._requestFrame(body, (json) => {
this._addTraces(json, true);
});
}
/**
* Removes all traces.
*
* @param skipHistory {Boolean} - 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.
*/
_removeAll(skipHistory) {
this.state.formulas = [];
this.state.queries = [];
this.state.keys = '';
this._plot.removeAll();
this._dataframe.traceset = {};
this._paramset.paramsets = [];
this._trace_id.textContent = '';
this._zoomRange = null;
this._detailTab.selected = PARAMS_TAB_INDEX;
this._render();
if (!skipHistory) {
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.
_reShortCut(keys) {
if (keys.length === 0) {
this.state.keys = '';
this.state.queries = [];
return undefined;
}
const state = {
keys: 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._stateHasChanged();
}).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.
_shortcutAll() {
const toShortcut = [];
Object.keys(this._dataframe.traceset).forEach((key) => {
if (key[0] === ',') {
toShortcut.push(key);
}
});
return this._reShortCut(toShortcut);
}
// Apply norm() to all the traces currently being displayed.
_normalize() {
const promise = this._shortcutAll();
if (!promise) {
errorMessage('No traces to normalize.');
return;
}
promise.then(() => {
const f = `norm(shortcut("${this.state.keys}"))`;
this._removeAll(true);
const body = this._requestFrameBodyFullFromState();
Object.assign(body, {
formulas: [f],
});
this.state.formulas.push(f);
this._stateHasChanged();
this._requestFrame(body, (json) => {
this._addTraces(json, false);
});
});
}
// Apply scale_by_avg() to all the traces currently being displayed.
_scale_by_avg() {
const promise = this._shortcutAll();
if (!promise) {
errorMessage('No traces to scale.');
return;
}
promise.then(() => {
const f = `scale_by_avg(shortcut("${this.state.keys}"))`;
this._removeAll(true);
const body = this._requestFrameBodyFullFromState();
Object.assign(body, {
formulas: [f],
});
this.state.formulas.push(f);
this._stateHasChanged();
this._requestFrame(body, (json) => {
this._addTraces(json, false);
});
});
}
_removeHighlighted() {
const ids = this._plot.highlight;
const toShortcut = [];
Object.keys(this._dataframe.traceset).forEach((key) => {
if (ids.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 (key[0] === ',') {
toShortcut.push(key);
}
});
// Remove the traces from the traceset so they don't reappear.
ids.forEach((key) => {
if (this._dataframe.traceset[key] !== undefined) {
delete this._dataframe.traceset[key];
}
});
this._plot.deleteLines(ids);
this._reShortCut(toShortcut);
}
_highlightedOnly() {
const ids = this._plot.highlight;
const toremove = [];
const toShortcut = [];
Object.keys(this._dataframe.traceset).forEach((key) => {
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);
}
});
// Remove the traces from the traceset so they don't reappear.
toremove.forEach((key) => {
delete this._dataframe.traceset[key];
});
this._plot.deleteLines(toremove);
this._reShortCut(toShortcut);
}
/**
* Plot the traces from the formula in this._formula.value;
*
* @param {Boolean} replace - If true then replace all the traces with the
* calculated traces from this formula, otherwise add the calculated traces to
* the current traces being displayed.
*/
_addCalculated(replace) {
this._queryDialog.close();
const f = this._formula.value;
if (f.trim() === '') {
errorMessage('The formula must not be empty.');
return;
}
this.state = Object.assign({}, this.state, this._range.state);
if (replace) {
this._removeAll(true);
}
if (this.state.formulas.indexOf(f) === -1) {
this.state.formulas.push(f);
}
const body = this._requestFrameBodyFullFromState();
this._requestFrame(body, (json) => {
this._addTraces(json, false);
});
}
// Common catch function for _requestFrame and _checkFrameRequestStatus.
_catch(msg) {
this._requestId = '';
if (msg) {
errorMessage(msg, 10000);
}
this._percent.textContent = '';
this.spinning = false;
}
/** @prop {Boolean} spinning - True if we are waiting to retrieve data from
* the server.
*/
set spinning(b) {
this._spinning = b;
this._render();
}
get spinning() {
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"))"],
// hidden: [],
// 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.
_requestFrame(body, cb) {
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;
this._stateHasChanged();
fetch('/_/frame/start', {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
},
}).then(jsonOrThrow).then((json) => {
this._requestId = json.id;
this._checkFrameRequestStatus(cb);
}).catch((msg) => this._catch(msg));
}
// Periodically check the status of a pending FrameRequest, calling the
// 'cb' callback with the decoded JSON upon success.
_checkFrameRequestStatus(cb) {
fetch(`/_/frame/status/${this._requestId}`, {
method: 'GET',
}).then(jsonOrThrow).then((json) => {
if (json.state === 'Running') {
this._percent.textContent = `${Math.floor(json.percent * 100)}%`;
window.setTimeout(() => this._checkFrameRequestStatus(cb), 300);
} else {
fetch(`/_/frame/results/${this._requestId}`, {
method: 'GET',
}).then(jsonOrThrow).then((json) => {
cb(json);
this._catch(json.msg);
}).catch((msg) => this._catch(msg));
}
}).catch((msg) => this._catch(msg));
}
// Download all the displayed data as a CSV file.
_csv() {
if (this._csvBlob) {
URL.revokeObjectURL(this._csvBlob);
this._csvBlob = null;
}
const csv = [];
let line = ['id'];
this._dataframe.header.forEach((_, i) => {
// TODO(jcgregorio) Look up the git hash and use that as the header.
line.push(i);
});
csv.push(line.join(','));
Object.keys(this._dataframe.traceset).forEach((traceId) => {
if (traceId === ZERO_NAME) {
return;
}
line = [`"${traceId}"`];
this._dataframe.traceset[traceId].forEach((f) => {
if (f !== MISSING_DATA_SENTINEL) {
line.push(f);
} else {
line.push('');
}
});
csv.push(line.join(','));
});
this._csvBlob = new Blob([csv.join('\n')], { type: 'text/csv' });
this._csv_download.href = URL.createObjectURL(this._csvBlob);
this._csv_download.click();
}
_isZero(n) {
return n === 0;
}
});