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