1// Copyright (c) 2012 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * EventsView displays a filtered list of all events sharing a source, and
7 * a details pane for the selected sources.
8 *
9 *  +----------------------++----------------+
10 *  |      filter box      ||                |
11 *  +----------------------+|                |
12 *  |                      ||                |
13 *  |                      ||                |
14 *  |                      ||                |
15 *  |                      ||                |
16 *  |     source list      ||    details     |
17 *  |                      ||    view        |
18 *  |                      ||                |
19 *  |                      ||                |
20 *  |                      ||                |
21 *  |                      ||                |
22 *  |                      ||                |
23 *  |                      ||                |
24 *  +----------------------++----------------+
25 */
26var EventsView = (function() {
27  'use strict';
28
29  // How soon after updating the filter list the counter should be updated.
30  var REPAINT_FILTER_COUNTER_TIMEOUT_MS = 0;
31
32  // We inherit from View.
33  var superClass = View;
34
35  /*
36   * @constructor
37   */
38  function EventsView() {
39    assertFirstConstructorCall(EventsView);
40
41    // Call superclass's constructor.
42    superClass.call(this);
43
44    // Initialize the sub-views.
45    var leftPane = new VerticalSplitView(new DivView(EventsView.TOPBAR_ID),
46                                         new DivView(EventsView.LIST_BOX_ID));
47
48    this.detailsView_ = new DetailsView(EventsView.DETAILS_LOG_BOX_ID);
49
50    this.splitterView_ = new ResizableVerticalSplitView(
51        leftPane, this.detailsView_, new DivView(EventsView.SIZER_ID));
52
53    SourceTracker.getInstance().addSourceEntryObserver(this);
54
55    this.tableBody_ = $(EventsView.TBODY_ID);
56
57    this.filterInput_ = $(EventsView.FILTER_INPUT_ID);
58    this.filterCount_ = $(EventsView.FILTER_COUNT_ID);
59
60    this.filterInput_.addEventListener('search',
61        this.onFilterTextChanged_.bind(this), true);
62
63    $(EventsView.SELECT_ALL_ID).addEventListener(
64        'click', this.selectAll_.bind(this), true);
65
66    $(EventsView.SORT_BY_ID_ID).addEventListener(
67        'click', this.sortById_.bind(this), true);
68
69    $(EventsView.SORT_BY_SOURCE_TYPE_ID).addEventListener(
70        'click', this.sortBySourceType_.bind(this), true);
71
72    $(EventsView.SORT_BY_DESCRIPTION_ID).addEventListener(
73        'click', this.sortByDescription_.bind(this), true);
74
75    new MouseOverHelp(EventsView.FILTER_HELP_ID,
76                      EventsView.FILTER_HELP_HOVER_ID);
77
78    // Sets sort order and filter.
79    this.setFilter_('');
80
81    this.initializeSourceList_();
82  }
83
84  EventsView.TAB_ID = 'tab-handle-events';
85  EventsView.TAB_NAME = 'Events';
86  EventsView.TAB_HASH = '#events';
87
88  // IDs for special HTML elements in events_view.html
89  EventsView.TBODY_ID = 'events-view-source-list-tbody';
90  EventsView.FILTER_INPUT_ID = 'events-view-filter-input';
91  EventsView.FILTER_COUNT_ID = 'events-view-filter-count';
92  EventsView.FILTER_HELP_ID = 'events-view-filter-help';
93  EventsView.FILTER_HELP_HOVER_ID = 'events-view-filter-help-hover';
94  EventsView.SELECT_ALL_ID = 'events-view-select-all';
95  EventsView.SORT_BY_ID_ID = 'events-view-sort-by-id';
96  EventsView.SORT_BY_SOURCE_TYPE_ID = 'events-view-sort-by-source';
97  EventsView.SORT_BY_DESCRIPTION_ID = 'events-view-sort-by-description';
98  EventsView.DETAILS_LOG_BOX_ID = 'events-view-details-log-box';
99  EventsView.TOPBAR_ID = 'events-view-filter-box';
100  EventsView.LIST_BOX_ID = 'events-view-source-list';
101  EventsView.SIZER_ID = 'events-view-splitter-box';
102
103  cr.addSingletonGetter(EventsView);
104
105  EventsView.prototype = {
106    // Inherit the superclass's methods.
107    __proto__: superClass.prototype,
108
109    /**
110     * Initializes the list of source entries.  If source entries are already,
111     * being displayed, removes them all in the process.
112     */
113    initializeSourceList_: function() {
114      this.currentSelectedRows_ = [];
115      this.sourceIdToRowMap_ = {};
116      this.tableBody_.innerHTML = '';
117      this.numPrefilter_ = 0;
118      this.numPostfilter_ = 0;
119      this.invalidateFilterCounter_();
120      this.invalidateDetailsView_();
121    },
122
123    setGeometry: function(left, top, width, height) {
124      superClass.prototype.setGeometry.call(this, left, top, width, height);
125      this.splitterView_.setGeometry(left, top, width, height);
126    },
127
128    show: function(isVisible) {
129      superClass.prototype.show.call(this, isVisible);
130      this.splitterView_.show(isVisible);
131    },
132
133    getFilterText_: function() {
134      return this.filterInput_.value;
135    },
136
137    setFilterText_: function(filterText) {
138      this.filterInput_.value = filterText;
139      this.onFilterTextChanged_();
140    },
141
142    onFilterTextChanged_: function() {
143      this.setFilter_(this.getFilterText_());
144    },
145
146    /**
147     * Updates text in the details view when privacy stripping is toggled.
148     */
149    onPrivacyStrippingChanged: function() {
150      this.invalidateDetailsView_();
151    },
152
153    /**
154     * Updates text in the details view when time display mode is toggled.
155     */
156    onUseRelativeTimesChanged: function() {
157      this.invalidateDetailsView_();
158    },
159
160    comparisonFuncWithReversing_: function(a, b) {
161      var result = this.comparisonFunction_(a, b);
162      if (this.doSortBackwards_)
163        result *= -1;
164      return result;
165    },
166
167    sort_: function() {
168      var sourceEntries = [];
169      for (var id in this.sourceIdToRowMap_) {
170        sourceEntries.push(this.sourceIdToRowMap_[id].getSourceEntry());
171      }
172      sourceEntries.sort(this.comparisonFuncWithReversing_.bind(this));
173
174      // Reposition source rows from back to front.
175      for (var i = sourceEntries.length - 2; i >= 0; --i) {
176        var sourceRow = this.sourceIdToRowMap_[sourceEntries[i].getSourceId()];
177        var nextSourceId = sourceEntries[i + 1].getSourceId();
178        if (sourceRow.getNextNodeSourceId() != nextSourceId) {
179          var nextSourceRow = this.sourceIdToRowMap_[nextSourceId];
180          sourceRow.moveBefore(nextSourceRow);
181        }
182      }
183    },
184
185    setFilter_: function(filterText) {
186      var lastComparisonFunction = this.comparisonFunction_;
187      var lastDoSortBackwards = this.doSortBackwards_;
188
189      var filterParser = new SourceFilterParser(filterText);
190      this.currentFilter_ = filterParser.filter;
191
192      this.pickSortFunction_(filterParser.sort);
193
194      if (lastComparisonFunction != this.comparisonFunction_ ||
195          lastDoSortBackwards != this.doSortBackwards_) {
196        this.sort_();
197      }
198
199      // Iterate through all of the rows and see if they match the filter.
200      for (var id in this.sourceIdToRowMap_) {
201        var entry = this.sourceIdToRowMap_[id];
202        entry.setIsMatchedByFilter(this.currentFilter_(entry.getSourceEntry()));
203      }
204    },
205
206    /**
207     * Given a "sort" object with "method" and "backwards" keys, looks up and
208     * sets |comparisonFunction_| and |doSortBackwards_|.  If the ID does not
209     * correspond to a sort function, defaults to sorting by ID.
210     */
211    pickSortFunction_: function(sort) {
212      this.doSortBackwards_ = sort.backwards;
213      this.comparisonFunction_ = COMPARISON_FUNCTION_TABLE[sort.method];
214      if (!this.comparisonFunction_) {
215        this.doSortBackwards_ = false;
216        this.comparisonFunction_ = compareSourceId_;
217      }
218    },
219
220    /**
221     * Repositions |sourceRow|'s in the table using an insertion sort.
222     * Significantly faster than sorting the entire table again, when only
223     * one entry has changed.
224     */
225    insertionSort_: function(sourceRow) {
226      // SourceRow that should be after |sourceRow|, if it needs
227      // to be moved earlier in the list.
228      var sourceRowAfter = sourceRow;
229      while (true) {
230        var prevSourceId = sourceRowAfter.getPreviousNodeSourceId();
231        if (prevSourceId == null)
232          break;
233        var prevSourceRow = this.sourceIdToRowMap_[prevSourceId];
234        if (this.comparisonFuncWithReversing_(
235                sourceRow.getSourceEntry(),
236                prevSourceRow.getSourceEntry()) >= 0) {
237          break;
238        }
239        sourceRowAfter = prevSourceRow;
240      }
241      if (sourceRowAfter != sourceRow) {
242        sourceRow.moveBefore(sourceRowAfter);
243        return;
244      }
245
246      var sourceRowBefore = sourceRow;
247      while (true) {
248        var nextSourceId = sourceRowBefore.getNextNodeSourceId();
249        if (nextSourceId == null)
250          break;
251        var nextSourceRow = this.sourceIdToRowMap_[nextSourceId];
252        if (this.comparisonFuncWithReversing_(
253                sourceRow.getSourceEntry(),
254                nextSourceRow.getSourceEntry()) <= 0) {
255          break;
256        }
257        sourceRowBefore = nextSourceRow;
258      }
259      if (sourceRowBefore != sourceRow)
260        sourceRow.moveAfter(sourceRowBefore);
261    },
262
263    /**
264     * Called whenever SourceEntries are updated with new log entries.  Updates
265     * the corresponding table rows, sort order, and the details view as needed.
266     */
267    onSourceEntriesUpdated: function(sourceEntries) {
268      var isUpdatedSourceSelected = false;
269      var numNewSourceEntries = 0;
270
271      for (var i = 0; i < sourceEntries.length; ++i) {
272        var sourceEntry = sourceEntries[i];
273
274        // Lookup the row.
275        var sourceRow = this.sourceIdToRowMap_[sourceEntry.getSourceId()];
276
277        if (!sourceRow) {
278          sourceRow = new SourceRow(this, sourceEntry);
279          this.sourceIdToRowMap_[sourceEntry.getSourceId()] = sourceRow;
280          ++numNewSourceEntries;
281        } else {
282          sourceRow.onSourceUpdated();
283        }
284
285        if (sourceRow.isSelected())
286          isUpdatedSourceSelected = true;
287
288        // TODO(mmenke): Fix sorting when sorting by duration.
289        //               Duration continuously increases for all entries that
290        //               are still active.  This can result in incorrect
291        //               sorting, until sort_ is called.
292        this.insertionSort_(sourceRow);
293      }
294
295      if (isUpdatedSourceSelected)
296        this.invalidateDetailsView_();
297      if (numNewSourceEntries)
298        this.incrementPrefilterCount(numNewSourceEntries);
299    },
300
301    /**
302     * Returns the SourceRow with the specified ID, if there is one.
303     * Otherwise, returns undefined.
304     */
305    getSourceRow: function(id) {
306      return this.sourceIdToRowMap_[id];
307    },
308
309    /**
310     * Called whenever all log events are deleted.
311     */
312    onAllSourceEntriesDeleted: function() {
313      this.initializeSourceList_();
314    },
315
316    /**
317     * Called when either a log file is loaded, after clearing the old entries,
318     * but before getting any new ones.
319     */
320    onLoadLogStart: function() {
321      // Needed to sort new sourceless entries correctly.
322      this.maxReceivedSourceId_ = 0;
323    },
324
325    onLoadLogFinish: function(data) {
326      return true;
327    },
328
329    incrementPrefilterCount: function(offset) {
330      this.numPrefilter_ += offset;
331      this.invalidateFilterCounter_();
332    },
333
334    incrementPostfilterCount: function(offset) {
335      this.numPostfilter_ += offset;
336      this.invalidateFilterCounter_();
337    },
338
339    onSelectionChanged: function() {
340      this.invalidateDetailsView_();
341    },
342
343    clearSelection: function() {
344      var prevSelection = this.currentSelectedRows_;
345      this.currentSelectedRows_ = [];
346
347      // Unselect everything that is currently selected.
348      for (var i = 0; i < prevSelection.length; ++i) {
349        prevSelection[i].setSelected(false);
350      }
351
352      this.onSelectionChanged();
353    },
354
355    selectAll_: function(event) {
356      for (var id in this.sourceIdToRowMap_) {
357        var sourceRow = this.sourceIdToRowMap_[id];
358        if (sourceRow.isMatchedByFilter()) {
359          sourceRow.setSelected(true);
360        }
361      }
362      event.preventDefault();
363    },
364
365    unselectAll_: function() {
366      var entries = this.currentSelectedRows_.slice(0);
367      for (var i = 0; i < entries.length; ++i) {
368        entries[i].setSelected(false);
369      }
370    },
371
372    /**
373     * If |params| includes a query, replaces the current filter and unselects.
374     * all items.  If it includes a selection, tries to select the relevant
375     * item.
376     */
377    setParameters: function(params) {
378      if (params.q) {
379        this.unselectAll_();
380        this.setFilterText_(params.q);
381      }
382
383      if (params.s) {
384        var sourceRow = this.sourceIdToRowMap_[params.s];
385        if (sourceRow) {
386          sourceRow.setSelected(true);
387          this.scrollToSourceId(params.s);
388        }
389      }
390    },
391
392    /**
393     * Scrolls to the source indicated by |sourceId|, if displayed.
394     */
395    scrollToSourceId: function(sourceId) {
396      this.detailsView_.scrollToSourceId(sourceId);
397    },
398
399    /**
400     * If already using the specified sort method, flips direction.  Otherwise,
401     * removes pre-existing sort parameter before adding the new one.
402     */
403    toggleSortMethod_: function(sortMethod) {
404      // Get old filter text and remove old sort directives, if any.
405      var filterParser = new SourceFilterParser(this.getFilterText_());
406      var filterText = filterParser.filterTextWithoutSort;
407
408      filterText = 'sort:' + sortMethod + ' ' + filterText;
409
410      // If already using specified sortMethod, sort backwards.
411      if (!this.doSortBackwards_ &&
412          COMPARISON_FUNCTION_TABLE[sortMethod] == this.comparisonFunction_) {
413        filterText = '-' + filterText;
414      }
415
416      this.setFilterText_(filterText.trim());
417    },
418
419    sortById_: function(event) {
420      this.toggleSortMethod_('id');
421    },
422
423    sortBySourceType_: function(event) {
424      this.toggleSortMethod_('source');
425    },
426
427    sortByDescription_: function(event) {
428      this.toggleSortMethod_('desc');
429    },
430
431    /**
432     * Modifies the map of selected rows to include/exclude the one with
433     * |sourceId|, if present.  Does not modify checkboxes or the LogView.
434     * Should only be called by a SourceRow in response to its selection
435     * state changing.
436     */
437    modifySelectionArray: function(sourceId, addToSelection) {
438      var sourceRow = this.sourceIdToRowMap_[sourceId];
439      if (!sourceRow)
440        return;
441      // Find the index for |sourceEntry| in the current selection list.
442      var index = -1;
443      for (var i = 0; i < this.currentSelectedRows_.length; ++i) {
444        if (this.currentSelectedRows_[i] == sourceRow) {
445          index = i;
446          break;
447        }
448      }
449
450      if (index != -1 && !addToSelection) {
451        // Remove from the selection.
452        this.currentSelectedRows_.splice(index, 1);
453      }
454
455      if (index == -1 && addToSelection) {
456        this.currentSelectedRows_.push(sourceRow);
457      }
458    },
459
460    getSelectedSourceEntries_: function() {
461      var sourceEntries = [];
462      for (var i = 0; i < this.currentSelectedRows_.length; ++i) {
463        sourceEntries.push(this.currentSelectedRows_[i].getSourceEntry());
464      }
465      return sourceEntries;
466    },
467
468    invalidateDetailsView_: function() {
469      this.detailsView_.setData(this.getSelectedSourceEntries_());
470    },
471
472    invalidateFilterCounter_: function() {
473      if (!this.outstandingRepaintFilterCounter_) {
474        this.outstandingRepaintFilterCounter_ = true;
475        window.setTimeout(this.repaintFilterCounter_.bind(this),
476                          REPAINT_FILTER_COUNTER_TIMEOUT_MS);
477      }
478    },
479
480    repaintFilterCounter_: function() {
481      this.outstandingRepaintFilterCounter_ = false;
482      this.filterCount_.innerHTML = '';
483      addTextNode(this.filterCount_,
484                  this.numPostfilter_ + ' of ' + this.numPrefilter_);
485    }
486  };  // end of prototype.
487
488  // ------------------------------------------------------------------------
489  // Helper code for comparisons
490  // ------------------------------------------------------------------------
491
492  var COMPARISON_FUNCTION_TABLE = {
493    // sort: and sort:- are allowed
494    '': compareSourceId_,
495    'active': compareActive_,
496    'desc': compareDescription_,
497    'description': compareDescription_,
498    'duration': compareDuration_,
499    'id': compareSourceId_,
500    'source': compareSourceType_,
501    'type': compareSourceType_
502  };
503
504  /**
505   * Sorts active entries first.  If both entries are inactive, puts the one
506   * that was active most recently first.  If both are active, uses source ID,
507   * which puts longer lived events at the top, and behaves better than using
508   * duration or time of first event.
509   */
510  function compareActive_(source1, source2) {
511    if (!source1.isInactive() && source2.isInactive())
512      return -1;
513    if (source1.isInactive() && !source2.isInactive())
514      return 1;
515    if (source1.isInactive()) {
516      var deltaEndTime = source1.getEndTicks() - source2.getEndTicks();
517      if (deltaEndTime != 0) {
518        // The one that ended most recently (Highest end time) should be sorted
519        // first.
520        return -deltaEndTime;
521      }
522      // If both ended at the same time, then odds are they were related events,
523      // started one after another, so sort in the opposite order of their
524      // source IDs to get a more intuitive ordering.
525      return -compareSourceId_(source1, source2);
526    }
527    return compareSourceId_(source1, source2);
528  }
529
530  function compareDescription_(source1, source2) {
531    var source1Text = source1.getDescription().toLowerCase();
532    var source2Text = source2.getDescription().toLowerCase();
533    var compareResult = source1Text.localeCompare(source2Text);
534    if (compareResult != 0)
535      return compareResult;
536    return compareSourceId_(source1, source2);
537  }
538
539  function compareDuration_(source1, source2) {
540    var durationDifference = source2.getDuration() - source1.getDuration();
541    if (durationDifference)
542      return durationDifference;
543    return compareSourceId_(source1, source2);
544  }
545
546  /**
547   * For the purposes of sorting by source IDs, entries without a source
548   * appear right after the SourceEntry with the highest source ID received
549   * before the sourceless entry. Any ambiguities are resolved by ordering
550   * the entries without a source by the order in which they were received.
551   */
552  function compareSourceId_(source1, source2) {
553    var sourceId1 = source1.getSourceId();
554    if (sourceId1 < 0)
555      sourceId1 = source1.getMaxPreviousEntrySourceId();
556    var sourceId2 = source2.getSourceId();
557    if (sourceId2 < 0)
558      sourceId2 = source2.getMaxPreviousEntrySourceId();
559
560    if (sourceId1 != sourceId2)
561      return sourceId1 - sourceId2;
562
563    // One or both have a negative ID. In either case, the source with the
564    // highest ID should be sorted first.
565    return source2.getSourceId() - source1.getSourceId();
566  }
567
568  function compareSourceType_(source1, source2) {
569    var source1Text = source1.getSourceTypeString();
570    var source2Text = source2.getSourceTypeString();
571    var compareResult = source1Text.localeCompare(source2Text);
572    if (compareResult != 0)
573      return compareResult;
574    return compareSourceId_(source1, source2);
575  }
576
577  return EventsView;
578})();
579