xref: /aosp_15_r20/external/perfetto/ui/src/components/query_table/query_table.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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 {BigintMath} from '../../base/bigint_math';
17import {copyToClipboard} from '../../base/clipboard';
18import {isString} from '../../base/object_utils';
19import {Time} from '../../base/time';
20import {QueryResponse} from './queries';
21import {Row} from '../../trace_processor/query_result';
22import {Anchor} from '../../widgets/anchor';
23import {Button} from '../../widgets/button';
24import {Callout} from '../../widgets/callout';
25import {DetailsShell} from '../../widgets/details_shell';
26import {downloadData} from '../../base/download_utils';
27import {Router} from '../../core/router';
28import {scrollTo} from '../../public/scroll_helper';
29import {AppImpl} from '../../core/app_impl';
30import {Trace} from '../../public/trace';
31
32interface QueryTableRowAttrs {
33  trace: Trace;
34  row: Row;
35  columns: string[];
36}
37
38type Numeric = bigint | number;
39
40function isIntegral(x: Row[string]): x is Numeric {
41  return (
42    typeof x === 'bigint' || (typeof x === 'number' && Number.isInteger(x))
43  );
44}
45
46function hasTs(row: Row): row is Row & {ts: Numeric} {
47  return 'ts' in row && isIntegral(row.ts);
48}
49
50function hasDur(row: Row): row is Row & {dur: Numeric} {
51  return 'dur' in row && isIntegral(row.dur);
52}
53
54function hasTrackId(row: Row): row is Row & {track_id: Numeric} {
55  return 'track_id' in row && isIntegral(row.track_id);
56}
57
58function hasType(row: Row): row is Row & {type: string} {
59  return 'type' in row && isString(row.type);
60}
61
62function hasId(row: Row): row is Row & {id: Numeric} {
63  return 'id' in row && isIntegral(row.id);
64}
65
66function hasSliceId(row: Row): row is Row & {slice_id: Numeric} {
67  return 'slice_id' in row && isIntegral(row.slice_id);
68}
69
70// These are properties that a row should have in order to be "slice-like",
71// insofar as it represents a time range and a track id which can be revealed
72// or zoomed-into on the timeline.
73type Sliceish = {
74  ts: Numeric;
75  dur: Numeric;
76  track_id: Numeric;
77};
78
79export function isSliceish(row: Row): row is Row & Sliceish {
80  return hasTs(row) && hasDur(row) && hasTrackId(row);
81}
82
83// Attempts to extract a slice ID from a row, or undefined if none can be found
84export function getSliceId(row: Row): number | undefined {
85  if (hasType(row) && row.type.includes('slice')) {
86    if (hasId(row)) {
87      return Number(row.id);
88    }
89  } else {
90    if (hasSliceId(row)) {
91      return Number(row.slice_id);
92    }
93  }
94  return undefined;
95}
96
97class QueryTableRow implements m.ClassComponent<QueryTableRowAttrs> {
98  private readonly trace: Trace;
99
100  constructor({attrs}: m.Vnode<QueryTableRowAttrs>) {
101    this.trace = attrs.trace;
102  }
103
104  view(vnode: m.Vnode<QueryTableRowAttrs>) {
105    const {row, columns} = vnode.attrs;
106    const cells = columns.map((col) => this.renderCell(col, row[col]));
107
108    // TODO(dproy): Make click handler work from analyze page.
109    if (
110      Router.parseUrl(window.location.href).page === '/viewer' &&
111      isSliceish(row)
112    ) {
113      return m(
114        'tr',
115        {
116          onclick: () => this.selectAndRevealSlice(row, false),
117          // TODO(altimin): Consider improving the logic here (e.g. delay?) to
118          // account for cases when dblclick fires late.
119          ondblclick: () => this.selectAndRevealSlice(row, true),
120          clickable: true,
121          title: 'Go to slice',
122        },
123        cells,
124      );
125    } else {
126      return m('tr', cells);
127    }
128  }
129
130  private renderCell(name: string, value: Row[string]) {
131    if (value instanceof Uint8Array) {
132      return m('td', this.renderBlob(name, value));
133    } else {
134      return m('td', `${value}`);
135    }
136  }
137
138  private renderBlob(name: string, value: Uint8Array) {
139    return m(
140      Anchor,
141      {
142        onclick: () => downloadData(`${name}.blob`, value),
143      },
144      `Blob (${value.length} bytes)`,
145    );
146  }
147
148  private selectAndRevealSlice(
149    row: Row & Sliceish,
150    switchToCurrentSelectionTab: boolean,
151  ) {
152    const trackId = Number(row.track_id);
153    const sliceStart = Time.fromRaw(BigInt(row.ts));
154    // row.dur can be negative. Clamp to 1ns.
155    const sliceDur = BigintMath.max(BigInt(row.dur), 1n);
156    const trackUri = this.trace.tracks.findTrack((td) =>
157      td.tags?.trackIds?.includes(trackId),
158    )?.uri;
159    if (trackUri !== undefined) {
160      scrollTo({
161        track: {uri: trackUri, expandGroup: true},
162        time: {start: sliceStart, end: Time.add(sliceStart, sliceDur)},
163      });
164      const sliceId = getSliceId(row);
165      if (sliceId !== undefined) {
166        this.selectSlice(sliceId, switchToCurrentSelectionTab);
167      }
168    }
169  }
170
171  private selectSlice(sliceId: number, switchToCurrentSelectionTab: boolean) {
172    this.trace.selection.selectSqlEvent('slice', sliceId, {
173      switchToCurrentSelectionTab,
174      scrollToSelection: true,
175    });
176  }
177}
178
179interface QueryTableContentAttrs {
180  trace: Trace;
181  resp: QueryResponse;
182}
183
184class QueryTableContent implements m.ClassComponent<QueryTableContentAttrs> {
185  private previousResponse?: QueryResponse;
186
187  onbeforeupdate(vnode: m.CVnode<QueryTableContentAttrs>) {
188    return vnode.attrs.resp !== this.previousResponse;
189  }
190
191  view(vnode: m.CVnode<QueryTableContentAttrs>) {
192    const resp = vnode.attrs.resp;
193    this.previousResponse = resp;
194    const cols = [];
195    for (const col of resp.columns) {
196      cols.push(m('td', col));
197    }
198    const tableHeader = m('tr', cols);
199
200    const rows = resp.rows.map((row) =>
201      m(QueryTableRow, {trace: vnode.attrs.trace, row, columns: resp.columns}),
202    );
203
204    if (resp.error) {
205      return m('.query-error', `SQL error: ${resp.error}`);
206    } else {
207      return m(
208        'table.pf-query-table',
209        m('thead', tableHeader),
210        m('tbody', rows),
211      );
212    }
213  }
214}
215
216interface QueryTableAttrs {
217  trace: Trace;
218  query: string;
219  resp?: QueryResponse;
220  contextButtons?: m.Child[];
221  fillParent: boolean;
222}
223
224export class QueryTable implements m.ClassComponent<QueryTableAttrs> {
225  private readonly trace: Trace;
226
227  constructor({attrs}: m.CVnode<QueryTableAttrs>) {
228    this.trace = attrs.trace;
229  }
230
231  view({attrs}: m.CVnode<QueryTableAttrs>) {
232    const {resp, query, contextButtons = [], fillParent} = attrs;
233
234    return m(
235      DetailsShell,
236      {
237        title: this.renderTitle(resp),
238        description: query,
239        buttons: this.renderButtons(query, contextButtons, resp),
240        fillParent,
241      },
242      resp && this.renderTableContent(resp),
243    );
244  }
245
246  renderTitle(resp?: QueryResponse) {
247    if (!resp) {
248      return 'Query - running';
249    }
250    const result = resp.error ? 'error' : `${resp.rows.length} rows`;
251    if (AppImpl.instance.testingMode) {
252      // Omit the duration in tests, they cause screenshot diff failures.
253      return `Query result (${result})`;
254    }
255    return `Query result (${result}) - ${resp.durationMs.toLocaleString()}ms`;
256  }
257
258  renderButtons(
259    query: string,
260    contextButtons: m.Child[],
261    resp?: QueryResponse,
262  ) {
263    return [
264      contextButtons,
265      m(Button, {
266        label: 'Copy query',
267        onclick: () => {
268          copyToClipboard(query);
269        },
270      }),
271      resp &&
272        resp.error === undefined &&
273        m(Button, {
274          label: 'Copy result (.tsv)',
275          onclick: () => {
276            queryResponseToClipboard(resp);
277          },
278        }),
279    ];
280  }
281
282  renderTableContent(resp: QueryResponse) {
283    return m(
284      '.pf-query-panel',
285      resp.statementWithOutputCount > 1 &&
286        m(
287          '.pf-query-warning',
288          m(
289            Callout,
290            {icon: 'warning'},
291            `${resp.statementWithOutputCount} out of ${resp.statementCount} `,
292            'statements returned a result. ',
293            'Only the results for the last statement are displayed.',
294          ),
295        ),
296      m(QueryTableContent, {trace: this.trace, resp}),
297    );
298  }
299}
300
301async function queryResponseToClipboard(resp: QueryResponse): Promise<void> {
302  const lines: string[][] = [];
303  lines.push(resp.columns);
304  for (const row of resp.rows) {
305    const line = [];
306    for (const col of resp.columns) {
307      const value = row[col];
308      line.push(value === null ? 'NULL' : `${value}`);
309    }
310    lines.push(line);
311  }
312  copyToClipboard(lines.map((line) => line.join('\t')).join('\n'));
313}
314