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