| /** |
| * @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'; |
| import { |
| DataFrame, |
| RequestType, |
| ParamSet, |
| FrameRequest, |
| FrameResponse, |
| } from '../json'; |
| import { |
| PlotSimpleSkZoomEventDetails, |
| 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 { TabsSk } from 'elements-sk/tabs-sk/tabs-sk'; |
| import { |
| ParamSetSk, |
| ParamSetSkClickEventDetail, |
| } from '../../../infra-sk/modules/paramset-sk/paramset-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 { HintableObject } from 'common-sk/modules/hintable'; |
| import { MISSING_DATA_SENTINEL } from '../plot-simple-sk/plot-simple-sk'; |
| |
| // 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; |
| |
| type RequestFrameCallback = (frameResponse: FrameResponse) => void; |
| |
| // State is reflected to the URL via stateReflector. |
| class State { |
| begin: number; |
| end: number; |
| formulas: string[]; |
| queries: string[]; |
| keys: string; // The id of the shortcut to a list of trace keys. |
| xbaroffset: number; // The offset of the commit in the repo. |
| showZero: boolean; |
| autoRefresh: boolean; |
| numCommits: number; |
| requestType: RequestType; // TODO(jcgregorio) Use constants in domain-picker-sk. |
| |
| constructor() { |
| this.begin = Math.floor(Date.now() / 1000 - DEFAULT_RANGE_S); |
| this.end = Math.floor(Date.now() / 1000); |
| this.formulas = []; |
| this.queries = []; |
| this.keys = ''; // The id of the shortcut to a list of trace keys. |
| this.xbaroffset = -1; // The offset of the commit in the repo. |
| this.showZero = true; |
| this.autoRefresh = false; |
| this.numCommits = 50; |
| this.requestType = 1; // TODO(jcgregorio) Use constants in domain-picker-sk. |
| } |
| } |
| |
| // 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; |
| } |
| |
| // 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: string): { [key: string]: string } { |
| const ret: { [key: string]: string } = {}; |
| 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; |
| } |
| |
| export class ExploreSk extends ElementSk { |
| private static template = (ele: ExploreSk) => 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} |
| ?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.'> |
| 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.'>Zero</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.'>Auto-Refresh</checkbox-sk> |
| </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.'><< ${ |
| ele._numShift |
| } |
| </button> |
| <button |
| @click=${ele.shiftBoth} |
| title='Expand the display ${ |
| ele._numShift |
| } commits in both directions.'><< ${+ele._numShift} >> |
| </button> |
| <button |
| @click=${ele.shiftRight} |
| title='Move ${ |
| ele._numShift |
| } commits in the future.'> ${+ele._numShift} >> |
| </button> |
| </div> |
| <div |
| id=calcButtons |
| ?hide_if_no_data=${!ele.hasData()}> |
| <button |
| @click=${ele.norm} |
| title='Apply norm() to all the traces.'> |
| Normalize |
| </button> |
| <button |
| @click=${ele.scaleByAvg} |
| 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> |
| </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> |
| `; |
| |
| private _dataframe: DataFrame = { |
| traceset: {}, |
| header: [], |
| paramset: {}, |
| skip: 0, |
| }; |
| |
| // The state that does into the URL. |
| private state = new State(); |
| |
| // Are we waiting on data from the server. |
| private _spinning: boolean = false; |
| |
| // The id of the current frame request. Will be the empty string if there |
| // is no pending request. |
| private _requestId = ''; |
| |
| private _numShift = window.sk.perf.num_shift; |
| |
| // 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 = ''; |
| |
| // Call this anytime something in private state is changed. Will be replaced |
| // with the real function once stateReflector has been setup. |
| // tslint:disable-next-line: no-empty |
| private _stateHasChanged = () => {}; |
| |
| private _initialized: boolean = false; |
| |
| 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 paramset: ParamSetSk | null = null; |
| private percent: HTMLSpanElement | null = null; |
| private plot: PlotSimpleSk | null = null; |
| private query: QuerySk | null = null; |
| private queryCount: QueryCountSk | null = null; |
| private range: DomainPickerSk | null = null; |
| private simpleParamset: ParamSetSk | null = null; |
| private summary: ParamSetSk | null = null; |
| private traceID: HTMLSpanElement | null = null; |
| private csvDownload: HTMLAnchorElement | null = null; |
| private queryDialog: HTMLDialogElement | null = null; |
| private helpDialog: HTMLDialogElement | null = null; |
| |
| constructor() { |
| super(ExploreSk.template); |
| } |
| |
| 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.queryCount = this.querySelector('query-count-sk'); |
| this.range = this.querySelector('#range'); |
| this.simpleParamset = this.querySelector('#simple_paramset'); |
| this.summary = this.querySelector('#summary'); |
| this.traceID = this.querySelector('#trace_id'); |
| this.csvDownload = 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 = { |
| begin: this.state.begin, |
| end: this.state.end, |
| num_commits: this.state.numCommits, |
| request_type: this.state.requestType, |
| }; |
| |
| 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)); |
| } |
| |
| private keyDown(e: KeyboardEvent) { |
| // 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; |
| } |
| } |
| |
| /** |
| * 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() { |
| 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, |
| delta, |
| }; |
| } |
| |
| /** |
| * Clamp a single zoom endpoint. |
| */ |
| private clampZoomEnd(z: number): number { |
| 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. |
| */ |
| private rationalizeZoom(zoom: [number, number]) { |
| 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; |
| } |
| |
| private zoomInKey() { |
| const cz = this.getCurrentZoom(); |
| const zoom: [number, number] = [ |
| cz.zoom[0] + ZOOM_JUMP_PERCENT * cz.delta, |
| cz.zoom[1] - ZOOM_JUMP_PERCENT * cz.delta, |
| ]; |
| this.plot!.zoom = this.rationalizeZoom(zoom); |
| } |
| |
| private zoomOutKey() { |
| const cz = this.getCurrentZoom(); |
| const zoom: [number, number] = [ |
| cz.zoom[0] - ZOOM_JUMP_PERCENT * cz.delta, |
| cz.zoom[1] + ZOOM_JUMP_PERCENT * cz.delta, |
| ]; |
| this.plot!.zoom = this.rationalizeZoom(zoom); |
| } |
| |
| private zoomLeftKey() { |
| const cz = this.getCurrentZoom(); |
| const zoom: [number, number] = [ |
| cz.zoom[0] - ZOOM_JUMP_PERCENT * cz.delta, |
| cz.zoom[1] - ZOOM_JUMP_PERCENT * cz.delta, |
| ]; |
| this.plot!.zoom = this.rationalizeZoom(zoom); |
| } |
| |
| private zoomRightKey() { |
| const cz = this.getCurrentZoom(); |
| const zoom: [number, number] = [ |
| 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. |
| private hasData() { |
| return Object.keys(this._dataframe.traceset).length > 0 || this._spinning; |
| } |
| |
| // Open the query dialog box. |
| private openQuery() { |
| this.queryDialog!.showModal(); |
| } |
| |
| private paramsetChanged(e: CustomEvent<ParamSet>) { |
| this.query!.paramset = e.detail; |
| } |
| |
| 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}"`); |
| } |
| } |
| |
| // Reflect the focused trace in the paramset. |
| private plotTraceFocused(e: CustomEvent<PlotSimpleSkTraceEventDetails>) { |
| this.paramset!.highlight = toObject(e.detail.name); |
| this.traceID!.textContent = e.detail.name; |
| } |
| |
| // User has zoomed in on the graph. |
| private plotZoom(e: CustomEvent<PlotSimpleSkZoomEventDetails>) { |
| this._render(); |
| } |
| |
| // Highlight a trace when it is clicked on. |
| private traceSelected(e: CustomEvent<PlotSimpleSkTraceEventDetails>) { |
| 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: { [key: string]: string } = toObject(e.detail.name); |
| const paramset: ParamSet = {}; |
| Object.keys(params).forEach((key) => { |
| paramset[key] = [params[key]]; |
| }); |
| |
| this._render(); |
| |
| // 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.simpleParamset!.paramsets = [paramset]; |
| this.detailTab!.selected = COMMIT_TAB_INDEX; |
| this.jsonsource!.cid = commits[0]; |
| this.jsonsource!.traceid = e.detail.name; |
| }) |
| .catch(errorMessage); |
| } |
| |
| private startStateReflector() { |
| this._stateHasChanged = stateReflector( |
| () => (this.state as unknown) as HintableObject, |
| (hintableState) => { |
| let state = (hintableState as unknown) as State; |
| state = this.rationalizeTimeRange(state); |
| this.state = state; |
| this.range!.state = { |
| begin: this.state.begin, |
| end: this.state.end, |
| num_commits: this.state.numCommits, |
| request_type: this.state.requestType, |
| }; |
| |
| this._render(); |
| // If 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. |
| */ |
| 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; |
| } |
| |
| 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 shiftBoth() { |
| this.shiftImpl(-this._numShift, this._numShift); |
| } |
| |
| private shiftRight() { |
| this.shiftImpl(this._numShift, this._numShift); |
| } |
| |
| private shiftLeft() { |
| this.shiftImpl(-this._numShift, -this._numShift); |
| } |
| |
| // Change the current range by the following +/- offsets. |
| private shiftImpl(beginOffset: number, endOffset: number) { |
| const body = { |
| begin: this.state.begin, |
| begin_offset: beginOffset, |
| end: this.state.end, |
| end_offset: endOffset, |
| num_commits: this.state.numCommits, |
| request_type: this.state.requestType, |
| }; |
| 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.numCommits = json.num_commits; |
| this.rangeChangeImpl(); |
| }) |
| .catch(errorMessage); |
| } |
| |
| // 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, |
| hidden: [], |
| tz: Intl.DateTimeFormat().resolvedOptions().timeZone, |
| }; |
| } |
| |
| // 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; |
| } |
| |
| if (this.traceID) { |
| this.traceID.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(); |
| }); |
| } |
| |
| private zeroChangeHandler(e: MouseEvent) { |
| this.state.showZero = (e.target! as HTMLInputElement).checked; |
| this._stateHasChanged(); |
| this.zeroChanged(); |
| } |
| |
| 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); |
| }); |
| } |
| |
| /** |
| * 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. |
| */ |
| private addTraces(json: FrameResponse, tab: boolean) { |
| const dataframe = json.dataframe!; |
| if (dataframe.traceset === null) { |
| return; |
| } |
| |
| // Add in the 0-trace. |
| if (this.state.showZero) { |
| dataframe.traceset[ZERO_NAME] = Array(dataframe.header!.length).fill(0); |
| } |
| |
| this._dataframe = dataframe; |
| this.plot!.removeAll(); |
| const labels: Date[] = []; |
| dataframe.header!.forEach((header) => { |
| labels.push(new Date(header.timestamp * 1000)); |
| }); |
| |
| this.plot!.addLines(dataframe.traceset, labels); |
| |
| // Normalize bands to be just offsets. |
| const bands: number[] = []; |
| dataframe.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; |
| 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 replace - If true then replace all the traces with ones |
| * that match this query, otherwise add them to the current traces being |
| * displayed. |
| */ |
| private add(replace: boolean) { |
| this.queryDialog!.close(); |
| const q = this.query!.current_query; |
| if (!q || q.trim() === '') { |
| errorMessage('The query must not be empty.'); |
| 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; |
| 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 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.traceset = {}; |
| this.paramset!.paramsets = []; |
| this.traceID!.textContent = ''; |
| 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. |
| private reShortCut(keys: string[]) { |
| 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._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() { |
| const toShortcut: string[] = []; |
| |
| 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. |
| private norm() { |
| 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. |
| private scaleByAvg() { |
| 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); |
| }); |
| }); |
| } |
| |
| private removeHighlighted() { |
| const ids = this.plot!.highlight; |
| const toShortcut: string[] = []; |
| |
| 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.plot!.highlight = []; |
| this.reShortCut(toShortcut); |
| } |
| |
| private highlightedOnly() { |
| const ids = this.plot!.highlight; |
| const toremove: string[] = []; |
| const toShortcut: string[] = []; |
| |
| 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.plot!.highlight = []; |
| this.reShortCut(toShortcut); |
| } |
| |
| /** |
| * Plot the traces from the formula in this._formula.value; |
| * |
| * @param 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. |
| */ |
| private addCalculated(replace: boolean) { |
| this.queryDialog!.close(); |
| const f = this.formula!.value; |
| if (f.trim() === '') { |
| errorMessage('The formula must not be empty.'); |
| 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; |
| |
| 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. |
| private catch(msg: string) { |
| 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. |
| private 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; |
| |
| 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. |
| private checkFrameRequestStatus(cb: RequestFrameCallback) { |
| 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((frameResponse: FrameResponse) => { |
| cb(frameResponse); |
| this.catch(frameResponse.msg); |
| }) |
| .catch((msg) => this.catch(msg)); |
| } |
| }) |
| .catch((msg) => this.catch(msg)); |
| } |
| |
| // Download all the displayed data as a CSV file. |
| private csv() { |
| if (this._csvBlobURL) { |
| URL.revokeObjectURL(this._csvBlobURL); |
| this._csvBlobURL = ''; |
| } |
| const csv = []; |
| let line: (string | number)[] = ['id']; |
| this._dataframe.header!.forEach((_, i) => { |
| // TODO(jcgregorio) Look up the git hash and use that as the header. |
| line.push(i.toString()); |
| }); |
| 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(',')); |
| }); |
| const blob = new Blob([csv.join('\n')], { 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; |
| } |
| } |
| |
| define('explore-sk', ExploreSk); |