blob: 5c79fdd2efe34d31c42e0a2b98bf666300407ab7 [file] [log] [blame]
// Copyright (C) 2019 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {time, Time, TimeSpan} from '../../base/time';
import {DetailsShell} from '../../widgets/details_shell';
import {Timestamp} from '../../public/lib/widgets/timestamp';
import {Engine} from '../../trace_processor/engine';
import {LONG, NUM, NUM_NULL, STR} from '../../trace_processor/query_result';
import {Monitor} from '../../base/monitor';
import {AsyncLimiter} from '../../base/async_limiter';
import {escapeGlob, escapeQuery} from '../../trace_processor/query_utils';
import {Select} from '../../widgets/select';
import {Button} from '../../widgets/button';
import {TextInput} from '../../widgets/text_input';
import {VirtualTable, VirtualTableRow} from '../../widgets/virtual_table';
import {classNames} from '../../base/classnames';
import {TagInput} from '../../widgets/tag_input';
import {Store} from '../../base/store';
import {Trace} from '../../public/trace';
const ROW_H = 20;
export interface LogFilteringCriteria {
minimumLevel: number;
tags: string[];
textEntry: string;
hideNonMatching: boolean;
}
export interface LogPanelAttrs {
filterStore: Store<LogFilteringCriteria>;
trace: Trace;
}
interface Pagination {
offset: number;
count: number;
}
interface LogEntries {
offset: number;
timestamps: time[];
priorities: number[];
tags: string[];
messages: string[];
isHighlighted: boolean[];
processName: string[];
totalEvents: number; // Count of the total number of events within this window
}
export class LogPanel implements m.ClassComponent<LogPanelAttrs> {
private entries?: LogEntries;
private pagination: Pagination = {
offset: 0,
count: 0,
};
private readonly rowsMonitor: Monitor;
private readonly filterMonitor: Monitor;
private readonly queryLimiter = new AsyncLimiter();
constructor({attrs}: m.CVnode<LogPanelAttrs>) {
this.rowsMonitor = new Monitor([
() => attrs.filterStore.state,
() => attrs.trace.timeline.visibleWindow.toTimeSpan().start,
() => attrs.trace.timeline.visibleWindow.toTimeSpan().end,
]);
this.filterMonitor = new Monitor([() => attrs.filterStore.state]);
}
view({attrs}: m.CVnode<LogPanelAttrs>) {
if (this.rowsMonitor.ifStateChanged()) {
this.reloadData(attrs);
}
const hasProcessNames =
this.entries &&
this.entries.processName.filter((name) => name).length > 0;
const totalEvents = this.entries?.totalEvents ?? 0;
return m(
DetailsShell,
{
title: 'Android Logs',
description: `Total messages: ${totalEvents}`,
buttons: m(LogsFilters, {trace: attrs.trace, store: attrs.filterStore}),
},
m(VirtualTable, {
className: 'pf-android-logs-table',
columns: [
{header: 'Timestamp', width: '13em'},
{header: 'Level', width: '4em'},
{header: 'Tag', width: '13em'},
...(hasProcessNames ? [{header: 'Process', width: '18em'}] : []),
// '' means column width can vary depending on the content.
// This works as this is the last column, but using this for other
// columns will pull the columns to the right out of line.
{header: 'Message', width: ''},
],
rows: this.renderRows(hasProcessNames),
firstRowOffset: this.entries?.offset ?? 0,
numRows: this.entries?.totalEvents ?? 0,
rowHeight: ROW_H,
onReload: (offset, count) => {
this.pagination = {offset, count};
this.reloadData(attrs);
},
onRowHover: (id) => {
const timestamp = this.entries?.timestamps[id];
if (timestamp !== undefined) {
attrs.trace.timeline.hoverCursorTimestamp = timestamp;
}
},
onRowOut: () => {
attrs.trace.timeline.hoverCursorTimestamp = undefined;
},
}),
);
}
private reloadData(attrs: LogPanelAttrs) {
this.queryLimiter.schedule(async () => {
const visibleSpan = attrs.trace.timeline.visibleWindow.toTimeSpan();
if (this.filterMonitor.ifStateChanged()) {
await updateLogView(attrs.trace.engine, attrs.filterStore.state);
}
this.entries = await updateLogEntries(
attrs.trace.engine,
visibleSpan,
this.pagination,
);
attrs.trace.scheduleFullRedraw();
});
}
private renderRows(hasProcessNames: boolean | undefined): VirtualTableRow[] {
if (!this.entries) {
return [];
}
const timestamps = this.entries.timestamps;
const priorities = this.entries.priorities;
const tags = this.entries.tags;
const messages = this.entries.messages;
const processNames = this.entries.processName;
const rows: VirtualTableRow[] = [];
for (let i = 0; i < this.entries.timestamps.length; i++) {
const priorityLetter = LOG_PRIORITIES[priorities[i]][0];
const ts = timestamps[i];
const prioClass = priorityLetter ?? '';
rows.push({
id: i,
className: classNames(
prioClass,
this.entries.isHighlighted[i] && 'pf-highlighted',
),
cells: [
m(Timestamp, {ts}),
priorityLetter || '?',
tags[i],
...(hasProcessNames ? [processNames[i]] : []),
messages[i],
],
});
}
return rows;
}
}
export const LOG_PRIORITIES = [
'-',
'-',
'Verbose',
'Debug',
'Info',
'Warn',
'Error',
'Fatal',
];
const IGNORED_STATES = 2;
interface LogPriorityWidgetAttrs {
readonly trace: Trace;
readonly options: string[];
readonly selectedIndex: number;
readonly onSelect: (id: number) => void;
}
class LogPriorityWidget implements m.ClassComponent<LogPriorityWidgetAttrs> {
view(vnode: m.Vnode<LogPriorityWidgetAttrs>) {
const attrs = vnode.attrs;
const optionComponents = [];
for (let i = IGNORED_STATES; i < attrs.options.length; i++) {
const selected = i === attrs.selectedIndex;
optionComponents.push(
m('option', {value: i, selected}, attrs.options[i]),
);
}
return m(
Select,
{
onchange: (e: Event) => {
const selectionValue = (e.target as HTMLSelectElement).value;
attrs.onSelect(Number(selectionValue));
attrs.trace.scheduleFullRedraw();
},
},
optionComponents,
);
}
}
interface LogTextWidgetAttrs {
readonly trace: Trace;
readonly onChange: (value: string) => void;
}
class LogTextWidget implements m.ClassComponent<LogTextWidgetAttrs> {
view({attrs}: m.CVnode<LogTextWidgetAttrs>) {
return m(TextInput, {
placeholder: 'Search logs...',
onkeyup: (e: KeyboardEvent) => {
// We want to use the value of the input field after it has been
// updated with the latest key (onkeyup).
const htmlElement = e.target as HTMLInputElement;
attrs.onChange(htmlElement.value);
attrs.trace.scheduleFullRedraw();
},
});
}
}
interface FilterByTextWidgetAttrs {
readonly hideNonMatching: boolean;
readonly disabled: boolean;
readonly onClick: () => void;
}
class FilterByTextWidget implements m.ClassComponent<FilterByTextWidgetAttrs> {
view({attrs}: m.Vnode<FilterByTextWidgetAttrs>) {
const icon = attrs.hideNonMatching ? 'unfold_less' : 'unfold_more';
const tooltip = attrs.hideNonMatching
? 'Expand all and view highlighted'
: 'Collapse all';
return m(Button, {
icon,
title: tooltip,
disabled: attrs.disabled,
onclick: attrs.onClick,
});
}
}
interface LogsFiltersAttrs {
readonly trace: Trace;
readonly store: Store<LogFilteringCriteria>;
}
export class LogsFilters implements m.ClassComponent<LogsFiltersAttrs> {
view({attrs}: m.CVnode<LogsFiltersAttrs>) {
return [
m('.log-label', 'Log Level'),
m(LogPriorityWidget, {
trace: attrs.trace,
options: LOG_PRIORITIES,
selectedIndex: attrs.store.state.minimumLevel,
onSelect: (minimumLevel) => {
attrs.store.edit((draft) => {
draft.minimumLevel = minimumLevel;
});
},
}),
m(TagInput, {
placeholder: 'Filter by tag...',
tags: attrs.store.state.tags,
onTagAdd: (tag) => {
attrs.store.edit((draft) => {
draft.tags.push(tag);
});
},
onTagRemove: (index) => {
attrs.store.edit((draft) => {
draft.tags.splice(index, 1);
});
},
}),
m(LogTextWidget, {
trace: attrs.trace,
onChange: (text) => {
attrs.store.edit((draft) => {
draft.textEntry = text;
});
},
}),
m(FilterByTextWidget, {
hideNonMatching: attrs.store.state.hideNonMatching,
onClick: () => {
attrs.store.edit((draft) => {
draft.hideNonMatching = !draft.hideNonMatching;
});
},
disabled: attrs.store.state.textEntry === '',
}),
];
}
}
async function updateLogEntries(
engine: Engine,
span: TimeSpan,
pagination: Pagination,
): Promise<LogEntries> {
const rowsResult = await engine.query(`
select
ts,
prio,
ifnull(tag, '[NULL]') as tag,
ifnull(msg, '[NULL]') as msg,
is_msg_highlighted as isMsgHighlighted,
is_process_highlighted as isProcessHighlighted,
ifnull(process_name, '') as processName
from filtered_logs
where ts >= ${span.start} and ts <= ${span.end}
order by ts
limit ${pagination.offset}, ${pagination.count}
`);
const timestamps: time[] = [];
const priorities = [];
const tags = [];
const messages = [];
const isHighlighted = [];
const processName = [];
const it = rowsResult.iter({
ts: LONG,
prio: NUM,
tag: STR,
msg: STR,
isMsgHighlighted: NUM_NULL,
isProcessHighlighted: NUM,
processName: STR,
});
for (; it.valid(); it.next()) {
timestamps.push(Time.fromRaw(it.ts));
priorities.push(it.prio);
tags.push(it.tag);
messages.push(it.msg);
isHighlighted.push(
it.isMsgHighlighted === 1 || it.isProcessHighlighted === 1,
);
processName.push(it.processName);
}
const queryRes = await engine.query(`
select
count(*) as totalEvents
from filtered_logs
where ts >= ${span.start} and ts <= ${span.end}
`);
const {totalEvents} = queryRes.firstRow({totalEvents: NUM});
return {
offset: pagination.offset,
timestamps,
priorities,
tags,
messages,
isHighlighted,
processName,
totalEvents,
};
}
async function updateLogView(engine: Engine, filter: LogFilteringCriteria) {
await engine.query('drop view if exists filtered_logs');
const globMatch = composeGlobMatch(filter.hideNonMatching, filter.textEntry);
let selectedRows = `select prio, ts, tag, msg,
process.name as process_name, ${globMatch}
from android_logs
left join thread using(utid)
left join process using(upid)
where prio >= ${filter.minimumLevel}`;
if (filter.tags.length) {
selectedRows += ` and tag in (${serializeTags(filter.tags)})`;
}
// We extract only the rows which will be visible.
await engine.query(`create view filtered_logs as select *
from (${selectedRows})
where is_msg_chosen is 1 or is_process_chosen is 1`);
}
function serializeTags(tags: string[]) {
return tags.map((tag) => escapeQuery(tag)).join();
}
function composeGlobMatch(isCollaped: boolean, textEntry: string) {
if (isCollaped) {
// If the entries are collapsed, we won't highlight any lines.
return `msg glob ${escapeGlob(textEntry)} as is_msg_chosen,
(process.name is not null and process.name glob ${escapeGlob(
textEntry,
)}) as is_process_chosen,
0 as is_msg_highlighted,
0 as is_process_highlighted`;
} else if (!textEntry) {
// If there is no text entry, we will show all lines, but won't highlight.
// any.
return `1 as is_msg_chosen,
1 as is_process_chosen,
0 as is_msg_highlighted,
0 as is_process_highlighted`;
} else {
return `1 as is_msg_chosen,
1 as is_process_chosen,
msg glob ${escapeGlob(textEntry)} as is_msg_highlighted,
(process.name is not null and process.name glob ${escapeGlob(
textEntry,
)}) as is_process_highlighted`;
}
}