dbus-vis: Initial commit

dbus-vis visualizes PCAP files captured by busctl as well as
text-format outputs from busctl.

dbus-vis displays the visualized results in horizontal
timelines, where each horizontal bar represents a DBus request, or
an IPMI request (an IPMI request is a specific kind of DBus request
sent to the IPMI daemon.)
The requests may be grouped by one or more fields including
type, sender, destination, path, interface, member.

In addition, if the BMC console is available, dbus-vis can connect
to the BMC console and capture DBus/IPMI requests in real time.

dbus-vis is in the form of a web page and is wrapped using Electron
to run as a desktop app.

Basic usage:
* Mouse click and drag:
  * If mouse is over time axis: drag timeline horizontally
  * If mouse is over vertical scroll bar: scroll up/down
  * Otherwise: select a region for measuring time & zooming in
* Mouse Scroll wheel:
  * If mouse is over time axis: zoom in/out
  * Otherwise: scroll up/down
* Click on the expandable headers will expand/collapse the entries under
that particular header.

Signed-off-by: Sui Chen <suichen@google.com>
Change-Id: I863c2ba80025d10efb44fd12868e37912fae9a47
diff --git a/dbus-vis/ipmi_timeline_vis.js b/dbus-vis/ipmi_timeline_vis.js
new file mode 100644
index 0000000..64af5fc
--- /dev/null
+++ b/dbus-vis/ipmi_timeline_vis.js
@@ -0,0 +1,726 @@
+// This file is about the layout (Preproess() and Group()) of the IPMI time line view.
+// The layout happens according to the following sequence that is very similar to how
+// the layout is done for DBus messages:
+//
+// 1. User clicks any of the checkboxes for the grouping fields (NetFN, CMD)
+// 2. OnGroupByConditionChanged() is called
+// 3. OnGroupByConditionChanged() calls PreProcess() and Group()
+// 4. PreProcess() takes the IPMI messages extracted from the DBus capture
+//    (g_ipmi_parsed_entries), and determines the start time.
+// 5. Group() takes the IPMI messages, and the list of keys, and groups the messages
+//    by the keys. The output is picked up by GenerateTimeLine(), which writes the
+//    timeline data into the Intervals and Titles arrays. The draw loop immediately
+//    picks up the updated Intervals and Titles arrays and draws on the canvas
+//    accordingly.
+
+const {dialog} = require('electron').remote;
+const {fs} = require('file-system');
+const {util} = require('util');
+const {exec} = require('child_process');
+
+// Main view objects
+var ipmi_timeline_view = new IPMITimelineView();
+ipmi_timeline_view.IsTimeDistributionEnabled = true;
+
+var btn_start_capture = document.getElementById('btn_start_capture');
+var select_capture_mode = document.getElementById('select_capture_mode');
+var capture_info = document.getElementById('capture_info');
+
+var radio_open_file = document.getElementById('radio_open_file');
+var radio_capture = document.getElementById('radio_capture');
+var title_open_file = document.getElementById('title_open_file');
+var title_capture = document.getElementById('title_capture');
+
+// Set up Electron-related stuff here; Electron does not allow inlining button
+// events
+document.getElementById('c1').addEventListener(
+    'click', OnGroupByConditionChanged);  // NetFN
+document.getElementById('c2').addEventListener(
+    'click', OnGroupByConditionChanged);  // CMD
+
+// Zoom in button
+document.getElementById('btn_zoom_in').addEventListener('click', function() {
+  ipmi_timeline_view.BeginZoomAnimation(0.5);
+  boost_asio_handler_timeline_view.BeginZoomAnimation(0.5);
+});
+
+// Zoom out button
+document.getElementById('btn_zoom_out').addEventListener('click', function() {
+  ipmi_timeline_view.BeginZoomAnimation(-1);
+  boost_asio_handler_timeline_view.BeginZoomAnimation(-1);
+});
+
+// Pan left button
+document.getElementById('btn_pan_left').addEventListener('click', function() {
+  ipmi_timeline_view.BeginPanScreenAnimaton(-0.5);
+  boost_asio_handler_timeline_view.BeginPanScreenAnimaton(-0.5);
+});
+
+// Pan right button
+document.getElementById('btn_pan_right').addEventListener('click', function() {
+  ipmi_timeline_view.BeginPanScreenAnimaton(0.5);
+  boost_asio_handler_timeline_view.BeginPanScreenAnimaton(0.5);
+});
+
+// Reset zoom button
+document.getElementById('btn_zoom_reset').addEventListener('click', function() {
+  ipmi_timeline_view.BeginSetBoundaryAnimation(
+      RANGE_LEFT_INIT, RANGE_RIGHT_INIT)
+  dbus_timeline_view.BeginSetBoundaryAnimation(
+      RANGE_LEFT_INIT, RANGE_RIGHT_INIT)
+  boost_asio_handler_timeline_view.BeginSetBoundaryAnimation(
+      RANGE_LEFT_INIT, RANGE_RIGHT_INIT)
+})
+
+// Generate replay
+document.getElementById('gen_replay_ipmitool1')
+    .addEventListener('click', function() {
+      GenerateIPMIToolIndividualCommandReplay(HighlightedRequests);
+    });
+document.getElementById('gen_replay_ipmitool2')
+    .addEventListener('click', function() {
+      GenerateIPMIToolExecListReplay(HighlightedRequests);
+    });
+document.getElementById('gen_replay_ipmid_legacy')
+    .addEventListener('click', function() {
+      GenerateBusctlReplayLegacyInterface(HighlightedRequests);
+    });
+document.getElementById('gen_replay_ipmid_new')
+    .addEventListener('click', function() {
+      GenerateBusctlReplayNewInterface(HighlightedRequests);
+    });
+document.getElementById('btn_start_capture')
+    .addEventListener('click', function() {
+      let h = document.getElementById('text_hostname').value;
+      g_capture_state = 'started';
+      StartCapture(h);
+    });
+
+// For capture mode
+document.getElementById('btn_stop_capture')
+    .addEventListener('click', function() {
+      StopCapture();
+    });
+document.getElementById('select_capture_mode')
+    .addEventListener('click', OnCaptureModeChanged);
+radio_open_file.addEventListener('click', OnAppModeChanged);
+radio_capture.addEventListener('click', OnAppModeChanged);
+
+radio_open_file.click();
+
+// App mode: open file or capture
+function OnAppModeChanged() {
+  title_open_file.style.display = 'none';
+  title_capture.style.display = 'none';
+  if (radio_open_file.checked) {
+    title_open_file.style.display = 'block';
+  }
+  if (radio_capture.checked) {
+    title_capture.style.display = 'block';
+  }
+}
+
+// Capture mode: Live capture or staged capture
+function OnCaptureModeChanged() {
+  let x = select_capture_mode;
+  let i = capture_info;
+  let desc = '';
+  switch (x.value) {
+    case 'live':
+      desc = 'Live: read BMC\'s dbus-monitor console output directly';
+      g_capture_mode = 'live';
+      break;
+    case 'staged':
+      desc =
+          'Staged, IPMI only: Store BMC\'s dbus-monitor output in a file and transfer back for display';
+      g_capture_mode = 'staged';
+      break;
+    case 'staged2':
+      desc =
+          'Staged, DBus + IPMI: Store BMC\'s busctl output in a file and transfer back for display';
+      g_capture_mode = 'staged2';
+      break;
+  }
+  i.textContent = desc;
+}
+
+// Data
+var HistoryHistogram = [];
+// var Data_IPMI = []
+
+// =====================
+
+let Intervals = [];
+let Titles = [];
+let HighlightedRequests = [];
+let GroupBy = [];
+let GroupByStr = '';
+
+// (NetFn, Cmd) -> [ Bucket Indexes ]
+// Normalized (0~1) bucket index for the currently highlighted IPMI requests
+let IpmiVizHistHighlighted = {};
+let HistogramThresholds = {};
+
+function IsIntersected(i0, i1) {
+  return (!((i0[1] < i1[0]) || (i0[0] > i1[1])));
+}
+
+function IsIntersectedPixelCoords(i0, i1) {
+  if (i0[1] == undefined || isNaN(i0[1])) {
+    return (Math.abs(i0[0] - i1[0]) < 5);
+  } else {
+    return (IsIntersected(i0, i1));
+  }
+}
+
+var NetFnCmdToDescription = {
+  '6, 1': 'App-GetDeviceId',
+  '6, 3': 'App-WarmReset',
+  '10, 64': 'Storage-GetSelInfo',
+  '10, 35': 'Storage-GetSdr',
+  '4, 32': 'Sensor-GetDeviceSDRInfo',
+  '4, 34': 'Sensor-ReserveDeviceSDRRepo',
+  '4, 47': 'Sensor-GetSensorType',
+  '10, 34': 'Storage-ReserveSdrRepository',
+  '46, 50': 'OEM Extension',
+  '4, 39': 'Sensor-GetSensorThresholds',
+  '4, 45': 'Sensor-GetSensorReading',
+  '10, 67': 'Storage-GetSelEntry',
+  '58, 196': 'IBM_OEM',
+  '10, 32': 'Storage-GetSdrRepositoryInfo',
+  '4, 33': 'Sensor-GetDeviceSDR',
+  '6, 54': 'App-Get BT Interface Capabilities',
+  '10, 17': 'Storage-ReadFruData',
+  '10, 16': 'Storage-GetFruInventoryAreaInfo',
+  '4, 2': 'Sensor-PlatformEvent',
+  '4, 48': 'Sensor-SetSensor',
+  '6, 34': 'App-ResetWatchdogTimer'
+};
+
+const CANVAS_H = document.getElementById('my_canvas_ipmi').height;
+const CANVAS_W = document.getElementById('my_canvas_ipmi').width;
+
+var LowerBoundTime = RANGE_LEFT_INIT;
+var UpperBoundTime = RANGE_RIGHT_INIT;
+var LastTimeLowerBound;
+var LastTimeUpperBound;
+// Dirty flags for determining when to redraw the canvas
+let IsCanvasDirty = true;
+let IsHighlightDirty = false;
+// Animating left and right boundaries
+let IsAnimating = false;
+let LowerBoundTimeTarget = LowerBoundTime;
+let UpperBoundTimeTarget = UpperBoundTime;
+// For keyboard interaction: arrow keys and Shift
+let CurrDeltaX = 0;         // Proportion of Canvas to scroll per frame.
+let CurrDeltaZoom = 0;      // Delta zoom per frame.
+let CurrShiftFlag = false;  // Whether the Shift key is depressed
+
+// TODO: these variables are shared across all views but are now in ipmi_timeline_vis.js, need to move to some other location some time
+const LEFT_MARGIN = 640
+const RIGHT_MARGIN = 1390;
+const LINE_HEIGHT = 15;
+const LINE_SPACING = 17;
+const YBEGIN = 22 + LINE_SPACING;
+const TOP_HORIZONTAL_SCROLLBAR_HEIGHT = YBEGIN - LINE_HEIGHT / 2; // ybegin is the center of the 1st line of the text so need to minus line_height/2
+const BOTTOM_HORIZONTAL_SCROLLBAR_HEIGHT = LINE_HEIGHT;
+const TEXT_Y0 = 3;
+const HISTOGRAM_W = 100, HISTOGRAM_H = LINE_SPACING;
+const HISTOGRAM_X = 270;
+// If some request's time is beyond the right tail, it's considered "too long"
+// If some request's time is below the left tail it's considered "good"
+// const HISTOGRAM_LEFT_TAIL_WIDTH = 0.05, HISTOGRAM_RIGHT_TAIL_WIDTH = 0.05;
+// temporarily disabled for now
+const HISTOGRAM_LEFT_TAIL_WIDTH = -1, HISTOGRAM_RIGHT_TAIL_WIDTH = -1;
+const SCROLL_BAR_WIDTH = 16;
+
+let IpmiVizHistogramImageData = {};  // Image data for rendered histogram
+
+// Input is the data that's completed layout
+// is_free_x:     Should each histogram has its own X range or not
+// num_buckets: # of buckets for histograms
+// theta: top and bottom portion to cut
+function ComputeHistogram(num_buckets = 30, is_free_x = true) {
+  let global_lb = Infinity, global_ub = -Infinity;
+  IpmiVizHistogramImageData = {};
+  // Global minimal and maximal values
+  for (let i = 0; i < Intervals.length; i++) {
+    let interval = Intervals[i];
+    let l = Math.min.apply(Math, interval.map(function(x) {
+      return x[1] - x[0];
+    }));
+    let u = Math.max.apply(Math, interval.map(function(x) {
+      return x[1] - x[0];
+    }));
+    global_lb = Math.min(l, global_lb);
+    global_ub = Math.max(u, global_ub);
+  }
+
+  HistoryHistogram = [];
+  for (let i = 0; i < Intervals.length; i++) {
+    let interval = Intervals[i];
+    let lb = global_lb, ub = global_ub;
+    if (is_free_x == true) {
+      lb = Math.min.apply(Math, interval.map(function(x) {
+        return x[1] - x[0];
+      }));
+      ub = Math.max.apply(Math, interval.map(function(x) {
+        return x[1] - x[0];
+      }));
+    }
+    const EPS = 1e-2;
+    if (lb == ub) ub = lb + EPS;
+    let line = [lb * 1000000, ub * 1000000];  // to usec
+    let buckets = [];
+    for (let j = 0; j < num_buckets; j++) buckets.push(0);
+    for (let j = 0; j < interval.length; j++) {
+      let t = interval[j][1] - interval[j][0];
+      let bucket_idx = parseInt(t / ((ub - lb) / num_buckets));
+      buckets[bucket_idx]++;
+    }
+    line.push(buckets);
+    HistoryHistogram[Titles[i].title] = line;
+  }
+}
+
+function Preprocess(data) {
+  preprocessed = [];
+  let StartingUsec_IPMI;
+
+  if (g_StartingSec == undefined) {
+    StartingUsec_IPMI = undefined;
+  } else {
+    StartingUsec_IPMI = g_StartingSec * 1000000;
+  }
+
+  for (let i = 0; i < data.length; i++) {
+    let entry = data[i].slice();
+    let lb = entry[2], ub = entry[3];
+
+    // Only when IPMI view is present (i.e. no DBus pcap is loaded)
+    if (i == 0 && StartingUsec_IPMI == undefined) {
+      StartingUsec_IPMI = lb;
+    }
+
+    entry[2] = lb - StartingUsec_IPMI;
+    entry[3] = ub - StartingUsec_IPMI;
+    preprocessed.push(entry);
+  }
+  return preprocessed;
+}
+
+let SHOW_BLOB_DETAILS = true;
+function Group(data, groupBy) {
+  let grouped = {};
+
+  // If has netfn and cmd: use "NetFN, CMD" as key
+  // Otherwise, use "NetFN" as key
+  // This distinction is made if the user chooses to label operation on each
+  // blob individually
+
+  // Key:   blob name
+  // Value: the commands that operate on the particular blob
+  let sid2blobid = {}
+
+  for (let n = 0; n < data.length; n++) {
+    const p = data[n];
+    const netfn = p[0], cmd = p[1], req = p[4], res = p[5];
+    if (netfn == 46 && cmd == 128) {
+      const oen = req[0] + req[1] * 256 + req[2] * 65536;
+      if (oen == 0xc2cf) {  // Blob operations
+        const blobcmd =
+            req[3];  // Refer to https://github.com/openbmc/phosphor-ipmi-blobs
+
+        // The IPMI blob commands are visible on DBus, another WIP command-line tool that
+        // utilizes this fact to show information about blobs can be found here:
+        // https://gerrit.openbmc-project.xyz/c/openbmc/openbmc-tools/+/41451
+
+        let sid, blobid;
+
+        // layout of req
+        //  0  1  2   3  4  5   6  7  8  9  10 ...
+        // CF C2 00 CMD [CRC ] [ other stuff  ]
+
+        // layout of res
+        //  0  1  2   3  4   5   6  7  8  ...
+        // CF C2 00  [CRC ] [other stuff]
+
+        // Determining blob id and session ID
+        switch (blobcmd) {
+          case 3:
+          case 4:
+          case 5:
+          case 6:
+          case 9:
+          case 10: {
+            const sid = req[6] + req[7] * 256;
+            blobid = sid2blobid[sid];
+            if (blobid != undefined) {
+              p.key = blobid;
+            }
+            break;
+          }
+          case 7:
+          case 8: {
+            blobid = '';
+            for (let i = 6; i < req.length; i++) {
+              blobid += String.fromCharCode(req[i]);
+            }
+            break;
+          }
+        }
+
+        switch (blobcmd) {
+          case 2: {  // open
+            blobid = '';
+            for (let i = 8; i < req.length; i++) {
+              if (req[i] == 0)
+                break;
+              else
+                blobid += String.fromCharCode(req[i]);
+            }
+            p.key = blobid;
+            sid = res[5] + res[6] * 256;  // session_id
+            sid2blobid[sid] = blobid;
+            break;
+          }
+          case 3: {  // Read
+
+            break;
+          }
+          case 4: {  // Write
+            const offset =
+                req[8] + req[9] * 256 + req[10] * 65536 + req[11] * 16777216;
+            p.offset = offset;
+            break;
+          }
+          case 5: {  // Commit
+            break;
+          }
+          case 6: {  // Close
+            break;
+          }
+        }
+      }
+    }
+  }
+
+  const idxes = {'NetFN': 0, 'CMD': 1};
+
+  //
+  for (let n = 0; n < data.length; n++) {
+    const p = data[n];
+    let key = '';
+    if (p.key != undefined)
+      key = p.key;
+    else if (p[0] != '' && p[1] != '') {
+      for (let i = 0; i < groupBy.length; i++) {
+        if (i > 0) {
+          key += ', ';
+        }
+        key += p[idxes[groupBy[i]]];
+      }
+    }
+
+    if (grouped[key] == undefined) {
+      grouped[key] = [];
+    }
+    grouped[key].push(p);
+  }
+
+  return grouped;
+}
+
+function GenerateTimeLine(grouped) {
+  const keys = Object.keys(grouped);
+  let sortedKeys = keys.slice();
+  // If NetFN and CMD are both selected, sort by NetFN then CMD
+  // In this case, all "keys" are string-encoded integer pairs
+  if (keys.length > 0 && ipmi_timeline_view.GroupBy.length == 2) {
+    sortedKeys = sortedKeys.sort(function(a, b) {
+      a = a.split(',');
+      b = b.split(',');
+      if (a.length == 2 && b.length == 2) {
+        let aa = parseInt(a[0]) * 256 + parseInt(a[1]);
+        let bb = parseInt(b[0]) * 256 + parseInt(b[1]);
+        return aa < bb ? -1 : (aa > bb ? 1 : 0);
+      } else {
+        return a < b ? -1 : (a > b ? 1 : 0);
+      }
+    });
+  }
+
+  Intervals = [];
+  Titles = [];
+  for (let i = 0; i < sortedKeys.length; i++) {
+    Titles.push({"header":false, "title":sortedKeys[i], "intervals_idxes":[i]});
+    line = [];
+    for (let j = 0; j < grouped[sortedKeys[i]].length; j++) {
+      let entry = grouped[sortedKeys[i]][j];
+      // Lower bound, Upper bound, and a reference to the original request
+      line.push([
+        parseFloat(entry[2]) / 1000000, parseFloat(entry[3]) / 1000000, entry,
+        'ok', 0
+      ]);
+    }
+    Intervals.push(line);
+  }
+
+  ipmi_timeline_view.Intervals = Intervals.slice();
+  ipmi_timeline_view.Titles = Titles.slice();
+  ipmi_timeline_view.LayoutForOverlappingIntervals();
+}
+
+function OnGroupByConditionChanged() {
+  const tags = ['c1', 'c2'];
+  const v = ipmi_timeline_view;
+  v.GroupBy = [];
+  v.GroupByStr = '';
+  for (let i = 0; i < tags.length; i++) {
+    let cb = document.getElementById(tags[i]);
+    if (cb.checked) {
+      v.GroupBy.push(cb.value);
+      if (v.GroupByStr.length > 0) {
+        v.GroupByStr += ', ';
+      }
+      v.GroupByStr += cb.value;
+    }
+  }
+  let preproc = Preprocess(Data_IPMI);
+  grouped = Group(preproc, v.GroupBy);
+  GenerateTimeLine(grouped);
+
+  IsCanvasDirty = true;
+  ipmi_timeline_view.IsCanvasDirty = true;
+}
+
+function MapXCoord(x, left_margin, right_margin, rl, rr) {
+  let ret = left_margin + (x - rl) / (rr - rl) * (right_margin - left_margin);
+  if (ret < left_margin) {
+    ret = left_margin;
+  } else if (ret > right_margin) {
+    ret = right_margin;
+  }
+  return ret;
+}
+
+function draw_timeline(ctx) {
+  ipmi_timeline_view.Render(ctx);
+}
+
+
+window.addEventListener('keydown', function() {
+  if (event.keyCode == 37) {  // Left Arrow
+    ipmi_timeline_view.CurrDeltaX = -0.004;
+    dbus_timeline_view.CurrDeltaX = -0.004;
+  } else if (event.keyCode == 39) {  // Right arrow
+    ipmi_timeline_view.CurrDeltaX = 0.004;
+    dbus_timeline_view.CurrDeltaX = 0.004;
+  } else if (event.keyCode == 16) {  // Shift
+    ipmi_timeline_view.CurrShiftFlag = true;
+    dbus_timeline_view.CurrShiftFlag = true;
+  } else if (event.keyCode == 38) {  // Up arrow
+    ipmi_timeline_view.CurrDeltaZoom = 0.01;
+    dbus_timeline_view.CurrDeltaZoom = 0.01;
+  } else if (event.keyCode == 40) {  // Down arrow
+    ipmi_timeline_view.CurrDeltaZoom = -0.01;
+    dbus_timeline_view.CurrDeltaZoom = -0.01;
+  }
+});
+
+window.addEventListener('keyup', function() {
+  if (event.keyCode == 37 || event.keyCode == 39) {
+    ipmi_timeline_view.CurrDeltaX = 0;
+    dbus_timeline_view.CurrDeltaX = 0;
+  } else if (event.keyCode == 16) {
+    ipmi_timeline_view.CurrShiftFlag = false;
+    dbus_timeline_view.CurrShiftFlag = false;
+  } else if (event.keyCode == 38 || event.keyCode == 40) {
+    ipmi_timeline_view.CurrDeltaZoom = 0;
+    dbus_timeline_view.CurrDeltaZoom = 0;
+  }
+});
+
+function MouseXToTimestamp(x) {
+  let ret = (x - LEFT_MARGIN) / (RIGHT_MARGIN - LEFT_MARGIN) *
+          (UpperBoundTime - LowerBoundTime) +
+      LowerBoundTime;
+  ret = Math.max(ret, LowerBoundTime);
+  ret = Math.min(ret, UpperBoundTime);
+  return ret;
+}
+
+let HighlightedRegion = {t0: -999, t1: -999};
+
+function IsHighlighted() {
+  return (HighlightedRegion.t0 != -999 && HighlightedRegion.t1 != -999);
+}
+
+function Unhighlight() {
+  HighlightedRegion.t0 = -999;
+  HighlightedRegion.t1 = -999;
+}
+
+function UnhighlightIfEmpty() {
+  if (HighlightedRegion.t0 == HighlightedRegion.t1) {
+    Unhighlight();
+    return true;
+  }
+  return false;
+}
+
+let MouseState = {
+  hovered: true,
+  pressed: false,
+  x: 0,
+  y: 0,
+  hoveredVisibleLineIndex: -999,
+  hoveredSide: undefined
+};
+let Canvas = document.getElementById('my_canvas_ipmi');
+
+Canvas.onmousemove = function(event) {
+  const v = ipmi_timeline_view;
+  v.MouseState.x = event.pageX - this.offsetLeft;
+  v.MouseState.y = event.pageY - this.offsetTop;
+  if (v.MouseState.pressed == true) {  // Update highlighted area
+    v.HighlightedRegion.t1 = v.MouseXToTimestamp(v.MouseState.x);
+  }
+  v.OnMouseMove();
+  v.IsCanvasDirty = true;
+
+  v.linked_views.forEach(function(u) {
+    u.MouseState.x = event.pageX - Canvas.offsetLeft;
+    u.MouseState.y = 0;                  // Do not highlight any entry
+    if (u.MouseState.pressed == true) {  // Update highlighted area
+      u.HighlightedRegion.t1 = u.MouseXToTimestamp(u.MouseState.x);
+    }
+    u.OnMouseMove();
+    u.IsCanvasDirty = true;
+  });
+};
+
+Canvas.onmouseover = function() {
+  ipmi_timeline_view.OnMouseMove();
+};
+
+Canvas.onmouseleave = function() {
+  ipmi_timeline_view.OnMouseLeave();
+};
+
+Canvas.onmousedown = function(event) {
+  if (event.button == 0) {  // Left mouse button
+    ipmi_timeline_view.OnMouseDown();
+  }
+};
+
+Canvas.onmouseup = function(event) {
+  if (event.button == 0) {
+    ipmi_timeline_view.OnMouseUp();
+    // page-specific, not view-specific
+    let hint = document.getElementById('highlight_hint');
+    if (ipmi_timeline_view.UnhighlightIfEmpty()) {
+      hint.style.display = 'none';
+    } else {
+      hint.style.display = 'block';
+    }
+  }
+};
+
+Canvas.onwheel = function(event) {
+  ipmi_timeline_view.OnMouseWheel(event);
+};
+
+// This function is not specific to TimelineView so putting it here
+function OnHighlightedChanged(reqs) {
+  let x = document.getElementById('ipmi_replay');
+  let i = document.getElementById('ipmi_replay_output');
+  let cnt = document.getElementById('highlight_count');
+  cnt.innerHTML = '' + reqs.length;
+  i.style.display = 'none';
+  if (reqs.length > 0) {
+    x.style.display = 'block';
+  } else
+    x.style.display = 'none';
+  let o = document.getElementById('ipmi_replay_output');
+  o.style.display = 'none';
+  o.textContent = '';
+}
+
+function ToHexString(bytes, prefix, sep) {
+  let ret = '';
+  for (let i = 0; i < bytes.length; i++) {
+    if (i > 0) {
+      ret += sep;
+    }
+    ret += prefix + bytes[i].toString(16);
+  }
+  return ret;
+}
+
+function ToASCIIString(bytes) {
+  ret = '';
+  for (let i = 0; i < bytes.length; i++) {
+    ret = ret + String.fromCharCode(bytes[i]);
+  }
+  return ret;
+}
+
+function ShowReplayOutputs(x, ncols) {
+  let o = document.getElementById('ipmi_replay_output');
+  o.cols = ncols;
+  o.style.display = 'block';
+  o.textContent = x;
+}
+
+function GenerateIPMIToolIndividualCommandReplay(reqs) {
+  let x = '';
+  for (let i = 0; i < reqs.length; i++) {
+    let req = reqs[i];
+    // [0]: NetFN, [1]: cmd, [4]: payload
+    // NetFN and cmd are DECIMAL while payload is HEXADECIMAL.
+    x = x + 'ipmitool raw ' + req[0] + ' ' + req[1] + ' ' +
+        ToHexString(req[4], '0x', ' ') + '\n';
+  }
+  ShowReplayOutputs(x, 80);
+}
+
+function GenerateIPMIToolExecListReplay(reqs) {
+  console.log(reqs.length);
+  let x = '';
+  for (let i = 0; i < reqs.length; i++) {
+    let req = reqs[i];
+    x = x + 'raw ' +
+        ToHexString([req[0]].concat([req[1]]).concat(req[4]), '0x', ' ') + '\n';
+  }
+  ShowReplayOutputs(x, 80);
+}
+
+function GenerateBusctlReplayLegacyInterface(reqs) {
+  console.log(reqs.length);
+  let serial = 0;
+  let x = '';
+  for (let i = 0; i < reqs.length; i++) {
+    let req = reqs[i];
+    x = x +
+        'busctl --system emit  /org/openbmc/HostIpmi/1 org.openbmc.HostIpmi ReceivedMessage yyyyay ';
+    x = x + serial + ' ' + req[0] + ' 0 ' + req[1] + ' ' + req[4].length + ' ' +
+        ToHexString(req[4], '0x', ' ') + '\n';
+    serial = (serial + 1) % 256;
+  }
+  ShowReplayOutputs(x, 120);
+}
+
+function GenerateBusctlReplayNewInterface(reqs) {
+  console.log(reqs.length);
+  let x = '';
+  for (let i = 0; i < reqs.length; i++) {
+    let req = reqs[i];
+    x = x +
+        'busctl --system call xyz.openbmc_project.Ipmi.Host /xyz/openbmc_project/Ipmi xyz.openbmc_project.Ipmi.Server execute yyyaya{sv} ';
+    x = x + req[0] + ' 0 ' + req[1] + ' ' + req[4].length + ' ' +
+        ToHexString(req[4], '0x', ' ');
+    +' 0\n';
+  }
+  ShowReplayOutputs(x, 150);
+}