• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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  }
149
150  private renderRows(hasProcessNames: boolean | undefined): VirtualTableRow[] {
151    if (!this.entries) {
152      return [];
153    }
154
155    const timestamps = this.entries.timestamps;
156    const priorities = this.entries.priorities;
157    const tags = this.entries.tags;
158    const messages = this.entries.messages;
159    const processNames = this.entries.processName;
160
161    const rows: VirtualTableRow[] = [];
162    for (let i = 0; i < this.entries.timestamps.length; i++) {
163      const priorityLetter = LOG_PRIORITIES[priorities[i]][0];
164      const ts = timestamps[i];
165      const prioClass = priorityLetter ?? '';
166
167      rows.push({
168        id: i,
169        className: classNames(
170          prioClass,
171          this.entries.isHighlighted[i] && 'pf-highlighted',
172        ),
173        cells: [
174          m(Timestamp, {ts}),
175          priorityLetter || '?',
176          tags[i],
177          ...(hasProcessNames ? [processNames[i]] : []),
178          messages[i],
179        ],
180      });
181    }
182
183    return rows;
184  }
185}
186
187export const LOG_PRIORITIES = [
188  '-',
189  '-',
190  'Verbose',
191  'Debug',
192  'Info',
193  'Warn',
194  'Error',
195  'Fatal',
196];
197const IGNORED_STATES = 2;
198
199interface LogPriorityWidgetAttrs {
200  readonly trace: Trace;
201  readonly options: string[];
202  readonly selectedIndex: number;
203  readonly onSelect: (id: number) => void;
204}
205
206class LogPriorityWidget implements m.ClassComponent<LogPriorityWidgetAttrs> {
207  view(vnode: m.Vnode<LogPriorityWidgetAttrs>) {
208    const attrs = vnode.attrs;
209    const optionComponents = [];
210    for (let i = IGNORED_STATES; i < attrs.options.length; i++) {
211      const selected = i === attrs.selectedIndex;
212      optionComponents.push(
213        m('option', {value: i, selected}, attrs.options[i]),
214      );
215    }
216    return m(
217      Select,
218      {
219        onchange: (e: Event) => {
220          const selectionValue = (e.target as HTMLSelectElement).value;
221          attrs.onSelect(Number(selectionValue));
222        },
223      },
224      optionComponents,
225    );
226  }
227}
228
229interface LogTextWidgetAttrs {
230  readonly trace: Trace;
231  readonly onChange: (value: string) => void;
232}
233
234class LogTextWidget implements m.ClassComponent<LogTextWidgetAttrs> {
235  view({attrs}: m.CVnode<LogTextWidgetAttrs>) {
236    return m(TextInput, {
237      placeholder: 'Search logs...',
238      onkeyup: (e: KeyboardEvent) => {
239        // We want to use the value of the input field after it has been
240        // updated with the latest key (onkeyup).
241        const htmlElement = e.target as HTMLInputElement;
242        attrs.onChange(htmlElement.value);
243      },
244    });
245  }
246}
247
248interface FilterByTextWidgetAttrs {
249  readonly hideNonMatching: boolean;
250  readonly disabled: boolean;
251  readonly onClick: () => void;
252}
253
254class FilterByTextWidget implements m.ClassComponent<FilterByTextWidgetAttrs> {
255  view({attrs}: m.Vnode<FilterByTextWidgetAttrs>) {
256    const icon = attrs.hideNonMatching ? 'unfold_less' : 'unfold_more';
257    const tooltip = attrs.hideNonMatching
258      ? 'Expand all and view highlighted'
259      : 'Collapse all';
260    return m(Button, {
261      icon,
262      title: tooltip,
263      disabled: attrs.disabled,
264      onclick: attrs.onClick,
265    });
266  }
267}
268
269interface LogsFiltersAttrs {
270  readonly trace: Trace;
271  readonly store: Store<LogFilteringCriteria>;
272}
273
274export class LogsFilters implements m.ClassComponent<LogsFiltersAttrs> {
275  view({attrs}: m.CVnode<LogsFiltersAttrs>) {
276    return [
277      m('.log-label', 'Log Level'),
278      m(LogPriorityWidget, {
279        trace: attrs.trace,
280        options: LOG_PRIORITIES,
281        selectedIndex: attrs.store.state.minimumLevel,
282        onSelect: (minimumLevel) => {
283          attrs.store.edit((draft) => {
284            draft.minimumLevel = minimumLevel;
285          });
286        },
287      }),
288      m(TagInput, {
289        placeholder: 'Filter by tag...',
290        tags: attrs.store.state.tags,
291        onTagAdd: (tag) => {
292          attrs.store.edit((draft) => {
293            draft.tags.push(tag);
294          });
295        },
296        onTagRemove: (index) => {
297          attrs.store.edit((draft) => {
298            draft.tags.splice(index, 1);
299          });
300        },
301      }),
302      m(LogTextWidget, {
303        trace: attrs.trace,
304        onChange: (text) => {
305          attrs.store.edit((draft) => {
306            draft.textEntry = text;
307          });
308        },
309      }),
310      m(FilterByTextWidget, {
311        hideNonMatching: attrs.store.state.hideNonMatching,
312        onClick: () => {
313          attrs.store.edit((draft) => {
314            draft.hideNonMatching = !draft.hideNonMatching;
315          });
316        },
317        disabled: attrs.store.state.textEntry === '',
318      }),
319    ];
320  }
321}
322
323async function updateLogEntries(
324  engine: Engine,
325  span: TimeSpan,
326  pagination: Pagination,
327): Promise<LogEntries> {
328  const rowsResult = await engine.query(`
329        select
330          ts,
331          prio,
332          ifnull(tag, '[NULL]') as tag,
333          ifnull(msg, '[NULL]') as msg,
334          is_msg_highlighted as isMsgHighlighted,
335          is_process_highlighted as isProcessHighlighted,
336          ifnull(process_name, '') as processName
337        from filtered_logs
338        where ts >= ${span.start} and ts <= ${span.end}
339        order by ts
340        limit ${pagination.offset}, ${pagination.count}
341    `);
342
343  const timestamps: time[] = [];
344  const priorities = [];
345  const tags = [];
346  const messages = [];
347  const isHighlighted = [];
348  const processName = [];
349
350  const it = rowsResult.iter({
351    ts: LONG,
352    prio: NUM,
353    tag: STR,
354    msg: STR,
355    isMsgHighlighted: NUM_NULL,
356    isProcessHighlighted: NUM,
357    processName: STR,
358  });
359  for (; it.valid(); it.next()) {
360    timestamps.push(Time.fromRaw(it.ts));
361    priorities.push(it.prio);
362    tags.push(it.tag);
363    messages.push(it.msg);
364    isHighlighted.push(
365      it.isMsgHighlighted === 1 || it.isProcessHighlighted === 1,
366    );
367    processName.push(it.processName);
368  }
369
370  const queryRes = await engine.query(`
371    select
372      count(*) as totalEvents
373    from filtered_logs
374    where ts >= ${span.start} and ts <= ${span.end}
375  `);
376  const {totalEvents} = queryRes.firstRow({totalEvents: NUM});
377
378  return {
379    offset: pagination.offset,
380    timestamps,
381    priorities,
382    tags,
383    messages,
384    isHighlighted,
385    processName,
386    totalEvents,
387  };
388}
389
390async function updateLogView(engine: Engine, filter: LogFilteringCriteria) {
391  await engine.query('drop view if exists filtered_logs');
392
393  const globMatch = composeGlobMatch(filter.hideNonMatching, filter.textEntry);
394  let selectedRows = `select prio, ts, tag, msg,
395      process.name as process_name, ${globMatch}
396      from android_logs
397      left join thread using(utid)
398      left join process using(upid)
399      where prio >= ${filter.minimumLevel}`;
400  if (filter.tags.length) {
401    selectedRows += ` and tag in (${serializeTags(filter.tags)})`;
402  }
403
404  // We extract only the rows which will be visible.
405  await engine.query(`create view filtered_logs as select *
406    from (${selectedRows})
407    where is_msg_chosen is 1 or is_process_chosen is 1`);
408}
409
410function serializeTags(tags: string[]) {
411  return tags.map((tag) => escapeQuery(tag)).join();
412}
413
414function composeGlobMatch(isCollaped: boolean, textEntry: string) {
415  if (isCollaped) {
416    // If the entries are collapsed, we won't highlight any lines.
417    return `msg glob ${escapeGlob(textEntry)} as is_msg_chosen,
418      (process.name is not null and process.name glob ${escapeGlob(
419        textEntry,
420      )}) as is_process_chosen,
421      0 as is_msg_highlighted,
422      0 as is_process_highlighted`;
423  } else if (!textEntry) {
424    // If there is no text entry, we will show all lines, but won't highlight.
425    // any.
426    return `1 as is_msg_chosen,
427      1 as is_process_chosen,
428      0 as is_msg_highlighted,
429      0 as is_process_highlighted`;
430  } else {
431    return `1 as is_msg_chosen,
432      1 as is_process_chosen,
433      msg glob ${escapeGlob(textEntry)} as is_msg_highlighted,
434      (process.name is not null and process.name glob ${escapeGlob(
435        textEntry,
436      )}) as is_process_highlighted`;
437  }
438}
439