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--> 15<template> 16 <md-card-content class="container"> 17 <div class="navigation"> 18 <md-content 19 md-tag="md-toolbar" 20 md-elevation="0" 21 class="card-toolbar md-transparent md-dense" 22 > 23 <h2 class="md-title" style="flex: 1">Log View</h2> 24 <md-button 25 class="md-dense md-primary" 26 @click.native="scrollToRow(lastOccuredVisibleIndex)" 27 > 28 Jump to latest entry 29 </md-button> 30 <md-button 31 class="md-icon-button" :class="{'md-primary': pinnedToLatest}" 32 @click.native="togglePin" 33 > 34 <md-icon>push_pin</md-icon> 35 <md-tooltip md-direction="top" v-if="pinnedToLatest"> 36 Unpin to latest message 37 </md-tooltip> 38 <md-tooltip md-direction="top" v-else> 39 Pin to latest message 40 </md-tooltip> 41 </md-button> 42 </md-content> 43 </div> 44 45 <div class="filters"> 46 <md-field> 47 <label>Log Levels</label> 48 <md-select v-model="selectedLogLevels" multiple> 49 <md-option v-for="level in logLevels" :value="level">{{ level }}</md-option> 50 </md-select> 51 </md-field> 52 53 <md-field> 54 <label>Tags</label> 55 <md-select v-model="selectedTags" multiple> 56 <md-option v-for="tag in tags" :value="tag">{{ tag }}</md-option> 57 </md-select> 58 </md-field> 59 60 <md-autocomplete v-model="selectedSourceFile" :md-options="sourceFiles"> 61 <label>Source file</label> 62 63 <template slot="md-autocomplete-item" slot-scope="{ item, term }"> 64 <md-highlight-text :md-term="term">{{ item }}</md-highlight-text> 65 </template> 66 67 <template slot="md-autocomplete-empty" slot-scope="{ term }"> 68 No source file matching "{{ term }}" was found. 69 </template> 70 </md-autocomplete> 71 72 <md-field class="search-message-field" md-clearable> 73 <md-input placeholder="Search messages..." v-model="searchInput"></md-input> 74 </md-field> 75 </div> 76 77 <div v-if="processedData.length > 0" style="overflow-y: auto;"> 78 <virtual-list style="height: 600px; overflow-y: auto;" 79 :data-key="'uid'" 80 :data-sources="processedData" 81 :data-component="logEntryComponent" 82 ref="loglist" 83 /> 84 </div> 85 <div class="no-logs-message" v-else> 86 <md-icon>error_outline</md-icon> 87 <span class="message">No logs founds...</span> 88 </div> 89 </md-card-content> 90</template> 91<script> 92import { findLastMatchingSorted } from './utils/utils.js'; 93import { logLevel } from './utils/consts'; 94import LogEntryComponent from './LogEntry.vue'; 95import VirtualList from '../libs/virtualList/VirtualList'; 96 97export default { 98 name: 'logview', 99 data() { 100 const data = this.file.data; 101 102 const tags = new Set(); 103 const sourceFiles = new Set(); 104 for (const line of data) { 105 tags.add(line.tag); 106 sourceFiles.add(line.at); 107 } 108 109 data.forEach((entry, index) => entry.index = index); 110 111 const logLevels = Object.values(logLevel); 112 113 return { 114 data, 115 isSelected: false, 116 prevLastOccuredIndex: -1, 117 lastOccuredIndex: 0, 118 selectedTags: [], 119 selectedSourceFile: null, 120 searchInput: null, 121 sourceFiles: Object.freeze(Array.from(sourceFiles)), 122 tags: Object.freeze(Array.from(tags)), 123 pinnedToLatest: true, 124 logEntryComponent: LogEntryComponent, 125 logLevels, 126 selectedLogLevels: [], 127 } 128 }, 129 methods: { 130 arrowUp() { 131 this.isSelected = !this.isSelected; 132 return !this.isSelected; 133 }, 134 arrowDown() { 135 this.isSelected = !this.isSelected; 136 return !this.isSelected; 137 }, 138 getRowEl(idx) { 139 return this.$refs.tableBody.querySelectorAll('tr')[idx]; 140 }, 141 togglePin() { 142 this.pinnedToLatest = !this.pinnedToLatest; 143 }, 144 scrollToRow(index) { 145 if (!this.$refs.loglist) { 146 return; 147 } 148 149 const itemOffset = this.$refs.loglist.virtual.getOffset(index); 150 const itemSize = 35; 151 const loglistSize = this.$refs.loglist.getClientSize(); 152 153 this.$refs.loglist.scrollToOffset(itemOffset - loglistSize + itemSize); 154 }, 155 getLastOccuredIndex(data, timestamp) { 156 if (this.data.length === 0) { 157 return 0; 158 } 159 return findLastMatchingSorted(data, 160 (array, idx) => array[idx].timestamp <= timestamp); 161 }, 162 }, 163 watch: { 164 pinnedToLatest(isPinned) { 165 if (isPinned) { 166 this.scrollToRow(this.lastOccuredVisibleIndex); 167 } 168 }, 169 currentTimestamp: { 170 immediate: true, 171 handler(newTimestamp) { 172 this.prevLastOccuredIndex = this.lastOccuredIndex; 173 this.lastOccuredIndex = this.getLastOccuredIndex(this.data, newTimestamp); 174 175 if (this.pinnedToLatest) { 176 this.scrollToRow(this.lastOccuredVisibleIndex); 177 } 178 }, 179 } 180 }, 181 props: ['file'], 182 computed: { 183 lastOccuredVisibleIndex() { 184 return this.getLastOccuredIndex(this.processedData, this.currentTimestamp); 185 }, 186 currentTimestamp() { 187 return this.$store.state.currentTimestamp; 188 }, 189 processedData() { 190 const filteredData = this.data.filter(line => { 191 if (this.selectedLogLevels.length > 0 && 192 !this.selectedLogLevels.includes(line.level.toLowerCase())) { 193 return false; 194 } 195 196 if (this.sourceFiles.includes(this.selectedSourceFile)) { 197 // Only filter once source file is fully inputed 198 if (line.at != this.selectedSourceFile) { 199 return false; 200 } 201 } 202 203 if (this.selectedTags.length > 0 && !this.selectedTags.includes(line.tag)) { 204 return false; 205 } 206 207 if (this.searchInput && !line.text.includes(this.searchInput)) { 208 return false; 209 } 210 211 return true; 212 }); 213 214 for (const entry of filteredData) { 215 entry.new = this.prevLastOccuredIndex < entry.index && 216 entry.index <= this.lastOccuredIndex; 217 entry.occured = entry.index <= this.lastOccuredIndex; 218 entry.justInactivated = this.lastOccuredIndex < entry.index && 219 entry.index <= this.prevLastOccuredIndex; 220 221 // Force refresh if any of these changes 222 entry.uid = `${entry.index}${entry.new ? '-new' : ''}${entry.index}${entry.justInactivated ? '-just-inactivated' : ''}${entry.occured ? '-occured' : ''}` 223 } 224 225 return filteredData; 226 } 227 }, 228 components: { 229 'virtual-list': VirtualList, 230 'logentry': LogEntryComponent, 231 } 232} 233 234</script> 235<style> 236.container { 237 display: flex; 238 flex-wrap: wrap; 239} 240 241.filters, .navigation { 242 width: 100%; 243 display: flex; 244 flex-direction: row; 245 align-items: center; 246} 247 248.navigation { 249 justify-content: flex-end; 250} 251 252.navigation > button { 253 margin: 0; 254} 255 256.filters > div { 257 margin: 10px; 258} 259 260.log-header { 261 display: inline-flex; 262 color: var(--md-theme-default-text-accent-on-background, rgba(0,0,0,0.54)); 263 font-weight: bold; 264} 265 266.log-header > div { 267 padding: 6px 10px; 268 border-bottom: 1px solid #f1f1f1; 269} 270 271.log-header .time-column { 272 width: 13em; 273} 274 275.log-header .tag-column { 276 width: 10em; 277} 278 279.log-header .at-column { 280 width: 30em; 281} 282 283.column-title { 284 font-size: 12px; 285} 286 287.no-logs-message { 288 margin: 15px; 289 display: flex; 290 align-content: center; 291 align-items: center; 292} 293 294.no-logs-message .message { 295 margin-left: 10px; 296 font-size: 15px; 297} 298</style> 299