blob: 35a7775226a2233ff040c3cf543af4a09f606f91 [file] [log] [blame] [edit]
/**
* @module modules/job-search-sk
* @description <h2><code>job-search-sk</code></h2>
*
* Provides UI for searching the jobs in the DB.
*/
import { html } from 'lit/html.js';
import { define } from '../../../elements-sk/modules/define';
import {
ParamSet,
toParamSet,
fromParamSet,
} from '../../../infra-sk/modules/query';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import {
TaskSchedulerService,
SearchJobsResponse,
SearchJobsRequest,
Job,
JobStatus,
} from '../rpc';
import '../../../elements-sk/modules/icons/delete-icon-sk';
import { $$ } from '../../../infra-sk/modules/dom';
// Names and types of search terms.
interface DisplaySearchTerm {
label: string;
type: string;
}
// TODO(borenet): Find a way not to duplicate the contents of SearchJobRequest.
const searchTerms: { [key: string]: DisplaySearchTerm } = {
name: { label: 'Name', type: 'text' },
repo: { label: 'Repo', type: 'text' },
revision: { label: 'Revision', type: 'text' },
issue: { label: 'Issue', type: 'text' },
patchset: { label: 'Patchset', type: 'text' },
buildbucketBuildId: { label: 'Buildbucket Build ID', type: 'text' },
isForce: { label: 'Manually Triggered', type: 'checkbox' },
server: { label: 'Server', type: 'text' },
status: { label: 'Status', type: 'text' },
timeStart: { label: 'Start Time', type: 'datetime-local' },
timeEnd: { label: 'End Time', type: 'datetime-local' },
};
// Display parameters for job results.
interface DisplayJobResult {
label: string;
class: string;
}
const jobStatusToLabelAndClass: { [key: string]: DisplayJobResult } = {
[JobStatus.JOB_STATUS_IN_PROGRESS]: {
label: 'in progress',
class: 'bg-in-progress',
},
[JobStatus.JOB_STATUS_SUCCESS]: {
label: 'succeeded',
class: 'bg-success',
},
[JobStatus.JOB_STATUS_FAILURE]: {
label: 'failed',
class: 'bg-failure',
},
[JobStatus.JOB_STATUS_MISHAP]: {
label: 'mishap',
class: 'bg-mishap',
},
[JobStatus.JOB_STATUS_CANCELED]: {
label: 'canceled',
class: 'bg-canceled',
},
[JobStatus.JOB_STATUS_REQUESTED]: {
label: 'requested',
class: 'bg-in-progress',
},
};
interface SearchTerm {
key: string;
value: string;
}
export class JobSearchSk extends ElementSk {
private static template = (ele: JobSearchSk) => html`
<div class="container">
<table class="searchTerms">
${Array.from(ele.searchTerms.values()).map(
(term: SearchTerm) => html`
<tr class="searchTerms">
<th>
<label for="${term.key}">
${searchTerms[term.key]!.label}
</label>
</th>
<td>
${term.key === 'status'
? html`
<select
id="${term.key}"
@change="${(ev: Event) => {
const input = (<HTMLSelectElement>ev.target)!;
term.value = input.value;
ele.updateQuery();
}}"
selected="">
${Object.entries(jobStatusToLabelAndClass).map(
([status, labelAndClass]) => html`
<option
value="${status}"
?selected="${term.value === status}">
${labelAndClass.label}
</option>
`
)}
</select>
`
: html`
<input
.id="${term.key}"
.type="${searchTerms[term.key]!.type}"
.value="${term.value}"
?checked="${
searchTerms[term.key]!.type === 'checkbox' &&
term.value === 'true'
}"
@change="${(ev: Event) => {
const input = (<HTMLInputElement>ev.target)!;
if (searchTerms[term.key]!.type === 'checkbox') {
term.value = input.checked ? 'true' : 'false';
} else {
term.value = input.value;
}
ele.updateQuery();
}}"
>
</input>
`}
</td>
<td>
<button
class="delete"
@click="${() => {
ele.searchTerms.delete(term.key);
ele._render();
ele.updateQuery();
}}">
<delete-icon-sk></delete-icon-sk>
</button>
</td>
</tr>
`
)}
<tr class="searchTerms">
<td>
<select
@change="${(ev: Event) => {
const select = <HTMLSelectElement>ev.target!;
const selected = select.value;
ele.searchTerms.set(selected, {
key: select.value,
value: '',
});
select.selectedIndex = 0;
ele._render();
ele.updateQuery();
// Auto-focus the new input field.
const inp = $$<HTMLInputElement>(`#${selected}`, ele)!;
inp?.focus();
}}">
<option disabled selected>Add Search Term</option>
${Object.entries(searchTerms)
.filter(([key, _]) => !ele.searchTerms.get(key))
.map(
([key, term]) => html`
<option .value="${key}">${term.label}</option>
`
)}
</select>
</td>
<td></td>
<td>
<button class="search action" @click="${ele.search}">Search</button>
</td>
</tr>
</table>
</div>
${ele.results && ele.results.length > 0
? html`
<div class="container">
<table>
<tr>
<th>ID</th>
<th>Name</th>
<th>Repo</th>
<th>Revision</th>
<th>Codereview Link</th>
<th>Status</th>
<th>Manually Triggered</th>
<th>Created At</th>
<th>
<button class="cancel" @click="${ele.cancelAll}">
<delete-icon-sk></delete-icon-sk>
Cancel All
</button>
</th>
</tr>
${ele.results.map(
(job: Job) => html`
<tr>
<td>
<a href="/job/${job.id}" target="_blank">${job.id}</a>
</td>
<td>${job.name}</td>
<td>
<a href="${job.repoState?.repo}" target="_blank">
${job.repoState?.repo}
</a>
</td>
<td>
<a
href="${job.repoState!.repo}/+show/${job.repoState!
.revision}"
target="_blank">
${job.repoState!.revision.substring(0, 12)}
</a>
</td>
<td>
${job.repoState?.patch?.issue &&
job.repoState?.patch?.patchset &&
job.repoState?.patch?.server
? html`
<a
href="${job.repoState?.patch?.server}/c/${job
.repoState?.patch?.issue}/${job.repoState?.patch
?.patchset}"
target="_blank"
>${job.repoState?.patch?.server}/c/${job.repoState
?.patch?.issue}/${job.repoState?.patch
?.patchset}
</a>
`
: html``}
</td>
<td class="${jobStatusToLabelAndClass[job.status]!.class}">
${jobStatusToLabelAndClass[job.status]!.label}
</td>
<td>${job.isForce ? 'true' : 'false'}</td>
<td>${job.createdAt}</td>
<td>
${job.status === JobStatus.JOB_STATUS_IN_PROGRESS ||
job.status === JobStatus.JOB_STATUS_REQUESTED
? html`
<button
class="cancel"
@click="${() => ele.cancel(job)}">
<delete-icon-sk></delete-icon-sk>
Cancel
</button>
`
: html``}
</td>
</tr>
`
)}
</table>
</div>
`
: html``}
`;
private results: Job[] = [];
private _rpc: TaskSchedulerService | null = null;
private searchTerms: Map<string, SearchTerm> = new Map();
constructor() {
super(JobSearchSk.template);
}
get rpc(): TaskSchedulerService | null {
return this._rpc;
}
set rpc(rpc: TaskSchedulerService | null) {
this._rpc = rpc;
if (this.searchTerms.size > 0) {
this.search();
}
}
connectedCallback() {
super.connectedCallback();
if (window.location.search) {
const params = toParamSet(window.location.search.substring(1));
Object.entries(params).forEach((entry: [string, string[]]) => {
const key = entry[0];
const value = entry[1][0]; // Just take the first one.
this.searchTerms.set(key, {
key: key,
value: value,
});
});
if (this.rpc) {
this.search();
}
}
this._render();
}
private updateQuery() {
const params: ParamSet = {};
this.searchTerms.forEach((term: SearchTerm) => {
params[term.key] = [term.value];
});
const newUrl = `${window.location.href.split('?')[0]}?${fromParamSet(
params
)}`;
window.history.replaceState('', '', newUrl);
}
private search() {
const req = {
buildbucketBuildId:
this.searchTerms.get('buildbucketBuildId')?.value || '',
hasBuildbucketBuildId: !!this.searchTerms.get('buildbucketBuildId'),
isForce: this.searchTerms.get('isForce')?.value === 'true',
hasIsForce: !!this.searchTerms.get('isForce'),
issue: this.searchTerms.get('issue')?.value || '',
hasIssue: !!this.searchTerms.get('issue'),
name: this.searchTerms.get('name')?.value || '',
hasName: !!this.searchTerms.get('name'),
patchset: this.searchTerms.get('patchset')?.value || '',
hasPatchset: !!this.searchTerms.get('patchset'),
repo: this.searchTerms.get('repo')?.value || '',
hasRepo: !!this.searchTerms.get('repo'),
revision: this.searchTerms.get('revision')?.value || '',
hasRevision: !!this.searchTerms.get('revision'),
status: (this.searchTerms.get('status')?.value || null) as JobStatus,
hasStatus: !!this.searchTerms.get('status'),
timeEnd: new Date(
this.searchTerms.get('timeEnd')?.value || 0
).toISOString(),
hasTimeEnd: !!this.searchTerms.get('timeEnd'),
timeStart: new Date(
this.searchTerms.get('timeStart')?.value || 0
).toISOString(),
hasTimeStart: !!this.searchTerms.get('timeStart'),
};
this.rpc!.searchJobs(req as SearchJobsRequest).then(
(resp: SearchJobsResponse) => {
this.results = resp.jobs!;
this._render();
}
);
}
private cancel(job: Job) {
this.rpc!.cancelJob({ id: job.id }).then(() => {
const result = this.results.find((result: Job) => result.id === job.id);
if (result) {
result.status = JobStatus.JOB_STATUS_CANCELED;
this._render();
}
});
}
private cancelAll() {
this.results.forEach((job: Job) => {
if (job.status === JobStatus.JOB_STATUS_IN_PROGRESS) {
this.cancel(job);
}
});
}
}
define('job-search-sk', JobSearchSk);