xref: /aosp_15_r20/external/perfetto/ui/src/plugins/dev.perfetto.AndroidLog/logs_panel.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2019 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 {time, Time, TimeSpan} from '../../base/time';
17import {DetailsShell} from '../../widgets/details_shell';
18import {Timestamp} from '../../components/widgets/timestamp';
19import {Engine} from '../../trace_processor/engine';
20import {LONG, NUM, NUM_NULL, STR} from '../../trace_processor/query_result';
21import {Monitor} from '../../base/monitor';
22import {AsyncLimiter} from '../../base/async_limiter';
23import {escapeGlob, escapeQuery} from '../../trace_processor/query_utils';
24import {Select} from '../../widgets/select';
25import {Button} from '../../widgets/button';
26import {TextInput} from '../../widgets/text_input';
27import {VirtualTable, VirtualTableRow} from '../../widgets/virtual_table';
28import {classNames} from '../../base/classnames';
29import {TagInput} from '../../widgets/tag_input';
30import {Store} from '../../base/store';
31import {Trace} from '../../public/trace';
32
33const ROW_H = 20;
34
35export interface LogFilteringCriteria {
36  minimumLevel: number;
37  tags: string[];
38  textEntry: string;
39  hideNonMatching: boolean;
40}
41
42export interface LogPanelAttrs {
43  filterStore: Store<LogFilteringCriteria>;
44  trace: Trace;
45}
46
47interface Pagination {
48  offset: number;
49  count: number;
50}
51
52interface LogEntries {
53  offset: number;
54  timestamps: time[];
55  priorities: number[];
56  tags: string[];
57  messages: string[];
58  isHighlighted: boolean[];
59  processName: string[];
60  totalEvents: number; // Count of the total number of events within this window
61}
62
63export class LogPanel implements m.ClassComponent<LogPanelAttrs> {
64  private entries?: LogEntries;
65
66  private pagination: Pagination = {
67    offset: 0,
68    count: 0,
69  };
70  private readonly rowsMonitor: Monitor;
71  private readonly filterMonitor: Monitor;
72  private readonly queryLimiter = new AsyncLimiter();
73
74  constructor({attrs}: m.CVnode<LogPanelAttrs>) {
75    this.rowsMonitor = new Monitor([
76      () => attrs.filterStore.state,
77      () => attrs.trace.timeline.visibleWindow.toTimeSpan().start,
78      () => attrs.trace.timeline.visibleWindow.toTimeSpan().end,
79    ]);
80
81    this.filterMonitor = new Monitor([() => attrs.filterStore.state]);
82  }
83
84  view({attrs}: m.CVnode<LogPanelAttrs>) {
85    if (this.rowsMonitor.ifStateChanged()) {
86      this.reloadData(attrs);
87    }
88
89    const hasProcessNames =
90      this.entries &&
91      this.entries.processName.filter((name) => name).length > 0;
92    const totalEvents = this.entries?.totalEvents ?? 0;
93
94    return m(
95      DetailsShell,
96      {
97        title: 'Android Logs',
98        description: `Total messages: ${totalEvents}`,
99        buttons: m(LogsFilters, {trace: attrs.trace, store: attrs.filterStore}),
100      },
101      m(VirtualTable, {
102        className: 'pf-android-logs-table',
103        columns: [
104          {header: 'Timestamp', width: '13em'},
105          {header: 'Level', width: '4em'},
106          {header: 'Tag', width: '13em'},
107          ...(hasProcessNames ? [{header: 'Process', width: '18em'}] : []),
108          // '' means column width can vary depending on the content.
109          // This works as this is the last column, but using this for other
110          // columns will pull the columns to the right out of line.
111          {header: 'Message', width: ''},
112        ],
113        rows: this.renderRows(hasProcessNames),
114        firstRowOffset: this.entries?.offset ?? 0,
115        numRows: this.entries?.totalEvents ?? 0,
116        rowHeight: ROW_H,
117        onReload: (offset, count) => {
118          this.pagination = {offset, count};
119          this.reloadData(attrs);
120        },
121        onRowHover: (id) => {
122          const timestamp = this.entries?.timestamps[id];
123          if (timestamp !== undefined) {
124            attrs.trace.timeline.hoverCursorTimestamp = timestamp;
125          }
126        },
127        onRowOut: () => {
128          attrs.trace.timeline.hoverCursorTimestamp = undefined;
129        },
130      }),
131    );
132  }
133
134  private reloadData(attrs: LogPanelAttrs) {
135    this.queryLimiter.schedule(async () => {
136      const visibleSpan = attrs.trace.timeline.visibleWindow.toTimeSpan();
137
138      if (this.filterMonitor.ifStateChanged()) {
139        await updateLogView(attrs.trace.engine, attrs.filterStore.state);
140      }
141
142      this.entries = await updateLogEntries(
143        attrs.trace.engine,
144        visibleSpan,
145        this.pagination,
146      );
147
148      attrs.trace.scheduleFullRedraw();
149    });
150  }
151
152  private renderRows(hasProcessNames: boolean | undefined): VirtualTableRow[] {
153    if (!this.entries) {
154      return [];
155    }
156
157    const timestamps = this.entries.timestamps;
158    const priorities = this.entries.priorities;
159    const tags = this.entries.tags;
160    const messages = this.entries.messages;
161    const processNames = this.entries.processName;
162
163    const rows: VirtualTableRow[] = [];
164    for (let i = 0; i < this.entries.timestamps.length; i++) {
165      const priorityLetter = LOG_PRIORITIES[priorities[i]][0];
166      const ts = timestamps[i];
167      const prioClass = priorityLetter ?? '';
168
169      rows.push({
170        id: i,
171        className: classNames(
172          prioClass,
173          this.entries.isHighlighted[i] && 'pf-highlighted',
174        ),
175        cells: [
176          m(Timestamp, {ts}),
177          priorityLetter || '?',
178          tags[i],
179          ...(hasProcessNames ? [processNames[i]] : []),
180          messages[i],
181        ],
182      });
183    }
184
185    return rows;
186  }
187}
188
189export const LOG_PRIORITIES = [
190  '-',
191  '-',
192  'Verbose',
193  'Debug',
194  'Info',
195  'Warn',
196  'Error',
197  'Fatal',
198];
199const IGNORED_STATES = 2;
200
201interface LogPriorityWidgetAttrs {
202  readonly trace: Trace;
203  readonly options: string[];
204  readonly selectedIndex: number;
205  readonly onSelect: (id: number) => void;
206}
207
208class LogPriorityWidget implements m.ClassComponent<LogPriorityWidgetAttrs> {
209  view(vnode: m.Vnode<LogPriorityWidgetAttrs>) {
210    const attrs = vnode.attrs;
211    const optionComponents = [];
212    for (let i = IGNORED_STATES; i < attrs.options.length; i++) {
213      const selected = i === attrs.selectedIndex;
214      optionComponents.push(
215        m('option', {value: i, selected}, attrs.options[i]),
216      );
217    }
218    return m(
219      Select,
220      {
221        onchange: (e: Event) => {
222          const selectionValue = (e.target as HTMLSelectElement).value;
223          attrs.onSelect(Number(selectionValue));
224          attrs.trace.scheduleFullRedraw();
225        },
226      },
227      optionComponents,
228    );
229  }
230}
231
232interface LogTextWidgetAttrs {
233  readonly trace: Trace;
234  readonly onChange: (value: string) => void;
235}
236
237class LogTextWidget implements m.ClassComponent<LogTextWidgetAttrs> {
238  view({attrs}: m.CVnode<LogTextWidgetAttrs>) {
239    return m(TextInput, {
240      placeholder: 'Search logs...',
241      onkeyup: (e: KeyboardEvent) => {
242        // We want to use the value of the input field after it has been
243        // updated with the latest key (onkeyup).
244        const htmlElement = e.target as HTMLInputElement;
245        attrs.onChange(htmlElement.value);
246        attrs.trace.scheduleFullRedraw();
247      },
248    });
249  }
250}
251
252interface FilterByTextWidgetAttrs {
253  readonly hideNonMatching: boolean;
254  readonly disabled: boolean;
255  readonly onClick: () => void;
256}
257
258class FilterByTextWidget implements m.ClassComponent<FilterByTextWidgetAttrs> {
259  view({attrs}: m.Vnode<FilterByTextWidgetAttrs>) {
260    const icon = attrs.hideNonMatching ? 'unfold_less' : 'unfold_more';
261    const tooltip = attrs.hideNonMatching
262      ? 'Expand all and view highlighted'
263      : 'Collapse all';
264    return m(Button, {
265      icon,
266      title: tooltip,
267      disabled: attrs.disabled,
268      onclick: attrs.onClick,
269    });
270  }
271}
272
273interface LogsFiltersAttrs {
274  readonly trace: Trace;
275  readonly store: Store<LogFilteringCriteria>;
276}
277
278export class LogsFilters implements m.ClassComponent<LogsFiltersAttrs> {
279  view({attrs}: m.CVnode<LogsFiltersAttrs>) {
280    return [
281      m('.log-label', 'Log Level'),
282      m(LogPriorityWidget, {
283        trace: attrs.trace,
284        options: LOG_PRIORITIES,
285        selectedIndex: attrs.store.state.minimumLevel,
286        onSelect: (minimumLevel) => {
287          attrs.store.edit((draft) => {
288            draft.minimumLevel = minimumLevel;
289          });
290        },
291      }),
292      m(TagInput, {
293        placeholder: 'Filter by tag...',
294        tags: attrs.store.state.tags,
295        onTagAdd: (tag) => {
296          attrs.store.edit((draft) => {
297            draft.tags.push(tag);
298          });
299        },
300        onTagRemove: (index) => {
301          attrs.store.edit((draft) => {
302            draft.tags.splice(index, 1);
303          });
304        },
305      }),
306      m(LogTextWidget, {
307        trace: attrs.trace,
308        onChange: (text) => {
309          attrs.store.edit((draft) => {
310            draft.textEntry = text;
311          });
312        },
313      }),
314      m(FilterByTextWidget, {
315        hideNonMatching: attrs.store.state.hideNonMatching,
316        onClick: () => {
317          attrs.store.edit((draft) => {
318            draft.hideNonMatching = !draft.hideNonMatching;
319          });
320        },
321        disabled: attrs.store.state.textEntry === '',
322      }),
323    ];
324  }
325}
326
327async function updateLogEntries(
328  engine: Engine,
329  span: TimeSpan,
330  pagination: Pagination,
331): Promise<LogEntries> {
332  const rowsResult = await engine.query(`
333        select
334          ts,
335          prio,
336          ifnull(tag, '[NULL]') as tag,
337          ifnull(msg, '[NULL]') as msg,
338          is_msg_highlighted as isMsgHighlighted,
339          is_process_highlighted as isProcessHighlighted,
340          ifnull(process_name, '') as processName
341        from filtered_logs
342        where ts >= ${span.start} and ts <= ${span.end}
343        order by ts
344        limit ${pagination.offset}, ${pagination.count}
345    `);
346
347  const timestamps: time[] = [];
348  const priorities = [];
349  const tags = [];
350  const messages = [];
351  const isHighlighted = [];
352  const processName = [];
353
354  const it = rowsResult.iter({
355    ts: LONG,
356    prio: NUM,
357    tag: STR,
358    msg: STR,
359    isMsgHighlighted: NUM_NULL,
360    isProcessHighlighted: NUM,
361    processName: STR,
362  });
363  for (; it.valid(); it.next()) {
364    timestamps.push(Time.fromRaw(it.ts));
365    priorities.push(it.prio);
366    tags.push(it.tag);
367    messages.push(it.msg);
368    isHighlighted.push(
369      it.isMsgHighlighted === 1 || it.isProcessHighlighted === 1,
370    );
371    processName.push(it.processName);
372  }
373
374  const queryRes = await engine.query(`
375    select
376      count(*) as totalEvents
377    from filtered_logs
378    where ts >= ${span.start} and ts <= ${span.end}
379  `);
380  const {totalEvents} = queryRes.firstRow({totalEvents: NUM});
381
382  return {
383    offset: pagination.offset,
384    timestamps,
385    priorities,
386    tags,
387    messages,
388    isHighlighted,
389    processName,
390    totalEvents,
391  };
392}
393
394async function updateLogView(engine: Engine, filter: LogFilteringCriteria) {
395  await engine.query('drop view if exists filtered_logs');
396
397  const globMatch = composeGlobMatch(filter.hideNonMatching, filter.textEntry);
398  let selectedRows = `select prio, ts, tag, msg,
399      process.name as process_name, ${globMatch}
400      from android_logs
401      left join thread using(utid)
402      left join process using(upid)
403      where prio >= ${filter.minimumLevel}`;
404  if (filter.tags.length) {
405    selectedRows += ` and tag in (${serializeTags(filter.tags)})`;
406  }
407
408  // We extract only the rows which will be visible.
409  await engine.query(`create view filtered_logs as select *
410    from (${selectedRows})
411    where is_msg_chosen is 1 or is_process_chosen is 1`);
412}
413
414function serializeTags(tags: string[]) {
415  return tags.map((tag) => escapeQuery(tag)).join();
416}
417
418function composeGlobMatch(isCollaped: boolean, textEntry: string) {
419  if (isCollaped) {
420    // If the entries are collapsed, we won't highlight any lines.
421    return `msg glob ${escapeGlob(textEntry)} as is_msg_chosen,
422      (process.name is not null and process.name glob ${escapeGlob(
423        textEntry,
424      )}) as is_process_chosen,
425      0 as is_msg_highlighted,
426      0 as is_process_highlighted`;
427  } else if (!textEntry) {
428    // If there is no text entry, we will show all lines, but won't highlight.
429    // any.
430    return `1 as is_msg_chosen,
431      1 as is_process_chosen,
432      0 as is_msg_highlighted,
433      0 as is_process_highlighted`;
434  } else {
435    return `1 as is_msg_chosen,
436      1 as is_process_chosen,
437      msg glob ${escapeGlob(textEntry)} as is_msg_highlighted,
438      (process.name is not null and process.name glob ${escapeGlob(
439        textEntry,
440      )}) as is_process_highlighted`;
441  }
442}
443