| import { LitElement, html, css } from 'lit'; |
| import { customElement, query, state } from 'lit/decorators.js'; |
| import '@material/web/button/filled-button.js'; |
| import '@material/web/button/outlined-button.js'; |
| import '@material/web/textfield/outlined-text-field.js'; |
| import '@material/web/dialog/dialog.js'; |
| import '@material/web/icon/icon.js'; |
| import '@material/web/tabs/tabs.js'; |
| import '@material/web/tabs/primary-tab.js'; |
| import '@material/web/select/outlined-select.js'; |
| import '@material/web/select/select-option.js'; |
| |
| import type { MdDialog } from '@material/web/dialog/dialog'; |
| import type { Tabs } from '@material/web/tabs/internal/tabs.js'; |
| import type { MdOutlinedSelect } from '@material/web/select/outlined-select.js'; |
| import '../../../../elements-sk/modules/toast-sk'; |
| import type { ToastSk } from '../../../../elements-sk/modules/toast-sk/toast-sk'; |
| |
| import { |
| listBenchmarks, |
| listBots, |
| listStories, |
| schedulePairwise, |
| SchedulePairwiseRequest, |
| } from '../../services/api'; |
| |
| const MAX_ITERATIONS = 300; |
| |
| /** |
| * @element pinpoint-new-job-sk |
| * |
| * @description A modal for creating a new Pinpoint job. |
| * |
| */ |
| @customElement('pinpoint-new-job-sk') |
| export class PinpointNewJobSk extends LitElement { |
| static styles = css` |
| md-dialog { |
| max-width: 70vw; |
| max-height: 90vh; |
| } |
| |
| .modal-header { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| } |
| |
| .modal-content-container { |
| padding-top: 16px; |
| } |
| |
| .detailed-grid { |
| display: grid; |
| grid-template-columns: 2fr 1fr; |
| gap: 24px; |
| align-items: start; |
| } |
| |
| .help-section { |
| background-color: var(--md-sys-color-surface-container-lowest); |
| border-radius: 12px; |
| padding: 16px; |
| font-size: 0.875rem; |
| line-height: 1.4; |
| } |
| |
| .help-section h3 { |
| margin-top: 0; |
| margin-bottom: 8px; |
| font-size: 1rem; |
| color: var(--md-sys-color-on-surface); |
| } |
| |
| .help-section p { |
| margin: 0 0 1.5em 0; |
| color: var(--md-sys-color-on-surface-variant); |
| } |
| |
| .help-section ul { |
| padding-left: 20px; |
| margin: 1em 0; |
| } |
| .help-section li { |
| margin-bottom: 0.5em; |
| } |
| |
| .form-section { |
| display: flex; |
| flex-direction: column; |
| gap: 16px; |
| } |
| |
| .form-section h2 { |
| font-size: 1.2em; |
| font-weight: 500; |
| margin: 0; |
| padding-bottom: 8px; |
| border-bottom: 1px solid var(--md-sys-color-outline-variant); |
| } |
| |
| .form-section h3 { |
| font-size: 1em; |
| font-weight: 500; |
| margin: 8px 0 -8px 0; |
| color: var(--md-sys-color-on-surface); |
| } |
| |
| .form-section p { |
| margin: 0 0 1em 0; |
| color: var(--md-sys-color-on-surface-variant); |
| font-size: 0.9em; |
| line-height: 1.4; |
| } |
| |
| .form-section ul { |
| list-style: none; |
| padding-left: 0; |
| margin: 8px 0; |
| color: var(--md-sys-color-on-surface-variant); |
| } |
| |
| .form-section li { |
| padding: 4px 0; |
| } |
| |
| .form-section li b { |
| color: var(--md-sys-color-on-surface); |
| min-width: 120px; |
| display: inline-block; |
| } |
| |
| md-outlined-text-field, |
| md-outlined-select { |
| width: 100%; |
| } |
| |
| .about-section { |
| padding: 0 12px 24px 12px; |
| color: var(--md-sys-color-on-surface-variant); |
| } |
| .about-section h3 { |
| margin-top: 0; |
| margin-bottom: 8px; |
| font-size: 1.2em; |
| font-weight: 500; |
| color: var(--md-sys-color-on-surface); |
| } |
| |
| .simplified-view { |
| padding: 24px; |
| text-align: center; |
| color: var(--md-sys-color-on-surface-variant); |
| } |
| `; |
| |
| @query('md-dialog') private _dialog!: MdDialog; |
| |
| @query('#job-status-toast') private _toast!: ToastSk; |
| |
| @state() private _activeTab: 'simplified' | 'detailed' = 'detailed'; |
| |
| @state() private _benchmarks: string[] = []; |
| |
| @state() private _bots: string[] = []; |
| |
| @state() private _stories: string[] = []; |
| |
| @state() private _selectedBenchmark = ''; |
| |
| @state() private _selectedBot = ''; |
| |
| @state() private _selectedStory = ''; |
| |
| @state() private _startCommit = ''; |
| |
| @state() private _endCommit = ''; |
| |
| @state() private _jobName = ''; |
| |
| @state() private _iterationCount = '10'; |
| |
| @state() private _bugId = ''; |
| |
| @state() private _storyTags = ''; |
| |
| // UI state for handling the async job submission. |
| @state() private _startingJob = false; |
| |
| async connectedCallback() { |
| super.connectedCallback(); |
| try { |
| this._benchmarks = await listBenchmarks(); |
| this._bots = await listBots(''); |
| } catch (e) { |
| console.error('Failed to load initial data for new job modal', e); |
| } |
| } |
| |
| public show() { |
| this._dialog.show(); |
| } |
| |
| private close() { |
| this._dialog.close(); |
| } |
| |
| private _resetForSimplifiedView() { |
| this._selectedBenchmark = 'speedometer3.crossbench'; |
| this._selectedBot = 'mac-m1-pro-perf'; |
| this._selectedStory = 'default'; |
| this._iterationCount = '20'; |
| this._startCommit = ''; |
| this._endCommit = ''; |
| this._jobName = ''; |
| this._bugId = ''; |
| this._storyTags = ''; |
| } |
| |
| private onTabChanged(e: CustomEvent) { |
| const tabs = e.target as Tabs; |
| this._activeTab = tabs.activeTabIndex === 0 ? 'detailed' : 'simplified'; |
| if (this._activeTab === 'simplified') { |
| this._resetForSimplifiedView(); |
| } |
| } |
| |
| private async onBenchmarkChanged(e: Event) { |
| const select = e.target as MdOutlinedSelect; |
| this._selectedBenchmark = select.value; |
| this._selectedBot = ''; |
| this._selectedStory = ''; |
| this._stories = []; |
| |
| if (this._selectedBenchmark) { |
| try { |
| [this._bots, this._stories] = await Promise.all([ |
| listBots(this._selectedBenchmark), |
| listStories(this._selectedBenchmark), |
| ]); |
| } catch (err) { |
| console.error(`Failed to get bots or stories for ${this._selectedBenchmark}`, err); |
| // Fallback to all bots, stories will be empty. |
| this._bots = await listBots(''); |
| } |
| } else { |
| // No benchmark selected, get all bots and clear stories |
| this._bots = await listBots(''); |
| } |
| } |
| |
| private formIsValid(): string[] { |
| const errors: string[] = []; |
| if (this._startCommit.trim() === '') { |
| errors.push('Start commit is required.'); |
| } |
| if (this._endCommit.trim() === '') { |
| errors.push('End commit is required.'); |
| } |
| if (this._selectedBenchmark === '') { |
| errors.push('Benchmark is required.'); |
| } |
| if (this._selectedBot === '') { |
| errors.push('Device to test on is required.'); |
| } |
| |
| const iterationCount = parseInt(this._iterationCount, 10); |
| if (isNaN(iterationCount)) { |
| errors.push('Iteration count must be a number.'); |
| } else if (iterationCount < 1) { |
| errors.push('Iteration count must be at least 1.'); |
| } else if (iterationCount > MAX_ITERATIONS) { |
| errors.push(`Iteration count cannot be more than ${MAX_ITERATIONS}.`); |
| } |
| return errors; |
| } |
| |
| private async _startJob(e: Event) { |
| e.preventDefault(); |
| const validationErrors = this.formIsValid(); |
| if (validationErrors.length > 0) { |
| this._toast.textContent = validationErrors.join(' '); |
| this._toast.show(); |
| return; |
| } |
| |
| this._startingJob = true; |
| |
| // Use a default job name if the user doesn't provide one. |
| let jobName = this._jobName.trim(); |
| if (!jobName) { |
| jobName = `Try Job on ${this._selectedBenchmark} with ${this._selectedBot}`; |
| } |
| const chromeRepo = 'https://chromium.googlesource.com/chromium/src.git'; |
| |
| const request: SchedulePairwiseRequest = { |
| configuration: this._selectedBot, |
| benchmark: this._selectedBenchmark, |
| story: this._selectedStory, |
| story_tags: this._storyTags.trim() || undefined, |
| job_name: jobName, |
| bug_id: this._bugId.trim() || undefined, // Send undefined if empty |
| initial_attempt_count: this._iterationCount, |
| start_commit: { |
| main: { |
| repository: chromeRepo, |
| git_hash: this._startCommit.trim(), |
| }, |
| }, |
| end_commit: { |
| main: { |
| repository: chromeRepo, |
| git_hash: this._endCommit.trim(), |
| }, |
| }, |
| }; |
| |
| try { |
| const resp = await schedulePairwise(request); |
| const jobUrl = `/results/jobid/${resp.jobId}`; |
| |
| this._toast.innerHTML = `Successfully started job. |
| <a href="${jobUrl}" target="_blank" rel="noopener noreferrer">View Job ${resp.jobId}</a>`; |
| |
| this._toast.show(); |
| this.dispatchEvent( |
| new CustomEvent('pinpoint-job-started', { bubbles: true, composed: true }) |
| ); |
| this.close(); |
| } catch (err) { |
| const message = (err as Error).message || 'Unknown error'; |
| this._toast.textContent = `Failed to start job: ${message}`; |
| this._toast.show(); |
| } finally { |
| this._startingJob = false; |
| } |
| } |
| |
| private _onValueChanged(e: Event, property: string) { |
| (this as any)[property] = (e.target as HTMLInputElement | MdOutlinedSelect).value; |
| } |
| |
| private renderDetailedView() { |
| return html` |
| <div class="about-section"> |
| <h3>About your job</h3> |
| <p> |
| A Pinpoint job can either be a <b>bisection</b> to find a commit that caused a performance |
| regression, or a <b>try job</b> to compare performance between two commits. |
| </p> |
| </div> |
| <div class="detailed-grid"> |
| <div class="form-section"> |
| <h2>Select and customize your Chrome Build</h2> |
| <h3>Base Commit</h3> |
| <md-outlined-text-field |
| label="Commit Hash" |
| placeholder="Commit Hash" |
| .value=${this._startCommit} |
| @input=${(e: InputEvent) => |
| this._onValueChanged(e, '_startCommit')}></md-outlined-text-field> |
| <h3>Experimental Commit</h3> |
| <md-outlined-text-field |
| label="Commit Hash" |
| placeholder="Commit Hash" |
| .value=${this._endCommit} |
| @input=${(e: InputEvent) => |
| this._onValueChanged(e, '_endCommit')}></md-outlined-text-field> |
| </div> |
| <div class="help-section"> |
| <h3>Chrome Build</h3> |
| <p> |
| Provide two commit points (as git hashes) to define the range for the job. For a try |
| job, this is the A/B comparison. |
| </p> |
| </div> |
| |
| <div class="form-section"> |
| <h2>Select and configure device and benchmark to test</h2> |
| <md-outlined-select label="Benchmark" @change=${this.onBenchmarkChanged}> |
| <md-select-option></md-select-option> |
| ${this._benchmarks.map( |
| (b) => html`<md-select-option .value=${b}>${b}</md-select-option>` |
| )} |
| </md-outlined-select> |
| <md-outlined-select |
| label="Device to test on" |
| .value=${this._selectedBot} |
| @change=${(e: Event) => this._onValueChanged(e, '_selectedBot')}> |
| ${this._bots.map((b) => html`<md-select-option .value=${b}>${b}</md-select-option>`)} |
| </md-outlined-select> |
| <md-outlined-select |
| label="Story" |
| .value=${this._selectedStory} |
| ?disabled=${!this._selectedBenchmark} |
| @change=${(e: Event) => this._onValueChanged(e, '_selectedStory')}> |
| ${this._stories.map((s) => html`<md-select-option .value=${s}>${s}</md-select-option>`)} |
| </md-outlined-select> |
| <md-outlined-text-field |
| label="Story tags (optional)" |
| .value=${this._storyTags} |
| @input=${(e: InputEvent) => |
| this._onValueChanged(e, '_storyTags')}></md-outlined-text-field> |
| </div> |
| <div class="help-section"> |
| <h3>Device and Benchmark</h3> |
| <p> |
| Specify the hardware and the performance test to run. Each benchmark can have multiple |
| stories, which are specific scenarios to test. |
| </p> |
| </div> |
| |
| <div class="form-section"> |
| <h2>(Optional) Name your run and set iterations</h2> |
| <md-outlined-text-field |
| label="Job Name" |
| .value=${this._jobName} |
| @input=${(e: InputEvent) => |
| this._onValueChanged(e, '_jobName')}></md-outlined-text-field> |
| <md-outlined-text-field |
| label="Iteration Count" |
| type="number" |
| .value=${this._iterationCount} |
| @input=${(e: InputEvent) => |
| this._onValueChanged(e, '_iterationCount')}></md-outlined-text-field> |
| <md-outlined-text-field |
| label="Bug ID (optional)" |
| type="number" |
| .value=${this._bugId} |
| @input=${(e: InputEvent) => this._onValueChanged(e, '_bugId')}></md-outlined-text-field> |
| </div> |
| <div class="help-section"> |
| <h3>Job Name, Iterations & Bug ID</h3> |
| <p> |
| Give your job a memorable name for easier identification later. If left blank, a name |
| will be generated. |
| </p> |
| <p> |
| The number of iterations to run the benchmark. Higher iterations usually yield more |
| granular benchmark results. This value defaults to 10. |
| </p> |
| <p> |
| If this job is related to a bug, you can provide the bug ID here for tracking purposes. |
| </p> |
| </div> |
| </div> |
| `; |
| } |
| |
| private renderSimplifiedView() { |
| return html` |
| <div class="detailed-grid"> |
| <div class="form-section"> |
| <h2>1. Define Commit Range</h2> |
| <p> |
| A Pinpoint job can either be a <b>bisection</b> to find a commit that caused a |
| performance regression, or a <b>try job</b> to compare performance between two commits. |
| Provide two commit points to define the range for the job. |
| </p> |
| <h3>Base Commit</h3> |
| <md-outlined-text-field |
| label="Commit Hash" |
| placeholder="Commit Hash" |
| .value=${this._startCommit} |
| @input=${(e: InputEvent) => |
| this._onValueChanged(e, '_startCommit')}></md-outlined-text-field> |
| <h3>Experimental Commit</h3> |
| <md-outlined-text-field |
| label="Commit Hash" |
| placeholder="Commit Hash" |
| .value=${this._endCommit} |
| @input=${(e: InputEvent) => |
| this._onValueChanged(e, '_endCommit')}></md-outlined-text-field> |
| <h2>2. Review Test Configuration</h2> |
| <p> |
| This simplified flow uses a standard test configuration for general performance |
| analysis. For custom settings, use the "Detailed" tab. |
| </p> |
| <ul> |
| <li><b>Benchmark:</b> <span>speedometer3.crossbench</span></li> |
| <li><b>Device:</b> <span>mac-m1-pro-perf</span></li> |
| <li><b>Story:</b> <span>default</span></li> |
| <li><b>Iteration Count:</b> <span>20</span></li> |
| </ul> |
| </div> |
| <div class="help-section"> |
| <h3>What is Pinpoint?</h3> |
| <p> |
| Pinpoint is a performance testing tool for Chrome that helps diagnose regressions and |
| evaluate performance changes. It automates the process of building Chrome at different |
| revisions, running benchmarks, and comparing the results. |
| </p> |
| <p> |
| You can run two main types of jobs: |
| <ul> |
| <li><b>Try Job:</b> An A/B test that compares performance between two specific commits.</li> |
| <li><b>Bisection:</b> A binary search across a range of commits to automatically find the one that introduced a performance regression.</li> |
| </ul> |
| </p> |
| <h3>Commit Range</h3> |
| <p> |
| Provide two commit points (as git hashes) to define the job's scope. For a try job, these are your A and B points. For a bisection, this is the range to search. |
| </p> |
| <h3>Test Configuration</h3> |
| <p> |
| This simplified view uses a common, pre-selected configuration for quick testing. The |
| benchmark, device, and other parameters are fixed. If you need to test on different |
| devices or run other benchmarks, please use the "Detailed" tab. |
| </p> |
| </div> |
| </div> |
| `; |
| } |
| |
| render() { |
| return html` |
| <md-dialog> |
| <div slot="headline" class="modal-header"> |
| <span>Start a Pinpoint Job</span> |
| </div> |
| <div slot="content"> |
| <md-tabs |
| .activeIndex=${this._activeTab === 'detailed' ? 0 : 1} |
| @change=${this.onTabChanged}> |
| <md-primary-tab>Detailed</md-primary-tab> |
| <md-primary-tab>Simplified</md-primary-tab> |
| </md-tabs> |
| <div class="modal-content-container"> |
| ${this._activeTab === 'detailed' |
| ? this.renderDetailedView() |
| : this.renderSimplifiedView()} |
| </div> |
| </div> |
| <div slot="actions"> |
| <md-outlined-button @click=${this.close}>Cancel</md-outlined-button> |
| <md-filled-button @click=${this._startJob} ?disabled=${this._startingJob}> |
| ${this._startingJob ? 'Starting...' : 'Start'} |
| </md-filled-button> |
| </div> |
| </md-dialog> |
| <toast-sk id="job-status-toast"></toast-sk> |
| `; |
| } |
| } |