• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2020 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import m from 'mithril';
16import {copyToClipboard} from '../../base/clipboard';
17import {QueryResponse} from './queries';
18import {Row} from '../../trace_processor/query_result';
19import {Anchor} from '../../widgets/anchor';
20import {Button} from '../../widgets/button';
21import {Callout} from '../../widgets/callout';
22import {DetailsShell} from '../../widgets/details_shell';
23import {downloadData} from '../../base/download_utils';
24import {Router} from '../../core/router';
25import {AppImpl} from '../../core/app_impl';
26import {Trace} from '../../public/trace';
27import {MenuItem, PopupMenu} from '../../widgets/menu';
28import {Icons} from '../../base/semantic_icons';
29
30// Controls how many rows we see per page when showing paginated results.
31const ROWS_PER_PAGE = 50;
32
33interface QueryTableRowAttrs {
34  readonly trace: Trace;
35  readonly row: Row;
36  readonly columns: ReadonlyArray<string>;
37}
38
39type Numeric = bigint | number;
40
41function isIntegral(x: Row[string]): x is Numeric {
42  return (
43    typeof x === 'bigint' || (typeof x === 'number' && Number.isInteger(x))
44  );
45}
46
47function hasTs(row: Row): row is Row & {ts: Numeric} {
48  return 'ts' in row && isIntegral(row.ts);
49}
50
51function hasDur(row: Row): row is Row & {dur: Numeric} {
52  return 'dur' in row && isIntegral(row.dur);
53}
54
55function hasTrackId(row: Row): row is Row & {track_id: Numeric} {
56  return 'track_id' in row && isIntegral(row.track_id);
57}
58
59function hasSliceId(row: Row): row is Row & {slice_id: Numeric} {
60  return 'slice_id' in row && isIntegral(row.slice_id);
61}
62
63// These are properties that a row should have in order to be "slice-like",
64// insofar as it represents a time range and a track id which can be revealed
65// or zoomed-into on the timeline.
66type Sliceish = {
67  ts: Numeric;
68  dur: Numeric;
69  track_id: Numeric;
70};
71
72export function isSliceish(row: Row): row is Row & Sliceish {
73  return hasTs(row) && hasDur(row) && hasTrackId(row);
74}
75
76// Attempts to extract a slice ID from a row, or undefined if none can be found
77export function getSliceId(row: Row): number | undefined {
78  if (hasSliceId(row)) {
79    return Number(row.slice_id);
80  }
81  return undefined;
82}
83
84class QueryTableRow implements m.ClassComponent<QueryTableRowAttrs> {
85  private readonly trace: Trace;
86
87  constructor({attrs}: m.Vnode<QueryTableRowAttrs>) {
88    this.trace = attrs.trace;
89  }
90
91  view(vnode: m.Vnode<QueryTableRowAttrs>) {
92    const {row, columns} = vnode.attrs;
93    const cells = columns.map((col) => this.renderCell(col, row[col]));
94
95    // TODO(dproy): Make click handler work from analyze page.
96    if (
97      Router.parseUrl(window.location.href).page === '/viewer' &&
98      isSliceish(row)
99    ) {
100      return m(
101        'tr',
102        {
103          onclick: () => this.selectAndRevealSlice(row, false),
104          // TODO(altimin): Consider improving the logic here (e.g. delay?) to
105          // account for cases when dblclick fires late.
106          ondblclick: () => this.selectAndRevealSlice(row, true),
107          clickable: true,
108          title: 'Go to slice',
109        },
110        cells,
111      );
112    } else {
113      return m('tr', cells);
114    }
115  }
116
117  private renderCell(name: string, value: Row[string]) {
118    if (value instanceof Uint8Array) {
119      return m('td', this.renderBlob(name, value));
120    } else {
121      return m('td', `${value}`);
122    }
123  }
124
125  private renderBlob(name: string, value: Uint8Array) {
126    return m(
127      Anchor,
128      {
129        onclick: () => downloadData(`${name}.blob`, value),
130      },
131      `Blob (${value.length} bytes)`,
132    );
133  }
134
135  private selectAndRevealSlice(
136    row: Row & Sliceish,
137    switchToCurrentSelectionTab: boolean,
138  ) {
139    const sliceId = getSliceId(row);
140    if (sliceId === undefined) {
141      return;
142    }
143    this.trace.selection.selectSqlEvent('slice', sliceId, {
144      switchToCurrentSelectionTab,
145      scrollToSelection: true,
146    });
147  }
148}
149
150interface QueryTableContentAttrs {
151  readonly trace: Trace;
152  readonly columns: ReadonlyArray<string>;
153  readonly rows: ReadonlyArray<Row>;
154}
155
156class QueryTableContent implements m.ClassComponent<QueryTableContentAttrs> {
157  view({attrs}: m.CVnode<QueryTableContentAttrs>) {
158    const cols = [];
159    for (const col of attrs.columns) {
160      cols.push(m('td', col));
161    }
162    const tableHeader = m('tr', cols);
163
164    const rows = attrs.rows.map((row) => {
165      return m(QueryTableRow, {
166        trace: attrs.trace,
167        row,
168        columns: attrs.columns,
169      });
170    });
171
172    return m('table.pf-query-table', m('thead', tableHeader), m('tbody', rows));
173  }
174}
175
176interface QueryTableAttrs {
177  trace: Trace;
178  query: string;
179  resp?: QueryResponse;
180  contextButtons?: m.Child[];
181  fillParent: boolean;
182}
183
184export class QueryTable implements m.ClassComponent<QueryTableAttrs> {
185  private readonly trace: Trace;
186  private pageNumber = 0;
187
188  constructor({attrs}: m.CVnode<QueryTableAttrs>) {
189    this.trace = attrs.trace;
190  }
191
192  view({attrs}: m.CVnode<QueryTableAttrs>) {
193    const {resp, query, contextButtons = [], fillParent} = attrs;
194
195    // Clamp the page number to ensure the page count doesn't exceed the number
196    // of rows in the results.
197    if (resp) {
198      const pageCount = this.getPageCount(resp.rows.length);
199      if (this.pageNumber >= pageCount) {
200        this.pageNumber = Math.max(0, pageCount - 1);
201      }
202    } else {
203      this.pageNumber = 0;
204    }
205
206    return m(
207      DetailsShell,
208      {
209        title: this.renderTitle(resp),
210        description: query,
211        buttons: this.renderButtons(query, contextButtons, resp),
212        fillParent,
213      },
214      resp && this.renderTableContent(resp),
215    );
216  }
217
218  private getPageCount(rowCount: number) {
219    return Math.floor((rowCount - 1) / ROWS_PER_PAGE) + 1;
220  }
221
222  private getFirstRowInPage() {
223    return this.pageNumber * ROWS_PER_PAGE;
224  }
225
226  private getCountOfRowsInPage(totalRows: number) {
227    const firstRow = this.getFirstRowInPage();
228    const endStop = Math.min(firstRow + ROWS_PER_PAGE, totalRows);
229    return endStop - firstRow;
230  }
231
232  private renderTitle(resp?: QueryResponse) {
233    if (!resp) {
234      return 'Query - running';
235    }
236    const result = resp.error ? 'error' : `${resp.rows.length} rows`;
237    if (AppImpl.instance.testingMode) {
238      // Omit the duration in tests, they cause screenshot diff failures.
239      return `Query result (${result})`;
240    }
241    return `Query result (${result}) - ${resp.durationMs.toLocaleString()}ms`;
242  }
243
244  private renderButtons(
245    query: string,
246    contextButtons: m.Child[],
247    resp?: QueryResponse,
248  ) {
249    return [
250      resp && this.renderPrevNextButtons(resp),
251      contextButtons,
252      m(
253        PopupMenu,
254        {
255          trigger: m(Button, {
256            label: 'Copy',
257            rightIcon: Icons.ContextMenu,
258          }),
259        },
260        m(MenuItem, {
261          label: 'Query',
262          onclick: () => copyToClipboard(query),
263        }),
264        resp &&
265          resp.error === undefined && [
266            m(MenuItem, {
267              label: 'Result (.tsv)',
268              onclick: () => queryResponseAsTsvToClipboard(resp),
269            }),
270            m(MenuItem, {
271              label: 'Result (.md)',
272              onclick: () => queryResponseAsMarkdownToClipboard(resp),
273            }),
274          ],
275      ),
276    ];
277  }
278
279  private renderPrevNextButtons(resp: QueryResponse) {
280    const from = this.getFirstRowInPage();
281    const to = Math.min(from + this.getCountOfRowsInPage(resp.rows.length)) - 1;
282    const pageCount = this.getPageCount(resp.rows.length);
283
284    return [
285      `Showing rows ${from + 1} to ${to + 1} of ${resp.rows.length}`,
286      m(Button, {
287        label: 'Prev',
288        icon: 'skip_previous',
289        title: 'Go to previous page of results',
290        disabled: this.pageNumber === 0,
291        onclick: () => {
292          this.pageNumber = Math.max(0, this.pageNumber - 1);
293        },
294      }),
295      m(Button, {
296        label: 'Next',
297        icon: 'skip_next',
298        title: 'Go to next page of results',
299        disabled: this.pageNumber >= pageCount - 1,
300        onclick: () => {
301          this.pageNumber = Math.min(pageCount - 1, this.pageNumber + 1);
302        },
303      }),
304    ];
305  }
306
307  private renderTableContent(resp: QueryResponse) {
308    return m(
309      '.pf-query-panel',
310      resp.statementWithOutputCount > 1 &&
311        m(
312          '.pf-query-warning',
313          m(
314            Callout,
315            {icon: 'warning'},
316            `${resp.statementWithOutputCount} out of ${resp.statementCount} `,
317            'statements returned a result. ',
318            'Only the results for the last statement are displayed.',
319          ),
320        ),
321      this.renderContent(resp),
322    );
323  }
324
325  private renderContent(resp: QueryResponse) {
326    if (resp.error) {
327      return m('.query-error', `SQL error: ${resp.error}`);
328    }
329
330    // Pick out only the rows in this page.
331    const rowOffset = this.getFirstRowInPage();
332    const totalRows = this.getCountOfRowsInPage(resp.rows.length);
333    const rowsInPage: Row[] = [];
334    for (
335      let rowIndex = rowOffset;
336      rowIndex < rowOffset + totalRows;
337      ++rowIndex
338    ) {
339      rowsInPage.push(resp.rows[rowIndex]);
340    }
341
342    return m(QueryTableContent, {
343      trace: this.trace,
344      columns: resp.columns,
345      rows: rowsInPage,
346    });
347  }
348}
349
350async function queryResponseAsTsvToClipboard(
351  resp: QueryResponse,
352): Promise<void> {
353  const lines: string[][] = [];
354  lines.push(resp.columns);
355  for (const row of resp.rows) {
356    const line = [];
357    for (const col of resp.columns) {
358      const value = row[col];
359      line.push(value === null ? 'NULL' : `${value}`);
360    }
361    lines.push(line);
362  }
363  await copyToClipboard(lines.map((line) => line.join('\t')).join('\n'));
364}
365
366async function queryResponseAsMarkdownToClipboard(
367  resp: QueryResponse,
368): Promise<void> {
369  // Convert all values to strings.
370  // rows = [header, separators, ...body]
371  const rows: string[][] = [];
372  rows.push(resp.columns);
373  rows.push(resp.columns.map((_) => '---'));
374  for (const responseRow of resp.rows) {
375    rows.push(
376      resp.columns.map((responseCol) => {
377        const value = responseRow[responseCol];
378        return value === null ? 'NULL' : `${value}`;
379      }),
380    );
381  }
382
383  // Find the maximum width of each column.
384  const maxWidths: number[] = Array(resp.columns.length).fill(0);
385  for (const row of rows) {
386    for (let i = 0; i < resp.columns.length; i++) {
387      if (row[i].length > maxWidths[i]) {
388        maxWidths[i] = row[i].length;
389      }
390    }
391  }
392
393  const text = rows
394    .map((row, rowIndex) => {
395      // Pad each column to the maximum width with hyphens (separator row) or
396      // spaces (all other rows).
397      const expansionChar = rowIndex === 1 ? '-' : ' ';
398      const line: string[] = row.map(
399        (str, colIndex) =>
400          str + expansionChar.repeat(maxWidths[colIndex] - str.length),
401      );
402      return `| ${line.join(' | ')} |`;
403    })
404    .join('\n');
405
406  await copyToClipboard(text);
407}
408