| /** |
| * @module bugs-central-sk |
| * @description <h2><code>bugs-central-sk</code></h2> |
| * |
| * <p> |
| * Displays a table with issue counts for client+source+queries. Also |
| * displays that information in charts. |
| * </p> |
| * |
| */ |
| |
| import { define } from 'elements-sk/define'; |
| import { $$ } from 'common-sk/modules/dom'; |
| import { html, TemplateResult } from 'lit-html'; |
| import { errorMessage } from 'elements-sk/errorMessage'; |
| import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow'; |
| import { stateReflector } from 'common-sk/modules/stateReflector'; |
| |
| import 'elements-sk/spinner-sk'; |
| import '../bugs-chart-sk'; |
| import '../bugs-slo-popup-sk'; |
| |
| import { HintableObject } from 'common-sk/modules/hintable'; |
| import { ElementSk } from '../../../infra-sk/modules/ElementSk'; |
| import { BugsSLOPopupSk } from '../bugs-slo-popup-sk/bugs-slo-popup-sk'; |
| import { |
| IssueCountsData, Issue, ClientSourceQueryRequest, GetChartsDataResponse, GetClientsResponse, |
| } from '../json'; |
| |
| const CLIENT_KEY_DELIMITER = ' > '; |
| |
| const SKIA_SLO_DOC = 'https://docs.google.com/document/d/1OgpX1KDDq3YkHzRJjqRHSPJ9CJ8hH0RTvMAApKVxwm8/edit'; |
| |
| function getClientKey(c: string, s: string, q: string) { |
| if (!c) { |
| return ''; |
| } if (!s) { |
| return `${c}`; |
| } if (!q) { |
| return `${c}${CLIENT_KEY_DELIMITER}${s}`; |
| } |
| return `${c}${CLIENT_KEY_DELIMITER}${s}${CLIENT_KEY_DELIMITER}${q}`; |
| } |
| |
| function breakupClientKey(clientKey: string) { |
| const ret = { |
| client: '', |
| source: '', |
| query: '', |
| }; |
| const tokens = clientKey.split(CLIENT_KEY_DELIMITER); |
| if (tokens.length === 0) { |
| // Leave all values blank. |
| } else if (tokens.length === 1) { |
| ret.client = tokens[0]; |
| } else if (tokens.length === 2) { |
| ret.client = tokens[0]; |
| ret.source = tokens[1]; |
| } else if (tokens.length === 3) { |
| ret.client = tokens[0]; |
| ret.source = tokens[1]; |
| ret.query = tokens[2]; |
| } |
| return ret; |
| } |
| |
| declare interface PriToSLOIssues{ |
| pri_to_slo_issues: Record<string, Issue[]>; |
| } |
| |
| // State is reflected to the URL via stateReflector. |
| declare interface State { |
| client: string, |
| source: string, |
| query: string, |
| } |
| |
| export class BugsCentralSk extends ElementSk { |
| public state: State = { |
| client: '', |
| source: '', |
| query: '', |
| }; |
| |
| private clients_to_counts: Record<string, IssueCountsData> = {}; |
| |
| private clients_map: Record<string, Record<string, Record<string, boolean> | null> | null> = {}; |
| |
| private open_chart_data: string = ''; |
| |
| private slo_chart_data: string = ''; |
| |
| private untriaged_chart_data: string = ''; |
| |
| private updatingData: boolean = true; |
| |
| private sloPopup: BugsSLOPopupSk | null = null; |
| |
| constructor() { |
| super(BugsCentralSk.template); |
| } |
| |
| private static template = (el: BugsCentralSk) => html` |
| <h2>${el.getTitle()}</h2> |
| <spinner-sk ?active=${el.updatingData}></spinner-sk> |
| <br/><br/> |
| <div class="charts-container"> |
| <div class="chart-div"> |
| <bugs-chart-sk chart_type='open' |
| chart_title='Bug Count' |
| data=${el.open_chart_data}> |
| </bugs-chart-sk> |
| </div> |
| <div class="chart-div"> |
| <bugs-chart-sk chart_type='slo' |
| chart_title='SLO Violations' |
| data=${el.slo_chart_data}> |
| </bugs-chart-sk> |
| </div> |
| <div class="chart-div"> |
| <bugs-chart-sk chart_type='untriaged' |
| chart_title='Untriaged Bugs' |
| data=${el.untriaged_chart_data}> |
| </bugs-chart-sk> |
| </div> |
| </div> |
| <br/><br/> |
| ${el.displayClientsTable()} |
| `; |
| |
| |
| async connectedCallback(): Promise<void> { |
| super.connectedCallback(); |
| |
| // Populate map of clients to sources to queries. |
| await this.doImpl('/_/get_clients_sources_queries', {}, async (json: GetClientsResponse) => { |
| this.clients_map = json.clients || {}; |
| }); |
| |
| // From this point on reflect the state to the URL. |
| this.startStateReflector(); |
| |
| this.updatingData = true; |
| this._render(); |
| await this.populateDataAndRender(); |
| this.updatingData = false; |
| this._render(); |
| |
| this.sloPopup = $$<BugsSLOPopupSk>('bugs-slo-popup-sk', this); |
| } |
| |
| // 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 = () => {}; |
| |
| private displayClientsTable(): TemplateResult { |
| return html` |
| <table class=client-counts> |
| <colgroup> |
| <col span="1" style="width: 58%"> |
| <col span="1" style="width: 6%"> |
| <col span="1" style="width: 6%"> |
| <col span="1" style="width: 6%"> |
| <col span="1" style="width: 6%"> |
| <col span="1" style="width: 6%"> |
| <col span="1" style="width: 6%"> |
| <col span="1" style="width: 6%"> |
| </colgroup> |
| <tr> |
| <th>Client</th> |
| <th>P0</th> |
| <th>P1</th> |
| <th>P2</th> |
| <th>P3+</th> |
| <th><a href="${SKIA_SLO_DOC}">SLO</a></th> |
| <th>Untriaged</th> |
| <th>Total</th> |
| </tr> |
| ${this.displayClientsRows()} |
| </table> |
| <bugs-slo-popup-sk></bugs-slo-popup-sk> |
| `; |
| } |
| |
| private getTitle(): TemplateResult { |
| if (!this.state.client) { |
| return html`Displaying all clients`; |
| } |
| const clientKey = getClientKey(this.state.client, this.state.source, this.state.query); |
| const clientCounts = this.clients_to_counts[clientKey]; |
| if (clientCounts && clientCounts.query_link) { |
| return html` |
| ${clientKey} |
| [ |
| <span class=query-link><a href="${clientCounts.query_link}" target=_blank>open issues</a></span>, |
| <span class=query-link><a href="${clientCounts.untriaged_query_link}" target=_blank>untriaged issues</a></span> |
| ] |
| `; |
| } |
| return html`${clientKey}`; |
| } |
| |
| private displayClientsRows(): TemplateResult[] { |
| const rowsHTML = []; |
| const clientKeys = Object.keys(this.clients_to_counts); |
| clientKeys.sort(); |
| for (let i = 0; i < clientKeys.length; i++) { |
| const clientKey = clientKeys[i]; |
| const clientKeyTokens = breakupClientKey(clientKey); |
| const clientCounts = this.clients_to_counts[clientKey]; |
| rowsHTML.push(html` |
| <tr> |
| <td @click=${() => this.clickClient(clientKeyTokens.client, clientKeyTokens.source, clientKeyTokens.query)}> |
| <span class=client-link>${clientKey}</span> |
| </td> |
| <td> |
| ${clientCounts.p0_link |
| ? html`<span class=query-link><a href="${clientCounts.p0_link}" target=_blank>${clientCounts.p0_count}</a></span>` |
| : html`${clientCounts.p0_count}`} |
| </td> |
| <td> |
| ${clientCounts.p1_link |
| ? html`<span class=query-link><a href="${clientCounts.p1_link}" target=_blank>${clientCounts.p1_count}</a></span>` |
| : html`${clientCounts.p1_count}`} |
| </td> |
| <td> |
| ${clientCounts.p2_link |
| ? html`<span class=query-link><a href="${clientCounts.p2_link}" target=_blank>${clientCounts.p2_count}</a></span>` |
| : html`${clientCounts.p2_count}`} |
| </td> |
| <td> |
| ${clientCounts.p3_and_rest_link |
| ? html`<span class=query-link><a href="${clientCounts.p3_and_rest_link}" target=_blank>${clientCounts.p3_count + clientCounts.p4_count + clientCounts.p5_count + clientCounts.p6_count}</a></span>` |
| : html`${clientCounts.p3_count + clientCounts.p4_count + clientCounts.p5_count + clientCounts.p6_count}`} |
| </td> |
| <td> |
| ${this.displaySLOTemplate(clientKeyTokens.client, clientKeyTokens.source, clientKeyTokens.query, clientCounts)} |
| </td> |
| <td> |
| ${clientCounts.untriaged_query_link |
| ? html`<span class=query-link><a href="${clientCounts.untriaged_query_link}" target=_blank>${clientCounts.untriaged_count}</a></span>` |
| : html`${clientCounts.untriaged_count}`} |
| </td> |
| <td> |
| ${clientCounts.query_link |
| ? html`<span class=query-link><a href="${clientCounts.query_link}" target=_blank>${clientCounts.open_count}</a></span>` |
| : html`${clientCounts.open_count}`} |
| </td> |
| </tr> |
| `); |
| } |
| return rowsHTML; |
| } |
| |
| private displaySLOTemplate(client: string, source: string, query: string, clientCounts: IssueCountsData): TemplateResult { |
| const sloTotal = clientCounts.p0_slo_count + clientCounts.p1_slo_count + clientCounts.p2_slo_count + clientCounts.p3_slo_count; |
| if (!client || !source || !query || sloTotal === 0) { |
| // Do not make clickable if we do not have client+source+query or if the total is 0. |
| return html`${sloTotal}`; |
| } |
| return html`<span class=slo-link @click=${() => this.displaySLOPopup(client, source, query)}>${sloTotal}</span>`; |
| } |
| |
| private async displaySLOPopup(client: string, source: string, query: string) { |
| const priToSLOIssues = await this.getSLOIssues(client, source, query); |
| this.sloPopup!.open(priToSLOIssues); |
| } |
| |
| private clickClient(client: string, source: string, query: string) { |
| this.state.client = client || ''; |
| this.state.source = source || ''; |
| this.state.query = query || ''; |
| this.stateHasChanged(); |
| this.populateDataAndRender(); |
| } |
| |
| // If client is specified and there is only one source then directly display |
| // it's queries. If there is only one query available then directly set it |
| // on the status. This saves unnecessary clicks for users. |
| // |
| // Eg: When a user clicks on 'Android' the UI would show 'Android>Buganizer'. |
| // Clicking on that would then display 'Android>Buganizer>query'. Instead of these |
| // unnecessary clicks, this function directly displays 'Android>Buganizer>query' |
| // when 'Android' is clicked. |
| private addExtraInformationToState(state: State): boolean { |
| let stateUpdated = false; |
| if (state.client && !state.source && !state.query) { |
| const sources = Object.keys(this.clients_map[state.client as string] || {}); |
| if (sources.length === 1) { |
| state.source = sources[0]; |
| stateUpdated = true; |
| const queries = Object.keys((this.clients_map[state.client as string] || {})[state.source] || {}); |
| if (queries.length === 1) { |
| state.query = queries[0]; |
| stateUpdated = true; |
| } |
| } |
| } |
| return stateUpdated; |
| } |
| |
| private startStateReflector() { |
| this.stateHasChanged = stateReflector( |
| /* getState */() => { |
| this.addExtraInformationToState(this.state); |
| return (this.state as unknown) as HintableObject; |
| }, |
| /* setState */(newState) => { |
| this.state = (newState as unknown) as State; |
| const stateUpdated = this.addExtraInformationToState(this.state); |
| if (stateUpdated) { |
| this.stateHasChanged(); |
| } |
| this.populateDataAndRender(); |
| }, |
| ); |
| } |
| |
| // Common work done for all fetch requests. |
| private async doImpl(url: string, detail: any, action: (json: any)=> void): Promise<void> { |
| try { |
| const resp = await fetch(url, { |
| body: JSON.stringify(detail), |
| headers: { |
| 'content-type': 'application/json', |
| }, |
| credentials: 'include', |
| method: 'POST', |
| }); |
| const json = await jsonOrThrow(resp); |
| action(json); |
| } catch (msg) { |
| errorMessage(msg); |
| } |
| } |
| |
| private async getSLOIssues(client: string, source: string, query: string) { |
| const detail: ClientSourceQueryRequest = { |
| client: client, |
| source: source, |
| query: query, |
| }; |
| let priToSLOIssues = {} as Record<string, Issue[]>; |
| await this.doImpl('/_/get_issues_outside_slo', detail, (json: PriToSLOIssues) => { |
| priToSLOIssues = json.pri_to_slo_issues; |
| }); |
| return priToSLOIssues; |
| } |
| |
| private async getCounts(client: string, source: string, query: string) { |
| const detail: ClientSourceQueryRequest = { |
| client: client, |
| source: source, |
| query: query, |
| }; |
| let countsData = {} as IssueCountsData; |
| await this.doImpl('/_/get_issue_counts', detail, (json: IssueCountsData) => { |
| countsData = json; |
| }); |
| return countsData; |
| } |
| |
| private async populateChartData() { |
| const detail: ClientSourceQueryRequest = { |
| client: this.state.client, |
| source: this.state.source, |
| query: this.state.query, |
| }; |
| await this.doImpl('/_/get_charts_data', detail, (json: GetChartsDataResponse) => { |
| this.open_chart_data = JSON.stringify(json.open_data); |
| this.slo_chart_data = JSON.stringify(json.slo_data); |
| this.untriaged_chart_data = JSON.stringify(json.untriaged_data); |
| }); |
| } |
| |
| private async populateDataAndRender() { |
| this.clients_to_counts = {}; |
| const c = this.state.client; |
| const s = this.state.source; |
| const q = this.state.query; |
| |
| if (!c) { |
| await Promise.all(Object.keys(this.clients_map).map(async (client) => this.clients_to_counts[getClientKey(client, '', '')] = await this.getCounts(client, '', ''))); |
| } else if (!s) { |
| await Promise.all(Object.keys(this.clients_map[c] || {}).map(async (source) => this.clients_to_counts[getClientKey(c, source, '')] = await this.getCounts(c, source, ''))); |
| } else if (!q) { |
| await Promise.all(Object.keys((this.clients_map[c] || {})[s] || {}).map(async (query) => this.clients_to_counts[getClientKey(c, s, query)] = await this.getCounts(c, s, query))); |
| } else { |
| this.clients_to_counts[getClientKey(c, s, q)] = await this.getCounts(c, s, q); |
| } |
| // Render counts as soon we have them. Rendering charts will take longer. |
| this._render(); |
| |
| // Get chart data and render. |
| await this.populateChartData(); |
| this._render(); |
| } |
| } |
| |
| define('bugs-central-sk', BugsCentralSk); |