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/.clang-format b/dbus-vis/.clang-format
new file mode 100644
index 0000000..1240600
--- /dev/null
+++ b/dbus-vis/.clang-format
@@ -0,0 +1,5 @@
+---
+Language:        JavaScript
+BasedOnStyle:    Google
+ColumnLimit:     80
+...
diff --git a/dbus-vis/.gitignore b/dbus-vis/.gitignore
new file mode 100644
index 0000000..ea555fa
--- /dev/null
+++ b/dbus-vis/.gitignore
@@ -0,0 +1,4 @@
+node_modules
+*.js~
+dbus-pcap~
+*.tar.gz
diff --git a/dbus-vis/README.md b/dbus-vis/README.md
new file mode 100644
index 0000000..99af0b4
--- /dev/null
+++ b/dbus-vis/README.md
@@ -0,0 +1,43 @@
+This program captures & visualizes IPMI traffic on a BMC running OpenBMC. It allows the user to capture & view IPMI requests in a time line format, as well as generate commands that can talk to `ipmid` or `ipmitool` to replay those IPMI requests.
+
+## Build
+
+This program is based on Electron, and should be compatible with Windows, Linux, Mac and ChromeOS's Linux environment.
+
+The following commands are all run from this folder (where `index.html` is located.)
+
+To build and run, a user would first need to install node.js and npm (Node.js package manager), and then checkout `dbus-pcap` to this folder. To install node.js on a Ubuntu/Debian-based system:
+
+First, install `npm` and `node.js` using a method that is suitable for your setup.
+
+```
+$ node --version
+v10.20.1
+$ npm --version
+6.14.4
+```
+
+Then, with `npm`, `node.js` installed and `dbus-pcap` downloaded to this folder, run the following commands:
+
+1. `npm install`
+
+2. `npm start`
+
+## Run
+
+### Open existing file
+
+1. Select "Open an existing file"
+2. Click "Open file"
+3. Choose a file (The file should be a text file, and its contents should be dbus-monitor outputs)
+
+### Capture
+
+1. Select "Capture on a BMC"
+2. Fill the Megapede client name in the text box
+3. Choose a capture mode (live or staged)
+4. Click "start capture" and watch the status updates
+5. Click "stop capture" when done
+6. If something happens, manual clean-up might be needed, such as stopping dbus-monitor on the BMC
+
+![Image](./scrnshot.png)
diff --git a/dbus-vis/boost_handler_timeline_vis.js b/dbus-vis/boost_handler_timeline_vis.js
new file mode 100644
index 0000000..63378cb
--- /dev/null
+++ b/dbus-vis/boost_handler_timeline_vis.js
@@ -0,0 +1,219 @@
+// This script deals with Boost ASIO handlers
+// Will need to add code to visualize one-shot events
+
+// Fields: HandlerNumber, Level,
+// Creation time, Enter time, Exit time, EnterDescription,
+//    [ [Operation, time] ]
+
+// Will use g_StartingSec as the starting of epoch
+
+var ASIO_Data = [];
+var ASIO_Timestamp = [];
+
+function FindFirstEntrySlot(slots) {
+  let i = 0;
+  for (; i < slots.length; i++) {
+    if (slots[i] == undefined) break;
+  }
+  if (i >= slots.length) slots.push(undefined);
+  return i;
+}
+
+function SimplifyDesc(desc) {
+  const idx0 = desc.indexOf('0x');
+  if (idx0 == -1)
+    return desc;
+  else {
+    const d1 = desc.substr(idx0 + 2);
+    let idx1 = 0;
+    while (idx1 + 1 < d1.length &&
+           ((d1[idx1] >= '0' && d1[idx1] <= '9') ||
+            (d1[idx1] >= 'A' && d1[idx1] <= 'F') ||
+            (d1[idx1] >= 'a' && d1[idx1] <= 'f'))) {
+      idx1++;
+    }
+    return desc.substr(0, idx0) + d1.substr(idx1)
+  }
+}
+
+function ParseBoostHandlerTimeline(content) {
+  let parsed_entries = [];
+  const lines = content.split('\n');
+  let slots = [];               // In-flight handlers
+  let in_flight_id2level = {};  // In-flight ID to level
+
+  for (let lidx = 0; lidx < lines.length; lidx++) {
+    const line = lines[lidx];
+    if (line.startsWith('@asio|') == false) continue;
+    const sp = line.split('|');
+
+    const tag = sp[0], ts = sp[1], action = sp[2], desc = sp[3];
+    let handler_id = -999;
+    let ts_sec = parseFloat(ts);
+    const simp_desc = SimplifyDesc(desc);
+
+    if (action.indexOf('*') != -1) {
+      const idx = action.indexOf('*');
+      const handler_id = parseInt(action.substr(idx + 1));
+      const level = FindFirstEntrySlot(slots);
+
+      // Create an entry here
+      let entry = [
+        handler_id, level, ts_sec, undefined, undefined, desc, simp_desc, []
+      ];
+
+      slots[level] = entry;
+      in_flight_id2level[handler_id] = level;
+    } else if (action[0] == '>') {  // The program enters handler number X
+      handler_id = parseInt(action.substr(1));
+      if (handler_id in in_flight_id2level) {
+        const level = in_flight_id2level[handler_id];
+        let entry = slots[level];
+        entry[3] = ts_sec;
+      }
+    } else if (action[0] == '<') {
+      handler_id = parseInt(action.substr(1));
+      if (handler_id in in_flight_id2level) {
+        const level = in_flight_id2level[handler_id];
+        let entry = slots[level];
+        entry[4] = ts_sec;
+        slots[level] = undefined;
+        parsed_entries.push(entry);
+        delete in_flight_id2level[handler_id];
+      }
+    } else if (action[0] == '.') {  // syscalls
+    }
+  }
+
+  console.log(
+      'Boost handler log: ' + parsed_entries.length + ' entries' +
+      ', ' + slots.length + ' levels');
+  ASIO_Data = parsed_entries;
+  return parsed_entries;
+}
+
+function Group_ASIO(preprocessed, group_by) {
+  let grouped = {};
+  const IDXES = {'Layout Level': 1, 'Description': 5, 'Description1': 6};
+  for (var n = 0; n < preprocessed.length; n++) {
+    var key = ''
+    for (var i = 0; i < group_by.length; i++) {
+      if (i > 0) key += ' ';
+      key += ('' + preprocessed[n][IDXES[group_by[i]]]);
+    }
+    if (grouped[key] == undefined) grouped[key] = [];
+    grouped[key].push(preprocessed[n]);
+  }
+  return grouped;
+}
+
+function OnGroupByConditionChanged_ASIO() {
+  var tags = ['bah1', 'bah2', 'bah3'];
+  const v = boost_asio_handler_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 = ASIO_Data;
+  let grouped = Group_ASIO(preproc, v.GroupBy);
+  GenerateTimeLine_ASIO(grouped);
+  boost_asio_handler_timeline_view.IsCanvasDirty = true;
+}
+
+function GenerateTimeLine_ASIO(grouped) {
+  const keys = Object.keys(grouped);
+  let sortedKeys = keys.slice();
+  let intervals = [];
+  let titles = [];
+
+  const was_starting_time_undefined = (g_StartingSec == undefined);
+
+  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];
+      let t0 = parseFloat(entry[3]);
+      let t1 = parseFloat(entry[4]);
+
+      if (was_starting_time_undefined) {
+        if (g_StartingSec == undefined) {
+          g_StartingSec = t0;
+        }
+        g_StartingSec = Math.min(g_StartingSec, t0);
+      }
+
+      line.push([t0, t1, entry, 'ok', 0]);
+    }
+    intervals.push(line);
+  }
+
+  // Time shift
+  for (let i = 0; i < intervals.length; i++) {
+    for (let j = 0; j < intervals[i].length; j++) {
+      let x = intervals[i][j];
+      x[0] -= g_StartingSec;
+      x[1] -= g_StartingSec;
+    }
+  }
+
+  boost_asio_handler_timeline_view.Intervals = intervals.slice();
+  boost_asio_handler_timeline_view.Titles = titles.slice();
+  boost_asio_handler_timeline_view.LayoutForOverlappingIntervals();
+}
+
+// Main view object for Boost handler timeline
+
+boost_asio_handler_timeline_view = new BoostASIOHandlerTimelineView();
+boost_asio_handler_timeline_view.IsCanvasDirty = true;
+
+function draw_timeline_boost_asio_handler(ctx) {
+  boost_asio_handler_timeline_view.Render(ctx);
+}
+
+let Canvas_Asio = document.getElementById('my_canvas_boost_asio_handler');
+
+Canvas_Asio.onmousemove = function(event) {
+  const v = boost_asio_handler_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_Asio.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_Asio.onmousedown = function(event) {
+  if (event.button == 0) {
+    boost_asio_handler_timeline_view.OnMouseDown();
+  }
+};
+
+Canvas_Asio.onmouseup = function(event) {
+  if (event.button == 0) {
+    boost_asio_handler_timeline_view.OnMouseUp();
+  }
+};
+
+Canvas_Asio.onwheel = function(event) {
+  boost_asio_handler_timeline_view.OnMouseWheel(event);
+}
diff --git a/dbus-vis/dbus_pcap_loader.js b/dbus-vis/dbus_pcap_loader.js
new file mode 100644
index 0000000..729c316
--- /dev/null
+++ b/dbus-vis/dbus_pcap_loader.js
@@ -0,0 +1,339 @@
+// This file performs the file reading step
+// Actual preprocessing is done in dbus_timeline_vis.js
+
+function MyFloatMillisToBigIntUsec(x) {
+  x = ('' + x).split('.');
+  ret = BigInt(x[0]) * BigInt(1000);
+  return ret;
+}
+
+// When the Open File dialog is completed, the name of the opened file will be passed
+// to this routine. Then the program will do the following:
+// 1. Launch "linecount.py" to get the total packet count in the PCAP file for
+//    progress
+// 2. Launch "dbus-pcap" to get the timestamps of each DBus message
+// 3. Launch "dbus-pcap" to get the JSON representation of each DBus message
+//
+function OpenDBusPcapFile(file_name) {
+  // First try to parse using dbus-pcap
+  
+  ShowBlocker('Determining the number of packets in the pcap file ...');
+  const num_lines_py =
+      spawn('python3', ['linecount.py', file_name]);
+  let stdout_num_lines = '';
+  num_lines_py.stdout.on('data', (data) => {
+    stdout_num_lines += data;
+  });
+
+  num_lines_py.on('close', (code) => {
+    let num_packets = parseInt(stdout_num_lines.trim());
+    ShowBlocker('Running dbus-pcap (Pass 1/2, packet timestamps) ...');
+    const dbus_pcap1 =
+        //spawn('python3', ['dbus-pcap', file_name, '--json', '--progress']);
+        spawn('python3', ['dbus-pcap', file_name]);
+    let stdout1 = '';
+    let timestamps = [];
+    let count1 = 0; // In the first phase, consecutive newlines indicate a new entry
+    //const r = new RegExp('([0-9]+/[0-9]+) [0-9]+\.[0-9]+:.*');
+    const r = new RegExp('([0-9]+\.[0-9]+):.*');
+
+    let is_last_newline = false;  // Whether the last scanned character is a newline
+    let last_update_millis = 0;
+    dbus_pcap1.stdout.on('data', (data) => {
+      const s = data.toString();
+      stdout1 += s;
+      for (let i=0; i<s.length; i++) {
+        const ch = s[i];
+        let is_new_line = false;
+        if (ch == '\n' || ch == '\r') {
+          is_new_line = true;
+        }
+        if (!is_last_newline && is_new_line) {
+          count1 ++;
+        }
+        is_last_newline = is_new_line;
+      }
+      const millis = Date.now();
+      if (millis - last_update_millis > 100) { // Refresh at most 10 times per second
+        let pct = parseInt(count1 * 100 / num_packets);
+        ShowBlocker('Running dbus-pcap (Pass 1/2, packet timestamps): ' + count1 + '/' + num_packets + ' (' + pct + '%)');
+        last_update_millis = millis;
+      }
+    });
+
+    dbus_pcap1.on('close', (code) => {
+      ShowBlocker('Running dbus-pcap (Pass 2/2, packet contents) ...');
+      let stdout2 = '';
+      let count2 = 0;
+      is_last_newline = false;
+      const dbus_pcap2 =
+          spawn('python3', ['dbus-pcap', file_name, '--json']);
+      dbus_pcap2.stdout.on('data', (data) => {
+        const s = data.toString();
+        stdout2 += s;
+        for (let i=0; i<s.length; i++) {
+          const ch = s[i];
+          let is_new_line = false;
+          if (ch == '\n' || ch == '\r') {
+            is_new_line = true;
+          }
+          if (!is_last_newline && is_new_line) {
+            count2 ++;
+          }
+          is_last_newline = is_new_line;
+        }
+        const millis = Date.now();
+        if (millis - last_update_millis > 100) { // Refresh at most 10 times per second
+          let pct = parseInt(count2 * 100 / num_packets);
+          ShowBlocker('Running dbus-pcap (Pass 2/2, packet contents): ' + count2 + '/' + num_packets + ' (' + pct + '%)');
+          last_update_millis = millis;
+        }
+      });
+
+      dbus_pcap2.on('close', (code) => {
+        { ShowBlocker('Processing dbus-pcap output ... '); }
+
+        let packets = [];
+        // Parse timestamps
+        let lines = stdout1.split('\n');
+        for (let i=0; i<lines.length; i++) {
+          let l = lines[i].trim();
+          if (l.length > 0) {
+            // Timestamp
+            l = l.substr(0, l.indexOf(':'));
+            const ts_usec = parseFloat(l) * 1000.0;
+            if (!isNaN(ts_usec)) {
+              timestamps.push(ts_usec);
+            } else {
+              console.log('not a number: ' + l);
+            }
+          }
+        }
+
+        // JSON
+        lines = stdout2.split('\n');
+        for (let i=0; i<lines.length; i++) {
+          let l = lines[i].trim();
+          let parsed = undefined;
+          if (l.length > 0) {
+            try {
+              parsed = JSON.parse(l);
+            } catch (e) {
+              console.log(e);
+            }
+
+            if (parsed == undefined) {
+              try {
+                l = l.replace("NaN", "null");
+                parsed = JSON.parse(l);
+              } catch (e) {
+                console.log(e);
+              }
+            }
+
+            if (parsed != undefined) {
+              packets.push(parsed);
+            }
+          }
+        }
+
+        Timestamps_DBus = timestamps;
+
+        Data_DBus = packets.slice();
+        OnGroupByConditionChanged_DBus();
+        const v = dbus_timeline_view;
+
+        // Will return 2 packages
+        // 1) sensor PropertyChagned signal emissions
+        // 2) everything else
+        let preproc = Preprocess_DBusPcap(packets, timestamps);
+
+        let grouped = Group_DBus(preproc, v.GroupBy);
+        GenerateTimeLine_DBus(grouped);
+
+        dbus_timeline_view.IsCanvasDirty = true;
+        if (dbus_timeline_view.IsEmpty() == false ||
+            ipmi_timeline_view.IsEmpty() == false) {
+          dbus_timeline_view.CurrentFileName = file_name;
+          ipmi_timeline_view.CurrentFileName = file_name;
+          HideWelcomeScreen();
+          ShowDBusTimeline();
+          ShowIPMITimeline();
+          ShowNavigation();
+          UpdateFileNamesString();
+        }
+        HideBlocker();
+
+        g_btn_zoom_reset.click(); // Zoom to capture time range
+      });
+    });
+  })
+}
+
+// Input: data and timestamps obtained from 
+// Output: Two arrays
+//   The first is sensor PropertyChanged emissions only
+//   The second is all other DBus message types
+//
+// This function also determines the starting timestamp of the capture
+//
+function Preprocess_DBusPcap(data, timestamps) {
+  // Also clear IPMI entries
+  g_ipmi_parsed_entries = [];
+
+  let ret = [];
+
+  let in_flight = {};
+  let in_flight_ipmi = {};
+
+  for (let i = 0; i < data.length; i++) {
+    const packet = data[i];
+
+    // Fields we are interested in
+    const fixed_header = packet[0];  // is an [Array(5), Array(6)]
+
+    if (fixed_header == undefined) {  // for hacked dbus-pcap
+      console.log(packet);
+      continue;
+    }
+
+    const payload = packet[1];
+    const ty = fixed_header[0][1];
+    let timestamp = timestamps[i];
+    let timestamp_end = undefined;
+    const IDX_TIMESTAMP_END = 8;
+    const IDX_MC_OUTCOME = 9;  // Outcome of method call
+
+    let serial, path, member, iface, destination, sender, signature = '';
+    // Same as the format of the Dummy data set
+
+    switch (ty) {
+      case 4: {  // signal
+        serial = fixed_header[0][5];
+        path = fixed_header[1][0][1];
+        iface = fixed_header[1][1][1];
+        member = fixed_header[1][2][1];
+        // signature = fixed_header[1][3][1];
+        // fixed_header[1] can have variable length.
+        // For example: signal from org.freedesktop.PolicyKit1.Authority can
+        // have only 4 elements, while most others are 5
+        const idx = fixed_header[1].length - 1;
+        sender = fixed_header[1][idx][1];
+
+        // Ugly fix for:
+        if (sender == "s" || sender == "sss") {
+          sender = packet[1][0];
+          if (fixed_header[1].length == 6) {
+            // Example: fixed_header is
+            // 0: (2) [7, "org.freedesktop.DBus"]
+            // 1: (2) [6, ":1.1440274"]
+            // 2: (2) [1, "/org/freedesktop/DBus"]
+            // 3: (2) [2, "org.freedesktop.DBus"]
+            // 4: (2) [3, "NameLost"]
+            // 5: (2) [8, "s"]
+            path = fixed_header[1][2][1];
+            iface = fixed_header[1][3][1];
+            member = fixed_header[1][4][1];
+          } else if (fixed_header[1].length == 5) {
+            // Example:  fixed_header is
+            // 0: (2) [7, "org.freedesktop.DBus"]
+            // 1: (2) [1, "/org/freedesktop/DBus"]
+            // 2: (2) [2, "org.freedesktop.DBus"]
+            // 3: (2) [3, "NameOwnerChanged"]
+            // 4: (2) [8, "sss"]
+            path = fixed_header[1][1][1];
+            iface = fixed_header[1][2][1];
+            member = fixed_header[1][3][1];
+          }
+        }
+
+
+        destination = '<none>';
+        timestamp_end = timestamp;
+        let entry = [
+          'sig', timestamp, serial, sender, destination, path, iface, member,
+          timestamp_end, payload
+        ];
+
+        // Legacy IPMI interface uses signal for IPMI request
+        if (iface == 'org.openbmc.HostIpmi' && member == 'ReceivedMessage') {
+          console.log('Legacy IPMI interface, request');
+        }
+
+        ret.push(entry);
+        break;
+      }
+      case 1: {  // method call
+        serial = fixed_header[0][5];
+        path = fixed_header[1][0][1];
+        member = fixed_header[1][1][1];
+        iface = fixed_header[1][2][1];
+        destination = fixed_header[1][3][1];
+        if (fixed_header[1].length > 5) {
+          sender = fixed_header[1][5][1];
+          signature = fixed_header[1][4][1];
+        } else {
+          sender = fixed_header[1][4][1];
+        }
+        let entry = [
+          'mc', timestamp, serial, sender, destination, path, iface, member,
+          timestamp_end, payload, ''
+        ];
+
+        // Legacy IPMI interface uses method call for IPMI response
+        if (iface == 'org.openbmc.HostIpmi' && member == 'sendMessge') {
+          console.log('Legacy IPMI interface, response')
+        } else if (
+            iface == 'xyz.openbmc_project.Ipmi.Server' && member == 'execute') {
+          let ipmi_entry = {
+            netfn: payload[0],
+            lun: payload[1],
+            cmd: payload[2],
+            request: payload[3],
+            start_usec: MyFloatMillisToBigIntUsec(timestamp),
+            end_usec: 0,
+            response: []
+          };
+          in_flight_ipmi[serial] = (ipmi_entry);
+        }
+
+
+        ret.push(entry);
+        in_flight[serial] = (entry);
+        break;
+      }
+      case 2: {  // method reply
+        let reply_serial = fixed_header[1][0][1];
+        if (reply_serial in in_flight) {
+          let x = in_flight[reply_serial];
+          delete in_flight[reply_serial];
+          x[IDX_TIMESTAMP_END] = timestamp;
+          x[IDX_MC_OUTCOME] = 'ok';
+        }
+
+        if (reply_serial in in_flight_ipmi) {
+          let x = in_flight_ipmi[reply_serial];
+          delete in_flight_ipmi[reply_serial];
+          if (payload[0] != undefined && payload[0][4] != undefined) {
+            x.response = payload[0][4];
+          }
+          x.end_usec = MyFloatMillisToBigIntUsec(timestamp);
+          g_ipmi_parsed_entries.push(x);
+        }
+        break;
+      }
+      case 3: {  // error reply
+        let reply_serial = fixed_header[1][0][1];
+        if (reply_serial in in_flight) {
+          let x = in_flight[reply_serial];
+          delete in_flight[reply_serial];
+          x[IDX_TIMESTAMP_END] = timestamp;
+          x[IDX_MC_OUTCOME] = 'error';
+        }
+      }
+    }
+  }
+
+  if (g_ipmi_parsed_entries.length > 0) UpdateLayout();
+  return ret;
+}
diff --git a/dbus-vis/dbus_timeline_vis.js b/dbus-vis/dbus_timeline_vis.js
new file mode 100644
index 0000000..8d4f583
--- /dev/null
+++ b/dbus-vis/dbus_timeline_vis.js
@@ -0,0 +1,327 @@
+// This file deals with preprocessing the parsed DBus timeline data.
+// Data and Timestamps are separate b/c dbus-pcap does not include
+// timestamps in JSON output so we need to export both formats
+// (JSON and text)
+var Data_DBus = [];
+var Timestamps_DBus = [];
+
+// Main view object
+var dbus_timeline_view = new DBusTimelineView();
+var sensors_timeline_view = new DBusTimelineView(); // Same DBusTimelineView type, just that it will have only sensor propertieschanged events
+
+// group-by condition changes
+{
+  const tags = [
+    'dbus_column1', 'dbus_column2', 'dbus_column3', 'dbus_column4',
+    'dbus_column5', 'dbus_column6', 'dbus_column7'
+  ];
+  for (let i = 0; i < 7; i++) {
+    document.getElementById(tags[i]).addEventListener(
+        'click', OnGroupByConditionChanged_DBus);
+  }
+}
+
+// Called from renderer.Render()
+function draw_timeline_sensors(ctx) {
+  sensors_timeline_view.Render(ctx);
+}
+
+// Called from renderer.Render()
+function draw_timeline_dbus(ctx) {
+  dbus_timeline_view.Render(ctx);
+}
+
+let Canvas_DBus = document.getElementById('my_canvas_dbus');
+
+const IDXES = {
+  'Type': 0,
+  'Timestamp': 1,
+  'Serial': 2,
+  'Sender': 3,
+  'Destination': 4,
+  'Path': 5,
+  'Interface': 6,
+  'Member': 7
+};
+
+// This "group" is based on the content of the DBus
+// It is independent of the "group_by" of the meta-data (sender/destination/
+// path/interface/member) of a DBus message
+//
+// Input is processed message and some basic statistics needed for categorizing
+//
+const DBusMessageContentKey = function(msg, cxn_occ) {
+  let ret = undefined;
+  const type = msg[IDXES["Type"]];
+  const dest = msg[IDXES["Destination"]];
+  const path = msg[IDXES["Path"]];
+  const iface = msg[IDXES["Interface"]];
+  const member = msg[IDXES["Member"]];
+  const sender = msg[IDXES["Sender"]];
+
+  if (sender == "s" || sender == "sss") {
+    console.log(msg)
+  }
+  
+  if (type == "sig") {
+    if (path.indexOf("/xyz/openbmc_project/sensors/") != -1 &&
+        iface == "org.freedesktop.DBus.Properties" &&
+        member == "PropertiesChanged") {
+      ret = "Sensor PropertiesChanged Signals";
+    }
+  } else if (type == "mc") {
+    if (dest == "xyz.openbmc_project.Ipmi.Host" &&
+        path == "/xyz/openbmc_project/Ipmi" &&
+        iface == "xyz.openbmc_project.Ipmi.Server" &&
+        member == "execute") {
+      ret = "IPMI Daemon";
+    }
+  }
+  
+  if (ret == undefined && cxn_occ[sender] <= 10) {
+    ret = "Total 10 messages or less"
+  }
+
+  if (ret == undefined && type == "mc") {
+    if (path.indexOf("/xyz/openbmc_project/sensors/") == 0 &&
+    iface == "org.freedesktop.DBus.Properties" &&
+    (member.startsWith("Get") || member.startsWith("Set"))) {
+      ret = "Sensor Get/Set";
+    }
+  }
+
+  if (ret == undefined) {
+    ret = "Uncategorized";
+  }
+
+  return ret;
+}
+
+function Group_DBus(preprocessed, group_by) {
+  let grouped = {};  // [content key][sort key] -> packet
+
+  let cxn_occ = {}; // How many times have a specific service appeared?
+  preprocessed.forEach((pp) => {
+    const cxn = pp[IDXES["Sender"]];
+    if (cxn_occ[cxn] == undefined) {
+      cxn_occ[cxn] = 0;
+    }
+    cxn_occ[cxn]++;
+  });
+
+  for (var n = 0; n < preprocessed.length; n++) {
+    var key = '';
+    for (var i = 0; i < group_by.length; i++) {
+      if (i > 0) key += ' ';
+      key += ('' + preprocessed[n][IDXES[group_by[i]]]);
+    }
+
+    // "Content Key" is displayed on the "Column Headers"
+    const content_group = DBusMessageContentKey(preprocessed[n], cxn_occ);
+
+    // Initialize the "Collapsed" array here
+    // TODO: this should ideally not be specific to the dbus_interface_view instance
+    if (dbus_timeline_view.HeaderCollapsed[content_group] == undefined) {
+      dbus_timeline_view.HeaderCollapsed[content_group] = false;  // Not collapsed by default
+    }
+
+    if (grouped[content_group] == undefined) {
+      grouped[content_group] = [];
+    }
+    let grouped1 = grouped[content_group];
+
+    if (grouped1[key] == undefined) grouped1[key] = [];
+    grouped1[key].push(preprocessed[n]);
+  }
+  return grouped;
+}
+
+function OnGroupByConditionChanged_DBus() {
+  var tags = [
+    'dbus_column1', 'dbus_column2', 'dbus_column3', 'dbus_column4',
+    'dbus_column5', 'dbus_column6', 'dbus_column7'
+  ];
+  const v = dbus_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_DBusPcap(
+      Data_DBus, Timestamps_DBus);  // should be from dbus_pcap
+  let grouped = Group_DBus(preproc, v.GroupBy);
+  GenerateTimeLine_DBus(grouped);
+  dbus_timeline_view.IsCanvasDirty = true;
+}
+
+// Todo: put g_StartingSec somewhere that's common between sensors and non-sensors
+function GenerateTimeLine_DBus(grouped) {
+  let intervals = [];
+  let titles = [];
+  g_StartingSec = undefined;
+
+  // First, turn "content keys" into headers in the flattened layout
+  const content_keys = Object.keys(grouped);
+
+  const keys = Object.keys(grouped);
+  let sortedKeys = keys.slice();
+
+  let interval_idx = 0;  // The overall index into the intervals array
+
+  for (let x=0; x<content_keys.length; x++) {
+    const content_key = content_keys[x];
+    // Per-content key
+    const grouped1 = grouped[content_key];
+    const keys1 = Object.keys(grouped1);
+
+    let the_header = { "header":true, "title":content_key, "intervals_idxes":[] };
+    titles.push(the_header);
+    // TODO: this currently depends on the dbus_timeline_view instance
+    const collapsed = dbus_timeline_view.HeaderCollapsed[content_key];
+
+    for (let i = 0; i < keys1.length; i++) {
+      // The Title array controls which lines are drawn. If we con't push the header
+      // it will not be drawn (thus giving a "collapsed" visual effect.)
+      if (!collapsed) {
+        titles.push({ "header":false, "title":keys1[i], "intervals_idxes":[interval_idx] });
+      }
+
+
+      line = [];
+      for (let j = 0; j < grouped1[keys1[i]].length; j++) {
+        let entry = grouped1[keys1[i]][j];
+        let t0 = parseFloat(entry[1]) / 1000.0;
+        let t1 = parseFloat(entry[8]) / 1000.0;
+
+        // Modify time shift delta if IPMI dataset is loaded first
+        if (g_StartingSec == undefined) {
+          g_StartingSec = t0;
+        }
+        g_StartingSec = Math.min(g_StartingSec, t0);
+        const outcome = entry[9];
+        line.push([t0, t1, entry, outcome, 0]);
+      }
+
+      the_header.intervals_idxes.push(interval_idx);  // Keep the indices into the intervals array for use in rendering
+      intervals.push(line);
+      interval_idx ++;
+    }
+
+    // Compute a set of "merged intervals" for each content_key
+    let rise_fall_edges = [];
+    the_header.intervals_idxes.forEach((i) => {
+      intervals[i].forEach((t0t1) => {
+        if (t0t1[0] <= t0t1[1]) {  // For errored-out method calls, the end time will be set to a value smaller than the start tiem
+          rise_fall_edges.push([t0t1[0], 0]);  // 0 is a rising edge
+          rise_fall_edges.push([t0t1[1], 1]);  // 1 is a falling edge
+        }
+      })
+    });
+
+    let merged_intervals = [], 
+        current_interval = [undefined, undefined, 0];  // start, end, weight
+    rise_fall_edges.sort();
+    let i = 0, level = 0;
+    while (i<rise_fall_edges.length) {
+      let timestamp = rise_fall_edges[i][0];
+      while (i < rise_fall_edges.length && timestamp == rise_fall_edges[i][0]) {
+        switch (rise_fall_edges[i][1]) {
+          case 0: {  // rising edge
+            if (level == 0) {
+              current_interval[0] = timestamp;
+              current_interval[2] ++;
+            }
+            level ++;
+            break;
+          }
+          case 1: {  // falling edge
+            level --;
+            if (level == 0) {
+              current_interval[1] = timestamp;
+              merged_intervals.push(current_interval);
+              current_interval = [undefined, undefined, 0];
+            }
+            break;
+          }
+        }
+        i++;
+      }
+    }
+    the_header.merged_intervals = merged_intervals;
+  }
+
+  // Time shift
+  for (let i = 0; i < intervals.length; i++) {
+    for (let j = 0; j < intervals[i].length; j++) {
+      let x = intervals[i][j];
+      x[0] -= g_StartingSec;
+      x[1] -= g_StartingSec;
+    }
+  }
+  // merged intervals should be time-shifted as well
+  titles.forEach((t) => {
+    if (t.header == true) {
+      t.merged_intervals.forEach((mi) => {
+        mi[0] -= g_StartingSec;
+        mi[1] -= g_StartingSec;
+      })
+    }
+  })
+
+  dbus_timeline_view.Intervals = intervals.slice();
+  dbus_timeline_view.Titles    = titles.slice();
+  dbus_timeline_view.LayoutForOverlappingIntervals();
+}
+
+Canvas_DBus.onmousemove =
+    function(event) {
+  const v = dbus_timeline_view;
+  v.MouseState.x = event.pageX - this.offsetLeft;
+  v.MouseState.y = event.pageY - this.offsetTop;
+  if (v.MouseState.pressed == true &&
+    v.MouseState.hoveredSide == 'timeline') {  // 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_DBus.offsetLeft;
+    u.MouseState.y = undefined;                  // Do not highlight any entry or the horizontal scroll bars
+    if (u.MouseState.pressed == true &&
+      v.MouseState.hoveredSide == 'timeline') {  // Update highlighted area
+      u.HighlightedRegion.t1 = u.MouseXToTimestamp(u.MouseState.x);
+    }
+    u.OnMouseMove();
+    u.IsCanvasDirty = true;
+  });
+}
+
+Canvas_DBus.onmousedown = function(event) {
+  if (event.button == 0) {
+    dbus_timeline_view.OnMouseDown();
+  }
+};
+
+Canvas_DBus.onmouseup =
+    function(event) {
+  if (event.button == 0) {
+    dbus_timeline_view.OnMouseUp();
+  }
+}
+
+Canvas_DBus.onmouseleave = 
+    function(event) {
+  dbus_timeline_view.OnMouseLeave();
+}
+
+Canvas_DBus.onwheel = function(event) {
+  dbus_timeline_view.OnMouseWheel(event);
+}
diff --git a/dbus-vis/dbus_vis.css b/dbus-vis/dbus_vis.css
new file mode 100644
index 0000000..b230c0a
--- /dev/null
+++ b/dbus-vis/dbus_vis.css
@@ -0,0 +1,113 @@
+body {
+	font-family: "monospace";
+	font-size: 12px;
+}
+
+#div_title, #div_group_by, #div_canvas, #div_navi_and_replay, #ipmi_replay {
+  text-align: center
+}
+
+#my_canvas, #capture_info {
+  border: 1px grey dashed;
+}
+
+#capture_info {
+  width: 1200px;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+#ipmi_replay_output {
+  margin: auto
+}
+
+#title_capture, #title_open_file, #title_mode_select, #title_capture_info {
+	margin: auto;
+	text-align: center
+}
+
+#ipmi_replay {
+	display: none
+}
+
+#div_canvas {
+	margin-top: 2px
+}
+
+#my_canvas_sensors, #span_group_by_sensors {
+	border: 1px #888 solid
+}
+
+#my_canvas_dbus, #span_group_by_dbus {
+	border: 1px #008000 solid
+}
+
+#my_canvas_ipmi, #span_group_by_ipmi {
+	border: 1px #00c0c0 solid
+}
+
+#my_canvas_boost_asio_handler, #span_group_by_boost_asio_handler {
+	border: 1px #c03030 solid
+}
+
+#my_canvas_dbus, #my_canvas_ipmi, #my_canvas_boost_asio_handler, #my_canvas_sensors {
+  margin-left: auto;
+  margin-right: auto;
+}
+
+#blocker {
+	position: absolute;
+	top: 0; bottom: 0; display: block;
+	width: 100%; height: 100%;
+	background-color: rgba(128, 128, 128, 0.6);
+	display: none;
+}
+
+#blocker_caption {
+	position: absolute;
+	top: 44%;
+	width: 100%;
+	color: #00F;
+	background-color: #CCF;
+}
+
+#welcome_screen {
+	border: 1px dashed black;
+	height: 300px;
+	text-align: left;
+}
+
+#welcome_screen_content {
+	margin-left: auto;
+	margin-right: auto;
+	width: 500px;
+}
+
+#dbus_pcap_status_content {
+	margin-left: auto;
+	margin-right: auto;
+	width: 500px;
+	color: #999;
+	display: none;
+}
+
+#dbus_pcap_error_content {
+	display: none;
+	margin-left: auto;
+	margin-right: auto;
+	width: 500px;
+	background-color: #ffc;
+}
+
+#scapy_error_content {
+	display: none;
+	margin-left: auto;
+	margin-right: auto;
+	width: 500px;
+	background-color: #ffc;
+}
+
+#span_group_by_dbus, #span_group_by_ipmi, #span_group_by_boost_asio_handler,
+#div_navi_and_replay{
+  display: none;
+}
\ No newline at end of file
diff --git a/dbus-vis/index.html b/dbus-vis/index.html
new file mode 100644
index 0000000..d387ea5
--- /dev/null
+++ b/dbus-vis/index.html
@@ -0,0 +1,157 @@
+<!DOCTYPE html>
+<!-- Caution: Electron does not allow inline scripts or styles! -->
+<html>
+  <head>
+    <meta charset="UTF-8">
+    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
+    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
+    <meta http-equiv="X-Content-Security-Policy" content="default-src 'self'; script-src 'self'">
+    <title>DBus &amp; IPMI Request Visualizer</title>
+  </head>
+  <body>
+    <div id="div_title"><span>
+    <b>DBus &amp; IPMI Request Visualizer</b>
+		</span>
+		</div>
+    <div id="title_mode_select">
+			<span>
+				<input type="radio" id="radio_open_file" name="mode" value="open_file"></input>
+  			<label for="radio_open_file">Open an existing file</label>
+  			<input type="radio" id="radio_capture" name="mode" value="capture"></input>
+  			<label for="radio_capture">Capture on a BMC</label>
+      </span>
+		</div>
+		<div id="title_open_file">
+			<span><b>File name: </b></span>
+			<span id="file_name"></span>
+			<button id="btn_open_file">Open file</button>
+			<br />
+		</div>
+		<div id="title_capture">
+			<span><b>Capture: </b></span>
+			<input type="text" id="text_hostname" value=""></input>
+			<button id="btn_start_capture">Start Capture</button>
+			<button id="btn_stop_capture">Stop Capture</button>
+			<select id="select_capture_mode">
+				<option value="live">Live</option>
+				<option value="staged">Staged (IPMI detailed)</option>
+				<option value="staged2">Staged (DBus + IPMI)</option>
+			</select>
+    </div>    
+		<div id="title_capture_info">
+			<div id="capture_info">Info will appear here</div>
+		</div>
+
+    <hr/> 
+
+		<div id="div_group_by">
+    <span id="span_group_by_dbus">
+				Group DBus Requests by:
+				<input type="checkbox" id="dbus_column1" value="Type">Type</input>
+				<input type="checkbox" id="dbus_column2" value="Serial">Serial</input>
+				<input type="checkbox" id="dbus_column3" value="Sender">Sender</input>
+				<input type="checkbox" id="dbus_column4" value="Destination">Destination</input>
+				<input type="checkbox" id="dbus_column5" value="Path"checked>Path</input>
+				<input type="checkbox" id="dbus_column6" value="Interface" checked>Interface</input>
+				<input type="checkbox" id="dbus_column7" value="Member">Member</input>
+    </span>
+    <span id="span_group_by_ipmi">
+			  Group IPMI Requests by:
+				<input type="checkbox" id="c1" value="NetFN" checked>NetFN</input>
+				<input type="checkbox" id="c2" value="CMD"     checked>CMD</input>
+    </span>
+    <div id="span_group_by_boost_asio_handler">
+			  Group Asio Handlers by:
+				<input type="checkbox" id="bah1" value="Layout Level" checked>Layout Level</input>
+				<input type="checkbox" id="bah2" value="Description">Description</input>
+				<input type="checkbox" id="bah3" value="Description1">Description w/ addresses removed</input>
+    </div>
+		</div>
+      
+		<div id="div_canvas">
+				<div id="blocker">
+						<div id="blocker_caption">AAAAA</div>
+				</div>
+				<div id="welcome_screen">
+						<div id="welcome_screen_content">
+								<br/><span>Welcome! Please <button id="btn_open_file2">Open file</button> to get timeline view/s.<span>
+								<br/>
+								<br/>Supported file types:
+								<ol>
+										<li>DBus pcap file</li>
+										<li>Boost ASIO handler log file</li>
+								</ol>
+								One file from each type (2 files in total) can be viewed simultaneously.
+						</div>
+						<hr />
+						<div id="dbus_pcap_status_content">
+							dbus-pcap status goes here
+						</div>
+						<div id="dbus_pcap_error_content">
+							The <b>dbus-pcap</b> script is not found; dbus-vis needs <b>dbus-pcap</b> for parsing PCAP files.<br/><br/>
+							Click to down <b>dbus-pcap</b> from: <br/>
+								https://raw.githubusercontent.com/openbmc/openbmc-tools/08ce0a5bad2b5c970af567c2e9888d444afe3946/dbus-pcap/dbus-pcap<br/><br/>
+							<button id="btn_download_dbus_pcap">Download to dbus-vis folder</button>
+						</div>
+						<div id="scapy_error_content">
+							The <b>scapy</b> Python module is not installed. dbus-vis depends on dbus-pcap, which in turn depends on the scapy Python module.
+							
+							Please install it using either of the following commands:<br/>
+							<br/>
+							python3 -m pip install scapy<br/>
+							sudo apt install python3-scapy<br/>
+							<br/>
+							After installation, refresh dbus-vis with Ctrl+R.
+						</div>
+				</div>
+				<canvas id="my_canvas_dbus" width="1400" height="600"></canvas>
+				<canvas id="my_canvas_ipmi" width="1400" height="200"></canvas>
+				<canvas id="my_canvas_boost_asio_handler" width="1400" height="200"></canvas>
+		</div>
+
+		<div id="div_navi_and_replay">
+			<div>
+			  <span>
+				Navigation Control:
+				<button id="btn_zoom_in">Zoom In</button>
+				<button id="btn_zoom_out">Zoom Out</button>
+				<button id="btn_pan_left">&lt;&lt;</button>
+				<button id="btn_pan_right">&gt;&gt;</button>
+				<button id="btn_zoom_reset">Reset</button>
+				</span>
+			</div>
+			<div>Keyboard: [Left]/[right] arrow keys to pan; [Up]/[down] arrow keys to zoom in/out; Hold [shift] to move faster<br/>Mouse: [Left click]: highlight an interval; [wheel up/down]: zoom in/out; click overflow triangles to warp to out-of-viewport requests
+			</div>
+			<div id="highlight_hint">Click highlighted region to zoom into the region</div>
+			<div>
+				<input type="checkbox" id="cb_debuginfo">Show Debug Info</input>
+			</div>
+			<br/>
+			<div id="ipmi_replay">
+				<span>Generate replay commands for the </span><span id="highlight_count">0</span><span> highlighted IPMI requests:</span>
+				<br/>
+				<span>For replaying through "ipmitool" on host or BMC:
+				<button id="gen_replay_ipmitool1">Individual calls</button>
+				<button id="gen_replay_ipmitool2">exec command list</button></span>
+				<br/>
+				<span>For replaying through "busctl" on BMC:
+				<button id="gen_replay_ipmid_legacy">Legacy Interface (for btbridged)</button>
+				<button id="gen_replay_ipmid_new">New Interface (for kcsbridged / netipmid)</button></span>
+				<textarea rows="10" cols="150" id="ipmi_replay_output"></textarea>
+			</div>
+		</div> <!-- navi and replay -->
+    <br/>
+
+    <!-- You can also require other files to run in this process -->
+    <script src="./timeline_view.js"></script>
+    <script src="./dbus_timeline_vis.js"></script>
+    <script src="./ipmi_timeline_vis.js"></script>
+		<script src="./boost_handler_timeline_vis.js"></script>
+    <script src="./ipmi_parse.js"></script>
+    <script src="./ipmi_capture.js"></script>
+    <script src="./renderer.js"></script>
+		<script src="./dbus_pcap_loader.js"></script>
+		<script src="./initialization.js"></script>
+		<link rel="stylesheet" href="./dbus_vis.css">
+  </body>
+</html>
diff --git a/dbus-vis/initialization.js b/dbus-vis/initialization.js
new file mode 100644
index 0000000..fb150e4
--- /dev/null
+++ b/dbus-vis/initialization.js
@@ -0,0 +1,224 @@
+const { spawnSync } = require('child_process');
+const md5File = require('md5-file');
+const https = require('https');
+
+function OpenFileHandler() {
+  console.log('Will open a dialog box ...');
+  const options = {
+    title: 'Open a file or folder',
+  };
+  let x = dialog.showOpenDialogSync(options) + '';  // Convert to string
+  console.log('file name: ' + x)
+
+  // Determine file type
+  let is_asio_log = false;
+  const data = fs.readFileSync(x, {encoding: 'utf-8'});
+  let lines = data.split('\n');
+  for (let i = 0; i < lines.length; i++) {
+    if (lines[i].indexOf('@asio') == 0) {
+      is_asio_log = true;
+      break;
+    }
+  }
+
+  if (is_asio_log) {
+    ShowBlocker('Loading Boost ASIO handler tracking log');
+    console.log('This file is a Boost Asio handler tracking log');
+    ParseBoostHandlerTimeline(data);
+    OnGroupByConditionChanged_ASIO();
+    if (boost_asio_handler_timeline_view.IsEmpty() == false) {
+      boost_asio_handler_timeline_view.CurrentFileName = x;
+      HideWelcomeScreen();
+      ShowASIOTimeline();
+      ShowNavigation();
+      UpdateFileNamesString();
+    }
+    HideBlocker();
+    return;
+  }
+
+  OpenDBusPcapFile(x);
+
+  UpdateLayout();
+}
+
+// The file may be either DBus dump or Boost Asio handler log
+document.getElementById('btn_open_file')
+    .addEventListener('click', OpenFileHandler);
+document.getElementById('btn_open_file2')
+    .addEventListener('click', OpenFileHandler);
+
+document.getElementById('bah1').addEventListener(
+    'click', OnGroupByConditionChanged_ASIO);
+document.getElementById('bah2').addEventListener(
+    'click', OnGroupByConditionChanged_ASIO);
+document.getElementById('bah3').addEventListener(
+    'click', OnGroupByConditionChanged_ASIO);
+
+// UI elements
+var g_group_by_dbus = document.getElementById('span_group_by_dbus');
+var g_group_by_ipmi = document.getElementById('span_group_by_ipmi');
+var g_group_by_asio =
+    document.getElementById('span_group_by_boost_asio_handler')
+var g_canvas_ipmi = document.getElementById('my_canvas_ipmi');
+var g_canvas_dbus = document.getElementById('my_canvas_dbus');
+var g_canvas_asio = document.getElementById('my_canvas_boost_asio_handler');
+
+var g_dbus_pcap_status_content = document.getElementById('dbus_pcap_status_content');
+var g_dbus_pcap_error_content = document.getElementById('dbus_pcap_error_content');
+var g_btn_download_dbus_pcap = document.getElementById('btn_download_dbus_pcap');
+var g_welcome_screen_content = document.getElementById('welcome_screen_content');
+var g_scapy_error_content = document.getElementById('scapy_error_content');
+
+var g_btn_zoom_reset = document.getElementById('btn_zoom_reset');
+
+function DownloadDbusPcap() {
+  const url = 'https://raw.githubusercontent.com/openbmc/openbmc-tools/08ce0a5bad2b5c970af567c2e9888d444afe3946/dbus-pcap/dbus-pcap';
+
+  https.get(url, (res) => {
+    const path = 'dbus-pcap';
+    const file_path = fs.createWriteStream(path);
+    res.pipe(file_path);
+    file_path.on('finish', () => {
+      file_path.close();
+      alert("dbus-pcap download complete!");
+      CheckDbusPcapPresence();
+    });
+  });
+}
+
+g_btn_download_dbus_pcap.addEventListener(
+  'click', DownloadDbusPcap);
+
+function ShowDBusTimeline() {
+  g_canvas_dbus.style.display = 'block';
+  g_group_by_dbus.style.display = 'block';
+}
+function ShowIPMITimeline() {
+  g_canvas_ipmi.style.display = 'block';
+  g_group_by_ipmi.style.display = 'block';
+}
+function ShowASIOTimeline() {
+  g_canvas_asio.style.display = 'block';
+  g_group_by_asio.style.display = 'block';
+}
+
+// Make sure the user has scapy.all installed
+function IsPythonInstallationOK() {
+  const x = spawnSync('python3', ['-m', 'scapy.all']);
+  return (x.status == 0);
+}
+
+function IsDbusPcapPresent() {
+  // This should exist if the openbmc-tools repository
+  // is checked out as a whole
+  const dbus_pcap = '../dbus-pcap/dbus-pcap';
+
+  if (fs.existsSync('dbus-pcap')) {
+    return true;
+  } else if (fs.existsSync(dbus_pcap)) { // Create symlink
+    fs.symlinkSync(dbus_pcap, './dbus-pcap');
+    return true;
+  } else {
+    return false;
+  }
+}
+
+function CheckDependencies() {
+  g_dbus_pcap_status_content.style.display = 'none';
+  g_dbus_pcap_error_content.style.display = 'none';
+  g_scapy_error_content.style.display = 'none';
+  g_welcome_screen_content.style.display = 'none';
+
+  const dbus_pcap_ok = IsDbusPcapPresent();
+  if (!dbus_pcap_ok) {
+    g_dbus_pcap_error_content.style.display = 'block';
+  }
+
+  const scapy_ok = IsPythonInstallationOK();
+  if (!scapy_ok) {
+    g_scapy_error_content.style.display = 'block';
+  }
+
+  let msg = "";
+  if (dbus_pcap_ok) {
+    msg += 'dbus-pcap found, md5sum: ' +
+      md5File.sync('dbus-pcap');
+    g_dbus_pcap_status_content.style.display = 'block';
+    g_dbus_pcap_status_content.textContent = msg;
+  }
+
+  if (dbus_pcap_ok && scapy_ok) {
+    g_welcome_screen_content.style.display = 'block';
+  }
+}
+
+function Init() {
+  console.log('[Init] Initialization');
+  ipmi_timeline_view.Canvas = document.getElementById('my_canvas_ipmi');
+  dbus_timeline_view.Canvas = document.getElementById('my_canvas_dbus');
+  boost_asio_handler_timeline_view.Canvas =
+      document.getElementById('my_canvas_boost_asio_handler');
+
+  // Hide all canvases until the user loads files
+  ipmi_timeline_view.Canvas.style.display = 'none';
+  dbus_timeline_view.Canvas.style.display = 'none';
+  boost_asio_handler_timeline_view.Canvas.style.display = 'none';
+
+  let v1 = ipmi_timeline_view;
+  let v2 = dbus_timeline_view;
+  let v3 = boost_asio_handler_timeline_view;
+
+  // Link views
+  v1.linked_views = [v2, v3];
+  v2.linked_views = [v1, v3];
+  v3.linked_views = [v1, v2];
+
+  // Set accent color
+  v1.AccentColor = 'rgba(0,224,224,0.5)';
+  v2.AccentColor = 'rgba(0,128,0,  0.5)';
+  v3.AccentColor = '#E22';
+
+  CheckDependencies();
+}
+
+var g_WelcomeScreen = document.getElementById('welcome_screen');
+function HideWelcomeScreen() {
+  g_WelcomeScreen.style.display = 'none';
+}
+
+var g_Blocker = document.getElementById('blocker');
+var g_BlockerCaption = document.getElementById('blocker_caption');
+function HideBlocker() {
+  g_Blocker.style.display = 'none';
+}
+function ShowBlocker(msg) {
+  g_Blocker.style.display = 'block';
+  g_BlockerCaption.textContent = msg;
+}
+
+var g_Navigation = document.getElementById('div_navi_and_replay');
+function ShowNavigation() {
+  g_Navigation.style.display = 'block';
+}
+
+function UpdateFileNamesString() {
+  let tmp = [];
+  if (ipmi_timeline_view.CurrentFileName != '') {
+    tmp.push('IPMI timeline: ' + ipmi_timeline_view.CurrentFileName)
+  }
+  if (dbus_timeline_view.CurrentFileName != '') {
+    tmp.push('DBus timeline: ' + dbus_timeline_view.CurrentFileName)
+  }
+  if (boost_asio_handler_timeline_view.CurrentFileName != '') {
+    tmp.push(
+        'ASIO timeline: ' + boost_asio_handler_timeline_view.CurrentFileName);
+  }
+  let s = tmp.join('<br/>');
+  document.getElementById('capture_info').innerHTML = s;
+}
+
+var g_cb_debug_info = document.getElementById('cb_debuginfo');
+
+
+Init();
diff --git a/dbus-vis/ipmi_capture.js b/dbus-vis/ipmi_capture.js
new file mode 100644
index 0000000..eb1ac9e
--- /dev/null
+++ b/dbus-vis/ipmi_capture.js
@@ -0,0 +1,507 @@
+const { spawn } = require('child_process');
+const targz = require('targz');
+
+const DBUS_MONITOR_LEGACY =
+    'dbus-monitor --system | grep "sendMessage\\|ReceivedMessage" -A7 \n';
+const DBUS_MONITOR_NEW =
+    'dbus-monitor --system | grep "member=execute\\|method return" -A7 \n';
+
+// Capture state for all scripts
+var g_capture_state = 'not started';
+var g_capture_mode = 'live';
+
+// For capturing IPMI requests live
+var g_dbus_monitor_cmd = '';
+
+// For tracking transfer
+var g_hexdump = '';
+var g_hexdump_received_size = 0;
+var g_hexdump_total_size = 0;
+
+function currTimestamp() {
+  var tmp = new Date();
+  return (tmp.getTime() + tmp.getTimezoneOffset() * 60000) / 1000;
+}
+
+var g_child;
+var g_rz;
+
+var g_capture_live = true;
+var g_dbus_capture_tarfile_size = 0;
+
+function ParseHexDump(hd) {
+  let ret = [];
+  let lines = hd.split('\n');
+  let tot_size = 0;
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i].trimEnd();
+    const sp = line.split(' ');
+    if (line.length < 1) continue;
+    if (sp.length < 1) continue;
+
+    for (let j = 1; j < sp.length; j++) {
+      let b0 = sp[j].slice(2);
+      let b1 = sp[j].slice(0, 2);
+      b0 = parseInt(b0, 16);
+      b1 = parseInt(b1, 16);
+      ret.push(b0);
+      ret.push(b1);
+    }
+
+    console.log('[' + line + ']')
+
+    {
+      tot_size = parseInt(sp[0], 16);
+      console.log('File size: ' + tot_size + ' ' + sp[0]);
+    }
+  }
+  ret = ret.slice(0, tot_size);
+  return new Buffer(ret);
+}
+
+function SaveHexdumpToFile(hd, file_name) {
+  const buf = ParseHexDump(hd);
+  fs.writeFileSync(file_name, buf)
+}
+
+// Delimiters: ">>>>>>" and "<<<<<<"
+function ExtractMyDelimitedStuff(x, parse_as = undefined) {
+  let i0 = x.lastIndexOf('>>>>>>'), i1 = x.lastIndexOf('<<<<<<');
+  if (i0 != -1 && i1 != -1) {
+    let ret = x.substr(i0 + 6, i1 - i0 - 6);
+    if (parse_as == undefined)
+      return ret;
+    else if (parse_as == 'int')
+      return parseInt(ret);
+  } else
+    return null;
+}
+
+function streamWrite(stream, chunk, encoding = 'utf8') {
+  return new Promise((resolve, reject) => {
+    const errListener = (err) => {
+      stream.removeListener('error', errListener);
+      reject(err);
+    };
+    stream.addListener('error', errListener);
+    const callback = () => {
+      stream.removeListener('error', errListener);
+      resolve(undefined);
+    };
+    stream.write(chunk, encoding, callback);
+  });
+}
+
+function ExtractTarFile() {
+  const tar_file = 'DBUS_MONITOR.tar.gz';
+  const target = '.';
+  targz.decompress({src: tar_file, dest: target}, function(err) {
+    if (err) {
+      console.log('Error decompressing .tar.gz file:' + err);
+    }
+    // Attempt to load even if error occurs
+    // example error: "Error decompressing .tar.gz file:Error: incorrect data check"
+    console.log('Done! will load file contents');
+    if (g_capture_mode == 'staged') {
+      fs.readFile('./DBUS_MONITOR', {encoding: 'utf-8'}, (err, data) => {
+        if (err) {
+          console.log('Error in readFile: ' + err);
+        } else {
+          ParseIPMIDump(data);
+        }
+      });
+    } else if (g_capture_mode == 'staged2') {
+      OpenDBusPcapFile('./DBUS_MONITOR');
+    }
+  });
+}
+
+function OnCaptureStart() {
+  switch (g_capture_state) {
+    case 'not started':
+      capture_info.textContent = 'dbus-monitor running on BMC';
+      break;
+    default:
+      break;
+  }
+}
+
+function OnCaptureStop() {
+  btn_start_capture.disabled = false;
+  select_capture_mode.disabled = false;
+  text_hostname.disabled = false;
+  g_capture_state = 'not started';
+}
+
+async function OnTransferCompleted() {
+  setTimeout(function() {
+    console.log('OnTransferCompleted');
+    g_child.kill('SIGINT');
+  }, 5000);
+
+  capture_info.textContent = 'Loaded the capture file';
+  OnCaptureStop();
+  ExtractTarFile();
+}
+
+// Example output from stderr:
+// ^M Bytes received:    2549/   2549   BPS:6370
+async function LaunchRZ() {
+  // On the Host
+
+  // Remove existing file
+  const file_names = ['DBUS_MONITOR', 'DBUS_MONITOR.tar.gz'];
+  try {
+    for (let i = 0; i < 2; i++) {
+      const fn = file_names[i];
+      if (fs.existsSync(fn)) {
+        fs.unlinkSync(fn);  // unlink is basically rm
+        console.log('Removed file: ' + fn);
+      }
+    }
+  } catch (err) {
+  }
+
+  g_rz = spawn(
+      'screen', ['rz', '-a', '-e', '-E', '-r', '-w', '32767'], {shell: false});
+  g_rz.stdout.on('data', (data) => {
+    console.log('[rz] received ' + data.length + ' B');
+    console.log(data);
+    console.log(data + '');
+    // data = MyCorrection(data);
+    if (data != undefined) g_child.stdin.write(data);
+  });
+  g_rz.stderr.on('data', (data) => {
+    console.log('[rz] error: ' + data);
+    let s = data.toString();
+    let idx = s.lastIndexOf('Bytes received:');
+    if (idx != -1) {
+      capture_info.textContent = s.substr(idx);
+    }
+    if (data.indexOf('Transfer complete') != -1) {
+      OnTransferCompleted();
+    } else if (data.indexOf('Transfer incomplete') != -1) {
+      // todo: retry transfer
+      // Bug info
+      // Uncaught Error [ERR_STREAM_WRITE_AFTER_END]: write after end
+      // at writeAfterEnd (_stream_writable.js:253)
+      // at Socket.Writable.write (_stream_writable.js:302)
+      // at Socket.<anonymous> (ipmi_capture.js:317)
+      // at Socket.emit (events.js:210)
+      // at addChunk (_stream_readable.js:308)
+      // at readableAddChunk (_stream_readable.js:289)
+      // at Socket.Readable.push (_stream_readable.js:223)
+      // at Pipe.onStreamRead (internal/stream_base_commons.js:182)
+      capture_info.textContent = 'Transfer incomplete';
+    }
+  });
+  await Promise.all(
+      [g_rz.stdin.pipe(g_child.stdout), g_rz.stdout.pipe(g_child.stdin)]);
+}
+
+function ClearAllPendingTimeouts() {
+  var id = setTimeout(function() {}, 0);
+  for (; id >= 0; id--) clearTimeout(id);
+}
+
+function StartDbusMonitorFileSizePollLoop() {
+  QueueDbusMonitorFileSize(5);
+}
+
+function QueueDbusMonitorFileSize(secs = 5) {
+  setTimeout(function() {
+    g_child.stdin.write(
+        'a=`ls -l /run/initramfs/DBUS_MONITOR | awk \'{print $5}\'` ; echo ">>>>>>$a<<<<<<"  \n\n\n\n');
+    QueueDbusMonitorFileSize(secs);
+  }, secs * 1000);
+}
+
+function StopCapture() {
+  switch (g_capture_mode) {
+    case 'live':
+      g_child.stdin.write('\x03 ');
+      g_capture_state = 'stopping';
+      capture_info.textContent = 'Ctrl+C sent to BMC console';
+      break;
+    case 'staged':
+      ClearAllPendingTimeouts();
+      g_child.stdin.write(
+          'echo ">>>>>>" && killall busctl && echo "<<<<<<" \n\n\n\n');
+      g_capture_state = 'stopping';
+      capture_info.textContent = 'Stopping dbus-monitor';
+    case 'staged2':
+      g_hexdump_received_size = 0;
+      g_hexdump_total_size = 0;
+      ClearAllPendingTimeouts();
+      g_child.stdin.write(
+          'echo ">>>>>>" && killall busctl && echo "<<<<<<" \n\n\n\n');
+      g_capture_state = 'stopping';
+      capture_info.textContent = 'Stopping busctl';
+      break;
+  }
+}
+
+function QueueBMCConsoleHello(secs = 3) {
+  setTimeout(function() {
+    try {
+      if (g_capture_state == 'not started') {
+        console.log('Sending hello <cr> to the BMC');
+        g_child.stdin.write('\n');
+        QueueBMCConsoleHello(secs);
+      }
+    } catch (err) {
+      console.log('g_child may have ended as intended');
+    }
+  }, secs * 1000);
+}
+
+// The command line needed to access the BMC. The expectation is
+// executing this command brings the user to the BMC's console.
+function GetCMDLine() {
+  let v = text_hostname.value.split(' ');
+  return [v[0], v.slice(1, v.length)];
+}
+
+async function StartCapture(host) {
+  // Disable buttons
+  HideWelcomeScreen();
+  ShowIPMITimeline();
+  ShowNavigation();
+  let args = GetCMDLine();
+  btn_start_capture.disabled = true;
+  select_capture_mode.disabled = true;
+  text_hostname.disabled = true;
+  capture_info.textContent = 'Contacting BMC console: ' + args.toString();
+
+  // On the B.M.C.
+  let last_t = currTimestamp();
+  let attempt = 0;
+  console.log('Args: ' + args);
+  g_child = spawn(args[0], args[1], {shell: true});
+  g_child.stdout.on('data', async function(data) {
+    QueueBMCConsoleHello();
+
+    var t = currTimestamp();
+    {
+      switch (g_capture_state) {
+        case 'not started':  // Do nothing
+          break;
+        case 'started':
+          attempt++;
+          console.log('attempt ' + attempt);
+          g_child.stdin.write('echo "haha" \n');
+          await streamWrite(g_child.stdin, 'whoami \n');
+          let idx = data.indexOf('haha');
+          if (idx != -1) {
+            ClearAllPendingTimeouts();
+            OnCaptureStart();  // Successfully logged on, start
+
+            if (g_capture_mode == 'live') {
+              g_child.stdin.write(
+                  '\n\n' +
+                  'a=`pidof btbridged`;b=`pidof kcsbridged`;c=`pidof netipmid`;' +
+                  'echo ">>>>>>$a,$b,$c<<<<<<"\n\n');
+              g_capture_state = 'determine bridge daemon';
+            } else {
+              g_capture_state = 'dbus monitor start';
+            }
+            capture_info.textContent = 'Reached BMC console';
+
+          } else {
+            console.log('idx=' + idx);
+          }
+          break;
+        case 'determine bridge daemon': {
+          const abc = ExtractMyDelimitedStuff(data.toString());
+          if (abc == null) break;
+          const sp = abc.split(',');
+          if (parseInt(sp[0]) >= 0) {  // btbridged, legacy interface
+            g_dbus_monitor_cmd = DBUS_MONITOR_LEGACY;
+            console.log('The BMC is using btbridged.');
+          } else if (parseInt(sp[1]) >= 0) {  // new iface
+            g_dbus_monitor_cmd = DBUS_MONITOR_NEW;
+            console.log('The BMC is using kcsbridged.');
+          } else if (parseInt(sp[2]) >= 0) {
+            g_dbus_monitor_cmd = DBUS_MONITOR_NEW;
+            console.log('The BMC is using netipmid.');
+          } else {
+            console.log('Cannot determine the IPMI bridge daemon\n')
+            return;
+          }
+          g_capture_state = 'dbus monitor start';
+          break;
+        }
+        case 'dbus monitor start':
+          if (g_capture_mode == 'live') {
+            // It would be good to make sure the console bit rate is greater
+            // than the speed at which outputs are generated.
+            //            g_child.stdin.write("dbus-monitor --system | grep
+            //            \"sendMessage\\|ReceivedMessage\" -A7 \n")
+            ClearAllPendingTimeouts();
+            g_child.stdin.write(
+                'dbus-monitor --system | grep "member=execute\\|method return" -A7 \n');
+            capture_info.textContent = 'Started dbus-monitor for live capture';
+          } else {
+            //            g_child.stdin.write("dbus-monitor --system | grep
+            //            \"sendMessage\\|ReceivedMessage\" -A7 >
+            //            /run/initramfs/DBUS_MONITOR & \n\n\n")
+            ClearAllPendingTimeouts();
+            if (g_capture_mode == 'staged') {
+              g_child.stdin.write(
+                  'dbus-monitor --system > /run/initramfs/DBUS_MONITOR & \n\n\n');
+              capture_info.textContent =
+                  'Started dbus-monitor for staged IPMI capture';
+            } else if (g_capture_mode == 'staged2') {
+              g_child.stdin.write(
+                  'busctl capture > /run/initramfs/DBUS_MONITOR & \n\n\n');
+              capture_info.textContent =
+                  'Started busctl for staged IPMI + DBus capture';
+            }
+            StartDbusMonitorFileSizePollLoop();
+          }
+          g_capture_state = 'dbus monitor running';
+          break;
+        case 'dbus monitor running':
+          if (g_capture_mode == 'staged' || g_capture_mode == 'staged2') {
+            let s = data.toString();
+            let tmp = ExtractMyDelimitedStuff(s, 'int');
+            if (tmp != undefined) {
+              let sz = Math.floor(parseInt(tmp) / 1024);
+              if (!isNaN(sz)) {
+                capture_info.textContent =
+                    'Raw Dbus capture size: ' + sz + ' KiB';
+              } else {  // This can happen if the output is cut by half & may be
+                        // fixed by queuing console outputs
+              }
+            }
+          } else {
+            AppendToParseBuffer(data.toString());
+            MunchLines();
+            UpdateLayout();
+            ComputeHistogram();
+          }
+          break;
+        case 'dbus monitor end':  // Todo: add speed check
+          let s = data.toString();
+          let i0 = s.lastIndexOf('>>>>'), i1 = s.lastIndexOf('<<<<');
+          if (i0 != -1 && i1 != -1) {
+            let tmp = s.substr(i0 + 4, i1 - i0 - 4);
+            let sz = parseInt(tmp);
+            if (isNaN(sz)) {
+              console.log(
+                  'Error: the tentative dbus-profile dump is not found!');
+            } else {
+              let bps = sz / 10;
+              console.log('dbus-monitor generates ' + bps + 'B per second');
+            }
+          }
+          g_child.kill('SIGINT');
+          break;
+        case 'sz sending':
+          console.log('[sz] Received a chunk of size ' + data.length);
+          console.log(data);
+          console.log(data + '');
+          //          capture_info.textContent = "Received a chunk of size " +
+          //          data.length
+          g_rz.stdin.write(data);
+          break;
+        case 'stopping':
+          let t = data.toString();
+          if (g_capture_mode == 'live') {
+            if (t.lastIndexOf('^C') != -1) {
+              // Live mode
+              g_child.kill('SIGINT');
+              g_capture_state = 'not started';
+              OnCaptureStop();
+              capture_info.textContent = 'connection to BMC closed';
+              // Log mode
+            }
+          } else if (
+              g_capture_mode == 'staged' || g_capture_mode == 'staged2') {
+            ClearAllPendingTimeouts();
+            if (t.lastIndexOf('<<<<<<') != -1) {
+              g_capture_state = 'compressing';
+              g_child.stdin.write(
+                  'echo ">>>>>>" && cd /run/initramfs && tar cfz DBUS_MONITOR.tar.gz DBUS_MONITOR && echo "<<<<<<" \n\n\n\n');
+              capture_info.textContent = 'Compressing dbus monitor dump on BMC';
+            }
+          }
+          break;
+        case 'compressing':
+          g_child.stdin.write(
+              '\n\na=`ls -l /run/initramfs/DBUS_MONITOR.tar.gz | awk \'{print $5}\'` && echo ">>>>>>$a<<<<<<"   \n\n\n\n');
+          g_capture_state = 'dbus_monitor size';
+          capture_info.textContent = 'Obtaining size of compressed dbus dump';
+          break;
+        case 'dbus_monitor size':
+          // Starting RZ
+          let tmp = ExtractMyDelimitedStuff(data.toString(), 'int');
+          if (tmp != null && !isNaN(tmp)) {  // Wait until result shows up
+            g_hexdump_total_size = tmp;
+            console.log(
+                'dbus_monitor size tmp=' + tmp + ', ' + data.toString());
+
+            // if (tmp != undefined) {
+            //   g_dbus_capture_tarfile_size = tmp;
+            //   capture_info.textContent =
+            //       'Starting rz and sz, file size: ' + Math.floor(tmp / 1024)
+            //       + ' KiB';
+            // } else {
+            //   capture_info.textContent = 'Starting rz and sz';
+            // }
+            // g_capture_state = 'sz start';
+            // g_child.stdin.write(
+            //   '\n\n\n\n' +
+            //   'sz -a -e -R -L 512 -w 32767 -y
+            //   /run/initramfs/DBUS_MONITOR.tar.gz\n');
+            // g_capture_state = 'sz sending';
+            // LaunchRZ();
+            g_child.stdin.write(
+                'echo ">>>>>>"; hexdump /run/initramfs/DBUS_MONITOR.tar.gz ; echo "<<<<<<"; \n');
+            g_capture_state = 'test hexdump running';
+            g_hexdump = new Buffer([]);
+          }
+
+          break;
+        case 'test hexdump start':
+          g_child.stdin.write(
+              'echo ">>>>>>"; hexdump /run/initramfs/DBUS_MONITOR.tar.gz ; echo "<<<<<<"; \n');
+          g_capture_state = 'test hexdump running';
+          g_hexdump = new Buffer([]);
+          g_hexdump_received_size = 0;
+          break;
+        case 'test hexdump running':
+          g_hexdump += data;
+          const lines = data.toString().split('\n');
+          for (let j = lines.length - 1; j >= 0; j--) {
+            sp = lines[j].trimEnd().split(' ');
+            if (sp.length >= 1) {
+              const sz = parseInt(sp[0], 16)
+              if (!isNaN(sz)) {
+                if (g_hexdump_received_size < sz) {
+                  g_hexdump_received_size = sz;
+                  capture_info.textContent = 'Receiving capture file: ' + sz +
+                      ' / ' + g_hexdump_total_size + ' B';
+                  break;
+                }
+              }
+            }
+          }
+          if (data.includes('<<<<<<') && !data.includes('echo')) {
+            g_hexdump = ExtractMyDelimitedStuff(g_hexdump);
+            SaveHexdumpToFile(g_hexdump, 'DBUS_MONITOR.tar.gz');
+            OnTransferCompleted();
+          }
+          break;
+      }
+      last_t = t;
+    }
+  });
+  g_child.stderr.on('data', (data) => {
+    console.log('[bmc] err=' + data);
+    g_child.stdin.write('echo "haha" \n\n');
+  });
+  g_child.on('close', (code) => {
+    console.log('return code: ' + code);
+  });
+}
diff --git a/dbus-vis/ipmi_parse.js b/dbus-vis/ipmi_parse.js
new file mode 100644
index 0000000..6323268
--- /dev/null
+++ b/dbus-vis/ipmi_parse.js
@@ -0,0 +1,479 @@
+// This file parses ASCII text-encoded dbus message dump.
+
+function extractUsec(line) {
+  let i0 = line.indexOf('time=');
+  if (i0 == -1) {
+    return BigInt(-1);
+  }
+  let line1 = line.substr(i0);
+  let i1 = line1.indexOf(' ');
+  if (i1 == -1) {
+    return BigInt(-1);
+  }
+  let line2 = line1.substr(5, i1 - 5);
+  let sp = line2.split('.');
+  return BigInt(sp[0]) * BigInt(1000000) + BigInt(sp[1]);
+}
+
+function extractInt(line, kw) {
+  let N = kw.length;
+  let i0 = line.indexOf(kw);
+  if (i0 == -1) {
+    return null;
+  }
+  let line1 = line.substr(i0);
+  let i1 = line1.indexOf(' ');
+  if (i1 == -1) {
+    i1 = line.length;
+  }
+  let line2 = line1.substr(N, i1 - N);
+  return parseInt(line2);
+}
+
+function extractSerial(line) {
+  return extractInt(line, 'serial=');
+}
+
+function extractReplySerial(line) {
+  return extractInt(line, 'reply_serial=');
+}
+
+// Returns [byte, i+1] if successful
+// Returns [null, i  ] if unsuccessful
+function munchByte(lines, i) {
+  if (i >= lines.length) {
+    return [null, i]
+  }
+  let l = lines[i];
+  let idx = l.indexOf('byte');
+  if (idx != -1) {
+    return [parseInt(l.substr(idx + 4), 10), i + 1];
+  } else {
+    return [null, i];
+  }
+}
+
+// array of bytes "@"
+function munchArrayOfBytes1(lines, i) {
+  let l = lines[i];
+  let idx = l.indexOf('array of bytes "');
+  if (idx != -1) {
+    let the_ch = l.substr(idx + 16, 1);
+    return [[the_ch.charCodeAt(0)], i + 1];
+  } else {
+    return [null, i];
+  }
+}
+
+function munchArrayOfBytes2(lines, i) {
+  let l = lines[i];
+  let idx = l.indexOf('array of bytes [');
+  if (idx == -1) {
+    idx = l.indexOf('array [');
+  }
+  if (idx != -1) {
+    let j = i + 1;
+    let payload = [];
+    while (true) {
+      if (j >= lines.length) {
+        break;
+      }
+      l = lines[j];
+      let sp = l.trim().split(' ');
+      let ok = true;
+      for (let k = 0; k < sp.length; k++) {
+        let b = parseInt(sp[k], 16);
+        if (isNaN(b)) {
+          ok = false;
+          break;
+        } else {
+          payload.push(b);
+        }
+      }
+      if (!ok) {
+        j--;
+        break;
+      } else
+        j++;
+    }
+    return [payload, j];
+  } else {
+    return [null, i];
+  }
+}
+
+function munchArrayOfBytes(lines, i) {
+  if (i >= lines.length) return [null, i];
+
+  let x = munchArrayOfBytes1(lines, i);
+  if (x[0] != null) {
+    return x;
+  }
+  x = munchArrayOfBytes2(lines, i);
+  if (x[0] != null) {
+    return x;
+  }
+  return [null, i];
+}
+
+// ReceivedMessage
+function munchLegacyMessageStart(lines, i) {
+  let entry = {
+    netfn: 0,
+    lun: 0,
+    cmd: 0,
+    serial: 0,
+    start_usec: 0,
+    end_usec: 0,
+    request: [],
+    response: []
+  };
+
+  let ts = extractUsec(lines[i]);
+  entry.start_usec = ts;
+
+  let x = munchByte(lines, i + 1);
+  if (x[0] == null) {
+    return [null, i];
+  }
+  entry.serial = x[0];
+  let j = x[1];
+
+  x = munchByte(lines, j);
+  if (x[0] == null) {
+    return [null, i];
+  }
+  entry.netfn = x[0];
+  j = x[1];
+
+  x = munchByte(lines, j);
+  if (x[0] == null) {
+    return [null, i];
+  }
+  entry.lun = x[0];
+  j = x[1];
+
+  x = munchByte(lines, j);
+  if (x[0] == null) {
+    return [null, i];
+  }
+  entry.cmd = x[0];
+  j = x[1];
+
+  x = munchArrayOfBytes(lines, j);
+  if (x[0] == null) {
+    return [null, i];
+  }
+  entry.request = x[0];
+  j = x[1];
+
+  return [entry, j];
+}
+
+function munchLegacyMessageEnd(lines, i, in_flight, parsed_entries) {
+  let ts = extractUsec(lines[i]);
+
+  let x = munchByte(lines, i + 1);
+  if (x[0] == null) {
+    return [null, i];
+  }  // serial
+  let serial = x[0];
+  let j = x[1];
+
+  let entry = null;
+  if (serial in in_flight) {
+    entry = in_flight[serial];
+    delete in_flight[serial];
+  } else {
+    return [null, i];
+  }
+
+  entry.end_usec = ts;
+
+  x = munchByte(lines, j);  // netfn
+  if (x[0] == null) {
+    return [null, i];
+  }
+  if (entry.netfn + 1 == x[0]) {
+  } else {
+    return [null, i];
+  }
+  j = x[1];
+
+  x = munchByte(lines, j);  // lun (not used right now)
+  if (x[0] == null) {
+    return [null, i];
+  }
+  j = x[1];
+
+  x = munchByte(lines, j);  // cmd
+  if (x[0] == null) {
+    return [null, i];
+  }
+  if (entry.cmd == x[0]) {
+  } else {
+    return [null, i];
+  }
+  j = x[1];
+
+  x = munchByte(lines, j);  // cc
+  if (x[0] == null) {
+    return [null, i];
+  }
+  j = x[1];
+
+  x = munchArrayOfBytes(lines, j);
+  if (x[0] == null) {
+    entry.response = [];
+  } else {
+    entry.response = x[0];
+  }
+  j = x[1];
+
+  parsed_entries.push(entry);
+
+  return [entry, j];
+}
+
+function munchNewMessageStart(lines, i, in_flight) {
+  let ts = extractUsec(lines[i]);
+  let serial = extractSerial(lines[i]);
+
+  let entry = {
+    netfn: 0,
+    lun: 0,
+    cmd: 0,
+    serial: -999,
+    start_usec: 0,
+    end_usec: 0,
+    request: [],
+    response: []
+  };
+  entry.start_usec = ts;
+  entry.serial = serial;
+
+  let x = munchByte(lines, i + 1);
+  if (x[0] == null) {
+    return [null, i];
+  }
+  entry.netfn = x[0];
+  let j = x[1];
+
+  x = munchByte(lines, j);
+  if (x[0] == null) {
+    return [null, i];
+  }
+  entry.lun = x[0];
+  j = x[1];
+
+  x = munchByte(lines, j);
+  if (x[0] == null) {
+    return [null, i];
+  }
+  entry.cmd = x[0];
+  j = x[1];
+
+  x = munchArrayOfBytes(lines, j);
+  if (x[0] == null) {
+    entry.request = [];
+  }  // Truncated
+  entry.request = x[0];
+  j = x[1];
+
+  return [entry, j];
+}
+
+function munchNewMessageEnd(lines, i, in_flight, parsed_entries) {
+  let ts = extractUsec(lines[i]);
+  let reply_serial = extractReplySerial(lines[i]);
+
+  let entry = null;
+  if (reply_serial in in_flight) {
+    entry = in_flight[reply_serial];
+    delete in_flight[reply_serial];
+  } else {
+    return [null, i];
+  }
+
+  entry.end_usec = ts;
+
+  let x = munchByte(lines, i + 2);  // Skip "struct {"
+  if (x[0] == null) {
+    return [null, i];
+  }  // NetFN
+  if (entry.netfn + 1 != x[0]) {
+    return [null, i];
+  }
+  let j = x[1];
+
+  x = munchByte(lines, j);  // LUN
+  if (x[0] == null) {
+    return [null, i];
+  }
+  j = x[1];
+
+  x = munchByte(lines, j);  // CMD
+  if (x[0] == null) {
+    return [null, i];
+  }
+  if (entry.cmd != x[0]) {
+    return [null, i];
+  }
+  j = x[1];
+
+  x = munchByte(lines, j);  // cc
+  if (x[0] == null) {
+    return [null, i];
+  }
+  j = x[1];
+
+  x = munchArrayOfBytes(lines, j);
+  if (x[0] == null) {
+    entry.response = [];
+  } else {
+    entry.response = x[0];
+  }
+  j = x[1];
+
+  parsed_entries.push(entry);
+
+  return [entry, j];
+}
+
+// Parsing state
+let g_ipmi_parse_buf = '';
+let g_ipmi_parse_lines = [];
+let g_ipmi_in_flight = {};
+let g_ipmi_parsed_entries = [];
+function StartParseIPMIDump() {
+  g_ipmi_parse_lines = [];
+  g_ipmi_parsed_entries = [];
+  g_ipmi_in_flight = {};
+  g_ipmi_parse_buf = '';
+}
+function AppendToParseBuffer(x) {
+  g_ipmi_parse_buf += x;
+}
+function MunchLines() {
+  // 1. Extract all lines from the buffer
+  let chars_munched = 0;
+  while (true) {
+    let idx = g_ipmi_parse_buf.indexOf('\n');
+    if (idx == -1) break;
+    let l = g_ipmi_parse_buf.substr(0, idx);
+    g_ipmi_parse_lines.push(l);
+    g_ipmi_parse_buf = g_ipmi_parse_buf.substr(idx + 1);
+    chars_munched += (idx + 1);
+  }
+  console.log(chars_munched + ' chars munched');
+
+  // 2. Parse as many lines as possible
+  let lidx_last = 0;
+  let i = 0;
+  while (i < g_ipmi_parse_lines.length) {
+    let line = g_ipmi_parse_lines[i];
+    if (line.indexOf('interface=org.openbmc.HostIpmi') != -1 &&
+        line.indexOf('member=ReceivedMessage') != -1) {
+      let x = munchLegacyMessageStart(g_ipmi_parse_lines, i);
+      let entry = x[0];
+      if (i != x[1]) lidx_last = x[1];  // Munch success!
+      i = x[1];
+      if (entry != null) {
+        g_ipmi_in_flight[entry.serial] = entry;
+      }
+    } else if (
+        line.indexOf('interface=org.openbmc.HostIpmi') != -1 &&
+        line.indexOf('member=sendMessage') != -1) {
+      let x = munchLegacyMessageEnd(
+          g_ipmi_parse_lines, i, g_ipmi_in_flight, g_ipmi_parsed_entries);
+      if (i != x[1]) lidx_last = x[1];  // Munch success!
+      i = x[1];
+
+    } else if (
+        line.indexOf('interface=xyz.openbmc_project.Ipmi.Server') != -1 &&
+        line.indexOf('member=execute') != -1) {
+      let x = munchNewMessageStart(g_ipmi_parse_lines, i);
+      let entry = x[0];
+      if (i != x[1]) lidx_last = x[1];
+      i = x[1];
+      if (entry != null) {
+        g_ipmi_in_flight[entry.serial] = entry;
+      }
+    } else if (line.indexOf('method return') != -1) {
+      let x = munchNewMessageEnd(
+          g_ipmi_parse_lines, i, g_ipmi_in_flight, g_ipmi_parsed_entries);
+      if (i != x[1]) lidx_last = x[1];  // Munch success
+      i = x[1];
+    }
+    i++;
+  }
+  g_ipmi_parse_lines = g_ipmi_parse_lines.slice(
+      lidx_last,
+      g_ipmi_parse_lines.length);  // Remove munched lines
+  console.log(
+      lidx_last + ' lines munched, |lines|=' + g_ipmi_parse_lines.length +
+          ', |entries|=' + g_ipmi_parsed_entries.length,
+      ', |inflight|=' + Object.keys(g_ipmi_in_flight).length);
+}
+
+let last_update_time = 0;  // Millis since Unix Epoch
+function UpdateLayout(level) {
+  const this_update_time = new Date().getTime();
+  const over_1s = (this_update_time - last_update_time > 1000);
+  if (!over_1s) {
+    if (level > 0) {
+      setTimeout(function() {
+        UpdateLayout(level - 1);
+      }, 1000);
+    } else {
+      return;
+    }
+  }
+
+  if (g_ipmi_parsed_entries.length > 0) {
+    last_update_time = this_update_time;
+    // Write to Data_IPMI
+    let ts0 = g_ipmi_parsed_entries[0].start_usec;
+    let ts1 = g_ipmi_parsed_entries[g_ipmi_parsed_entries.length - 1].end_usec;
+
+    // When calling from DBus PCap loader, the following happens
+    // >> OnGroupByConditionChanged
+    //   >> Preprocess  <-- Time shift will happen here
+    // So, we don't do time-shifting here
+    let time_shift;
+    if (g_StartingSec != undefined) {
+      time_shift = BigInt(0);
+    } else {  // This is during live capture mode
+      time_shift = ts0;
+    }
+
+    Data_IPMI = [];
+    for (i = 0; i < g_ipmi_parsed_entries.length; i++) {
+      let entry = g_ipmi_parsed_entries[i];
+      let x = [
+        entry.netfn, entry.cmd, parseInt(entry.start_usec - time_shift),
+        parseInt(entry.end_usec - time_shift), entry.request, entry.response
+      ];
+      Data_IPMI.push(x);
+    }
+
+    // Re-calculate time range
+    RANGE_LEFT_INIT = 0;
+    RANGE_RIGHT_INIT =
+        parseInt((ts1 - ts0) / BigInt(1000000) / BigInt(10)) * 10 + 10;
+
+    IsCanvasDirty = true;
+    OnGroupByConditionChanged();
+    
+    ComputeHistogram();
+  } else {
+    console.log('No entries parsed');
+  }
+}
+
+function ParseIPMIDump(data) {
+  StartParseIPMIDump();
+  AppendToParseBuffer(data);
+  MunchLines();
+  UpdateLayout();
+}
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);
+}
diff --git a/dbus-vis/linecount.py b/dbus-vis/linecount.py
new file mode 100644
index 0000000..ef7dd83
--- /dev/null
+++ b/dbus-vis/linecount.py
@@ -0,0 +1,14 @@
+# This script is used for printing out the number of packets in a pcap file
+
+from scapy.all import rdpcap
+import sys
+
+file_name = sys.argv[1]
+try:
+    stream = rdpcap(file_name)
+    n = 0
+    for packet in stream:
+        n += 1
+    print(n)
+except Exception as e:
+    pass
diff --git a/dbus-vis/main.js b/dbus-vis/main.js
new file mode 100644
index 0000000..b165fe6
--- /dev/null
+++ b/dbus-vis/main.js
@@ -0,0 +1,43 @@
+// Modules to control application life and create native browser window
+const {app, BrowserWindow} = require('electron');
+const path = require('path');
+
+function createWindow() {
+  // Create the browser window.
+  const mainWindow = new BrowserWindow({
+    width: 1440,
+    height: 900,
+    webPreferences: {
+      preload: path.join(__dirname, 'preload.js'),
+      nodeIntegration:
+          true  // For opening file dialog from the renderer process
+    }
+  });
+
+  // and load the index.html of the app.
+  mainWindow.loadFile('index.html');
+
+  // Open the DevTools.
+  //  mainWindow.webContents.openDevTools()
+}
+
+// This method will be called when Electron has finished
+// initialization and is ready to create browser windows.
+// Some APIs can only be used after this event occurs.
+app.whenReady().then(createWindow);
+
+// Quit when all windows are closed.
+app.on('window-all-closed', function() {
+  // On macOS it is common for applications and their menu bar
+  // to stay active until the user quits explicitly with Cmd + Q
+  if (process.platform !== 'darwin') app.quit();
+})
+
+app.on('activate', function() {
+  // On macOS it's common to re-create a window in the app when the
+  // dock icon is clicked and there are no other windows open.
+  if (BrowserWindow.getAllWindows().length === 0) createWindow();
+});
+
+// In this file you can include the rest of your app's specific main process
+// code. You can also put them in separate files and require them here.
diff --git a/dbus-vis/package.json b/dbus-vis/package.json
new file mode 100644
index 0000000..751c2fb
--- /dev/null
+++ b/dbus-vis/package.json
@@ -0,0 +1,27 @@
+{
+  "name": "openbmc_ipmi_timeline_vis",
+  "version": "0.0.1",
+  "description": "OpenBMC IPMI Timeline Visualizer",
+  "main": "main.js",
+  "scripts": {
+    "start": "electron ."
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/quadpixels/openbmc_ipmi_timeline_vis.git"
+  },
+  "keywords": [
+    "OpenBMC",
+    "IPMI"
+  ],
+  "devDependencies": {
+    "electron": "^8.2.4"
+  },
+  "dependencies": {
+    "electron-dialog": "^2.0.0",
+    "file-system": "^2.2.2",
+    "md5-file": "^5.0.0",
+    "tar-stream": "^2.1.2",
+    "targz": "^1.0.1"
+  }
+}
diff --git a/dbus-vis/preload.js b/dbus-vis/preload.js
new file mode 100644
index 0000000..fe6bb56
--- /dev/null
+++ b/dbus-vis/preload.js
@@ -0,0 +1,17 @@
+// All of the Node.js APIs are available in the preload process.
+// It has the same sandbox as a Chrome extension.
+
+const dialog = require('electron').remote.dialog;  // Must add "remote"
+
+window.addEventListener('DOMContentLoaded', () => {
+  const replaceText = (selector, text) => {
+    const element = document.getElementById(selector);
+    if (element) element.innerText = text;
+  };
+
+  for (const type of ['chrome', 'node', 'electron']) {
+    replaceText(`${type}-version`, process.versions[type]);
+  }
+})
+
+console.log(document.getElementById('btn_open_file'));
diff --git a/dbus-vis/renderer.js b/dbus-vis/renderer.js
new file mode 100644
index 0000000..5c74bf1
--- /dev/null
+++ b/dbus-vis/renderer.js
@@ -0,0 +1,56 @@
+// This file is required by the index.html file and will
+// be executed in the renderer process for that window.
+// No Node.js APIs are available in this process because
+// `nodeIntegration` is turned off. Use `preload.js` to
+// selectively enable features needed in the rendering
+// process.
+
+class Renderer {
+  constructor() {
+    let c1 = document.getElementById('my_canvas_ipmi');
+    let c2 = document.getElementById('my_canvas_dbus');
+    let c3 = document.getElementById('my_canvas_boost_asio_handler');
+    this.canvas1 = c1;
+    this.canvas2 = c2;
+    this.canvas3 = c3;
+    this.width1 = c1.width; this.height1 = c1.height;
+    this.width2 = c2.width; this.height2 = c2.height;
+    this.width3 = c3.width; this.height3 = c3.height;
+    this.ctx1 = this.canvas1.getContext('2d');
+    this.ctx2 = this.canvas2.getContext('2d');
+    this.ctx3 = this.canvas3.getContext('2d');
+    this.frame_count = 0;
+    this.addBindings();
+    this.addListeners();
+    this.update();
+    this.run();
+  }
+
+  addBindings() {
+    this.update = this.update.bind(this);
+    this.run = this.run.bind(this);
+  }
+
+  addListeners() {
+    window.addEventListener('resize', this.update);
+  }
+
+  update() {
+    console.log('update, ' + window.innerWidth + ' x ' + window.innerHeight);
+    if (false) {
+      this.width1 = window.innerWidth;
+      this.height1 = window.innerHeight;
+      this.canvas1.width = this.width1;
+      this.canvas1.height = this.height1;
+    }
+  }
+
+  run() {
+    draw_timeline(this.ctx1);
+    draw_timeline_dbus(this.ctx2);
+    draw_timeline_boost_asio_handler(this.ctx3);
+    window.requestAnimationFrame(this.run);
+  }
+}
+
+g_renderer = new Renderer();
\ No newline at end of file
diff --git a/dbus-vis/scrnshot.png b/dbus-vis/scrnshot.png
new file mode 100644
index 0000000..7e98af9
--- /dev/null
+++ b/dbus-vis/scrnshot.png
Binary files differ
diff --git a/dbus-vis/timeline_view.js b/dbus-vis/timeline_view.js
new file mode 100644
index 0000000..a132cb8
--- /dev/null
+++ b/dbus-vis/timeline_view.js
@@ -0,0 +1,1800 @@
+const { TouchBarScrubber } = require("electron");
+
+// Default range: 0 to 300s, shared between both views
+var RANGE_LEFT_INIT = 0;
+var RANGE_RIGHT_INIT = 300;
+
+// Global timeline start
+var g_StartingSec = undefined;
+
+function ShouldShowDebugInfo() {
+  if (g_cb_debug_info.checked) return true;
+  else return false;
+}
+
+function GetHistoryHistogram() {
+  return HistoryHistogram;
+}
+
+function RenderHistogramForImageData(ctx, key) {
+  let PAD = 1,   // To make up for the extra stroke width
+      PAD2 = 2;  // To preserve some space at both ends of the histogram
+
+  let cumDensity0 = 0, cumDensity1 = 0;
+
+  //      Left normalized index  Left value  Right normalized index, Right value
+  let threshEntry = [[undefined, undefined], [undefined, undefined]];
+  const x = 0, y = 0, w = HISTOGRAM_W, h = HISTOGRAM_H;
+  let hist = GetHistoryHistogram()[key];
+  if (hist == undefined) return;
+
+  let buckets = hist[2];
+  let dw = w * 1.0 / buckets.length;
+  let maxCount = 0, totalCount = 0;
+  for (let i = 0; i < buckets.length; i++) {
+    if (maxCount < buckets[i]) {
+      maxCount = buckets[i];
+    }
+    totalCount += buckets[i];
+  }
+  ctx.fillStyle = '#FFF';
+  ctx.fillRect(x, y, w, h);
+
+  ctx.strokeStyle = '#AAA';
+  ctx.fillStyle = '#000';
+  ctx.lineWidth = 1;
+  ctx.strokeRect(x + PAD, y + PAD, w - 2 * PAD, h - 2 * PAD);
+  for (let i = 0; i < buckets.length; i++) {
+    const bucketsLen = buckets.length;
+    if (buckets[i] > 0) {
+      let dx0 = x + PAD2 + (w - 2 * PAD2) * 1.0 * i / buckets.length,
+          dx1 = x + PAD2 + (w - 2 * PAD2) * 1.0 * (i + 1) / buckets.length,
+          dy0 = y + h - h * 1.0 * buckets[i] / maxCount, dy1 = y + h;
+      let delta_density = buckets[i] / totalCount;
+      cumDensity0 = cumDensity1;
+      cumDensity1 += delta_density;
+
+      // Write thresholds
+      if (cumDensity0 < HISTOGRAM_LEFT_TAIL_WIDTH &&
+          cumDensity1 >= HISTOGRAM_LEFT_TAIL_WIDTH) {
+        threshEntry[0][0] = i / buckets.length;
+        threshEntry[0][1] = hist[0] + (hist[1] - hist[0]) / bucketsLen * i;
+      }
+      if (cumDensity0 < 1 - HISTOGRAM_RIGHT_TAIL_WIDTH &&
+          cumDensity1 >= 1 - HISTOGRAM_RIGHT_TAIL_WIDTH) {
+        threshEntry[1][0] = (i - 1) / buckets.length;
+        threshEntry[1][1] =
+            hist[0] + (hist[1] - hist[0]) / bucketsLen * (i - 1);
+      }
+
+      ctx.fillRect(dx0, dy0, dx1 - dx0, dy1 - dy0);
+    }
+  }
+
+  // Mark the threshold regions
+  ctx.fillStyle = 'rgba(0,255,0,0.1)';
+  let dx = x + PAD2;
+  dw = (w - 2 * PAD2) * 1.0 * threshEntry[0][0];
+  ctx.fillRect(dx, y, dw, h);
+
+  ctx.fillStyle = 'rgba(255,0,0,0.1)';
+  ctx.beginPath();
+  dx = x + PAD2 + (w - 2 * PAD2) * 1.0 * threshEntry[1][0];
+  dw = (w - 2 * PAD2) * 1.0 * (1 - threshEntry[1][0]);
+  ctx.fillRect(dx, y, dw, h);
+
+  IsCanvasDirty = true;
+  return [ctx.getImageData(x, y, w, h), threshEntry];
+}
+
+function RenderHistogram(ctx, key, xMid, yMid) {
+  if (GetHistoryHistogram()[key] == undefined) {
+    return;
+  }
+  if (IpmiVizHistogramImageData[key] == undefined) {
+    return;
+  }
+  let hist = GetHistoryHistogram()[key];
+  ctx.putImageData(
+      IpmiVizHistogramImageData[key], xMid - HISTOGRAM_W / 2,
+      yMid - HISTOGRAM_H / 2);
+
+  let ub = '';  // Upper bound label
+  ctx.textAlign = 'left';
+  ctx.fillStyle = '#000';
+  if (hist[1] > 1000) {
+    ub = (hist[1] / 1000.0).toFixed(1) + 'ms';
+  } else {
+    ub = hist[1].toFixed(1) + 'us';
+  }
+  ctx.fillText(ub, xMid + HISTOGRAM_W / 2, yMid);
+
+  let lb = '';  // Lower bound label
+  if (hist[0] > 1000) {
+    lb = (hist[0] / 1000.0).toFixed(1) + 'ms';
+  } else {
+    lb = hist[0].toFixed(1) + 'us';
+  }
+  ctx.textAlign = 'right';
+  ctx.textBaseline = 'middle';
+  ctx.fillText(lb, xMid - HISTOGRAM_W / 2, yMid);
+}
+
+// A TimelineView contains data that has already gone through
+// the Layout step and is ready for showing
+class TimelineView {
+  constructor() {
+    this.Intervals = [];
+    this.Titles = [];  // { "header":true|false, "title":string, "intervals_idxes":[int] } 
+    this.Heights = [];  // Visual height for each line
+    this.HeaderCollapsed = {};
+    this.TitleProperties = []; // [Visual height, Is Header]
+    this.LowerBoundTime = RANGE_LEFT_INIT;
+    this.UpperBoundTime = RANGE_RIGHT_INIT;
+    this.LowerBoundTimeTarget = this.LowerBoundTime;
+    this.UpperBoundTimeTarget = this.UpperBoundTime;
+    this.LastTimeLowerBound = 0;
+    this.LastTimeUpperBound = 0;
+    this.IsCanvasDirty = true;
+    this.IsHighlightDirty = true;
+    this.IsAnimating = false;
+    this.IpmiVizHistogramImageData = {};
+    this.IpmiVizHistHighlighted = {};
+    this.HighlightedRequests = [];
+    this.Canvas = undefined;
+    this.TitleDispLengthLimit = 32;  // display this many chars for title
+    this.IsTimeDistributionEnabled = false;
+    this.AccentColor = '#000';
+    this.CurrentFileName = '';
+    this.VisualLineStartIdx = 0;
+
+    // For connecting to the data model
+    this.GroupBy = [];
+    this.GroupByStr = '';
+
+    // For keyboard navigation
+    this.CurrDeltaX = 0;
+    this.CurrDeltaZoom = 0;
+    this.CurrShiftFlag = false;
+    this.MouseState = {
+      hovered: true,
+      pressed: false,
+      x: 0,
+      y: 0,
+      hoveredVisibleLineIndex: -999,
+      hoveredSide: undefined,  // 'left', 'right', 'scroll', 'timeline'
+      drag_begin_title_start_idx: undefined,
+      drag_begin_y: undefined,
+      IsDraggingScrollBar: function() {
+        return (this.drag_begin_y != undefined);
+      },
+      EndDragScrollBar: function() {
+        this.drag_begin_y = undefined;
+        this.drag_begin_title_start_idx = undefined;
+      }
+    };
+    this.ScrollBarState = {
+      y0: undefined,
+      y1: undefined,
+    };
+    this.HighlightedRegion = {t0: -999, t1: -999};
+
+    // The linked view will move and zoom with this view
+    this.linked_views = [];
+  }
+
+  // Performs layout operation, move overlapping intervals to different
+  // lines
+  LayoutForOverlappingIntervals() {
+    this.Heights = [];
+    const MAX_STACK = 10; // Stack level limit: 10, arbitrarily chosen
+    
+    for (let i=0; i<this.Titles.length; i++) {
+      let last_x = {};
+      let ymax = 0;
+
+      const title_data = this.Titles[i];
+
+      const intervals_idxes = title_data.intervals_idxes;
+
+      // TODO: What happens if there are > 1
+      if (title_data.header == false) {
+        const line = this.Intervals[intervals_idxes[0]];
+
+        for (let j=0; j<line.length; j++) {
+          const entry = line[j];
+          let y = 0;
+          for (; y<MAX_STACK; y++) {
+            if (!(y in last_x)) { break; }
+            if (last_x[y] <= entry[0]) {
+              break;
+            }
+          }
+          
+          const end_time = entry[1];
+          if (end_time != undefined && !isNaN(end_time)) {
+            last_x[y] = end_time;
+          } else {
+            last_x[y] = entry[0];
+          }
+          entry[4] = y;
+          ymax = Math.max(y, ymax);
+        }
+      } else if (intervals_idxes.length == 0) {
+        // Don't do anything, set height to 1
+      }
+      this.Heights.push(ymax+1);
+    }
+  }
+
+  TotalVisualHeight() {
+    let ret = 0;
+    this.Heights.forEach((h) => {
+      ret += h;
+    })
+    return ret;
+  }
+
+  // Returns [Index, Offset]
+  VisualLineIndexToDataLineIndex(x) {
+    if (this.Heights.length < 1) return undefined;
+    let lb = 0, ub = this.Heights[0]-1;
+    for (let i=0; i<this.Heights.length; i++) {
+      ub = lb + this.Heights[i] - 1;
+      if (lb <= x && ub >= x) {
+        return [i, x-lb];
+      }
+      lb = ub+1;
+    }
+    return -999;
+  }
+
+  IsEmpty() {
+    return (this.Intervals.length < 1);
+  }
+
+  GetTitleWidthLimit() {
+    if (this.IsTimeDistributionEnabled == true) {
+      return 32;
+    } else {
+      return 64;
+    }
+  }
+
+  ToLines(t, limit) {
+    let ret = [];
+    for (let i = 0; i < t.length; i += limit) {
+      let j = Math.min(i + limit, t.length);
+      ret.push(t.substr(i, j));
+    }
+    return ret;
+  }
+
+  Zoom(dz, mid = undefined, iter = 1) {
+    if (this.CurrShiftFlag) dz *= 2;
+    if (dz != 0) {
+      if (mid == undefined) {
+        mid = (this.LowerBoundTime + this.UpperBoundTime) / 2;
+      }
+      this.LowerBoundTime = mid - (mid - this.LowerBoundTime) * (1 - dz);
+      this.UpperBoundTime = mid + (this.UpperBoundTime - mid) * (1 - dz);
+      this.IsCanvasDirty = true;
+      this.IsAnimating = false;
+    }
+
+    if (iter > 0) {
+      this.linked_views.forEach(function(v) {
+        v.Zoom(dz, mid, iter - 1);
+      });
+    }
+  }
+
+  BeginZoomAnimation(dz, mid = undefined, iter = 1) {
+    if (mid == undefined) {
+      mid = (this.LowerBoundTime + this.UpperBoundTime) / 2;
+    }
+    this.LowerBoundTimeTarget = mid - (mid - this.LowerBoundTime) * (1 - dz);
+    this.UpperBoundTimeTarget = mid + (this.UpperBoundTime - mid) * (1 - dz);
+    this.IsCanvasDirty = true;
+    this.IsAnimating = true;
+
+    if (iter > 0) {
+      this.linked_views.forEach(function(v) {
+        v.BeginZoomAnimation(dz, mid, iter - 1);
+      });
+    }
+  }
+
+  BeginPanScreenAnimaton(delta_screens, iter = 1) {
+    let deltat = (this.UpperBoundTime - this.LowerBoundTime) * delta_screens;
+    this.BeginSetBoundaryAnimation(
+        this.LowerBoundTime + deltat, this.UpperBoundTime + deltat);
+
+    if (iter > 0) {
+      this.linked_views.forEach(function(v) {
+        v.BeginPanScreenAnimaton(delta_screens, iter - 1);
+      });
+    }
+  }
+
+  BeginSetBoundaryAnimation(lt, rt, iter = 1) {
+    this.IsAnimating = true;
+    this.LowerBoundTimeTarget = lt;
+    this.UpperBoundTimeTarget = rt;
+
+    if (iter > 0) {
+      this.linked_views.forEach(function(v) {
+        v.BeginSetBoundaryAnimation(lt, rt, iter - 1);
+      });
+    }
+  }
+
+  BeginWarpToRequestAnimation(req, iter = 1) {
+    let mid_new = (req[0] + req[1]) / 2;
+    let mid = (this.LowerBoundTime + this.UpperBoundTime) / 2;
+    let lt = this.LowerBoundTime + (mid_new - mid);
+    let rt = this.UpperBoundTime + (mid_new - mid);
+    this.BeginSetBoundaryAnimation(lt, rt, 0);
+
+    this.linked_views.forEach(function(v) {
+      v.BeginSetBoundaryAnimation(lt, rt, 0);
+    });
+  }
+
+  UpdateAnimation() {
+    const EPS = 1e-3;
+    if (Math.abs(this.LowerBoundTime - this.LowerBoundTimeTarget) < EPS &&
+        Math.abs(this.UpperBoundTime - this.UpperBoundTimeTarget) < EPS) {
+      this.LowerBoundTime = this.LowerBoundTimeTarget;
+      this.UpperBoundTime = this.UpperBoundTimeTarget;
+      this.IsAnimating = false;
+    }
+    if (this.IsAnimating) {
+      let t = 0.80;
+      this.LowerBoundTime =
+          this.LowerBoundTime * t + this.LowerBoundTimeTarget * (1 - t);
+      this.UpperBoundTime =
+          this.UpperBoundTime * t + this.UpperBoundTimeTarget * (1 - t);
+      this.IsCanvasDirty = true;
+    }
+  }
+
+  IsHighlighted() {
+    return (
+        this.HighlightedRegion.t0 != -999 && this.HighlightedRegion.t1 != -999);
+  }
+
+  RenderHistogram(ctx, key, xMid, yMid) {
+    if (GetHistoryHistogram()[key] == undefined) {
+      return;
+    }
+    if (this.IpmiVizHistogramImageData[key] == undefined) {
+      return;
+    }
+    let hist = GetHistoryHistogram()[key];
+    ctx.putImageData(
+        this.IpmiVizHistogramImageData[key], xMid - HISTOGRAM_W / 2,
+        yMid - HISTOGRAM_H / 2);
+
+    let ub = '';  // Upper bound label
+    ctx.textAlign = 'left';
+    ctx.fillStyle = '#000';
+    if (hist[1] > 1000) {
+      ub = (hist[1] / 1000.0).toFixed(1) + 'ms';
+    } else {
+      ub = hist[1].toFixed(1) + 'us';
+    }
+    ctx.fillText(ub, xMid + HISTOGRAM_W / 2, yMid);
+
+    let lb = '';  // Lower bound label
+    if (hist[0] > 1000) {
+      lb = (hist[0] / 1000.0).toFixed(1) + 'ms';
+    } else {
+      lb = hist[0].toFixed(1) + 'us';
+    }
+    ctx.textAlign = 'right';
+    ctx.textBaseline = 'middle';
+    ctx.fillText(lb, xMid - HISTOGRAM_W / 2, yMid);
+  }
+
+  IsMouseOverTimeline() {
+    return this.MouseState.x > LEFT_MARGIN;
+  }
+
+  MouseXToTimestamp(x) {
+    let ret = (x - LEFT_MARGIN) / (RIGHT_MARGIN - LEFT_MARGIN) *
+            (this.UpperBoundTime - this.LowerBoundTime) +
+        this.LowerBoundTime;
+    ret = Math.max(ret, this.LowerBoundTime);
+    ret = Math.min(ret, this.UpperBoundTime);
+    return ret;
+  }
+
+  Unhighlight() {
+    this.HighlightedRegion.t0 = -999;
+    this.HighlightedRegion.t1 = -999;
+  }
+
+  OnMouseMove() {
+    // Drag gestures
+    if (this.MouseState.pressed == true) {
+      const h = this.MouseState.hoveredSide;
+      if (h == 'timeline') {
+        // Update highlighted area
+        this.HighlightedRegion.t1 =
+          this.MouseXToTimestamp(this.MouseState.x);
+      }
+    }
+
+    const PAD = 2;
+    if (this.MouseState.x < LEFT_MARGIN)
+      this.MouseState.hovered = false;
+    else if (this.MouseState.x > RIGHT_MARGIN)
+      this.MouseState.hovered = false;
+    else
+      this.MouseState.hovered = true;
+
+    this.IsCanvasDirty = true;
+    let lineIndex =
+        Math.floor((this.MouseState.y - YBEGIN + TEXT_Y0) / LINE_SPACING);
+
+    if (this.MouseState.x <= 0 ||
+        this.MouseState.x >= RIGHT_MARGIN) {
+      lineIndex = undefined;
+    }
+
+    const old_hoveredSide = this.MouseState.hoveredSide;
+
+    // Left/right overflow markers or time axis drag
+    this.MouseState.hoveredVisibleLineIndex = -999;
+    if (this.MouseState.hoveredSide != "scrollbar" &&
+        this.MouseState.pressed == false) {
+      if (lineIndex != undefined) {
+        this.MouseState.hoveredVisibleLineIndex = lineIndex;
+
+        let should_hide_cursor = false;  // Should we hide the vertical cursor for linked views?
+
+        if (this.MouseState.x <= PAD + LINE_SPACING / 2 + LEFT_MARGIN &&
+            this.MouseState.x >= PAD + LEFT_MARGIN) {
+          this.MouseState.hoveredSide = 'left';
+          this.IsCanvasDirty = true;
+        } else if (
+            this.MouseState.x <= RIGHT_MARGIN - PAD &&
+            this.MouseState.x >= RIGHT_MARGIN - PAD - LINE_SPACING / 2) {
+          this.MouseState.hoveredSide = 'right';
+          this.IsCanvasDirty = true;
+        } else if (this.MouseState.x >= PAD + LEFT_MARGIN &&
+                   this.MouseState.y <= TOP_HORIZONTAL_SCROLLBAR_HEIGHT &&
+                   this.MouseState.y >  0) {
+          this.MouseState.hoveredVisibleLineIndex = undefined;
+          this.MouseState.hoveredSide = 'top_horizontal_scrollbar';
+        } else if (this.MouseState.x >= PAD + LEFT_MARGIN &&
+                   this.MouseState.y >= this.Canvas.height - BOTTOM_HORIZONTAL_SCROLLBAR_HEIGHT &&
+                   this.MouseState.y <= this.Canvas.height) {
+          this.MouseState.hoveredVisibleLineIndex = undefined;
+          this.MouseState.hoveredSide = 'bottom_horizontal_scrollbar';
+        } else {
+          this.MouseState.hoveredSide = undefined;
+        }
+      }
+    }
+
+    // During a dragging session
+    if (this.MouseState.pressed == true) {
+
+      if (this.MouseState.hoveredSide == "top_horizontal_scrollbar" ||
+          this.MouseState.hoveredSide == "bottom_horizontal_scrollbar") {
+        const sec_per_px = (this.MouseState.begin_UpperBoundTime - this.MouseState.begin_LowerBoundTime) / (RIGHT_MARGIN - LEFT_MARGIN);
+        const pan_secs = (this.MouseState.x - this.MouseState.begin_drag_x) * sec_per_px;
+
+        const new_lb = this.MouseState.begin_LowerBoundTime - pan_secs;
+        const new_ub = this.MouseState.begin_UpperBoundTime - pan_secs;
+        this.LowerBoundTime = new_lb;
+        this.UpperBoundTime = new_ub;
+
+        // Sync to all other views
+        this.linked_views.forEach((v) => {
+          v.LowerBoundTime = new_lb; v.UpperBoundTime = new_ub;
+        })
+      }
+
+      const tvh = this.TotalVisualHeight();
+      if (this.MouseState.hoveredSide == 'scrollbar') {
+        const diff_y = this.MouseState.y - this.MouseState.drag_begin_y;
+        const diff_title_idx = tvh * diff_y / this.Canvas.height;
+        let new_title_start_idx = this.MouseState.drag_begin_title_start_idx + parseInt(diff_title_idx);
+        if (new_title_start_idx < 0) { new_title_start_idx = 0; }
+        else if (new_title_start_idx >= tvh) {
+          new_title_start_idx = tvh - 1;
+        }
+        this.VisualLineStartIdx = new_title_start_idx;
+      }
+    }
+  }
+
+  OnMouseLeave() {
+    // When dragging the scroll bar, allow mouse to temporarily leave the element since we only
+    // care about delta Y
+    if (this.MouseState.hoveredSide == 'scrollbar') {
+      
+    } else {
+      this.MouseState.hovered = false;
+      this.MouseState.hoveredSide = undefined;
+      this.IsCanvasDirty = true;
+      this.MouseState.hoveredVisibleLineIndex = undefined;
+      this.MouseState.y = undefined;
+      this.MouseState.x = undefined;
+    }
+  }
+
+  // Assume event.button is zero (left mouse button)
+  OnMouseDown(iter = 1) {
+    // If hovering over an overflowing triangle, warp to the nearest overflowed
+    //     request on that line
+    if (this.MouseState.hoveredVisibleLineIndex >= 0 &&
+        this.MouseState.hoveredVisibleLineIndex < this.Intervals.length &&
+        this.MouseState.hoveredSide != undefined) {
+      const x = this.VisualLineIndexToDataLineIndex(this.MouseState.hoveredVisibleLineIndex);
+      if (x == undefined) return;
+      const line = this.Intervals[x[0]];
+      if (this.MouseState.hoveredSide == 'left') {
+        for (let i = line.length - 1; i >= 0; i--) {
+          if (line[i][1] <= this.LowerBoundTime) {
+            this.BeginWarpToRequestAnimation(line[i]);
+            // TODO: pass timeline X to linked view
+            break;
+          }
+        }
+      } else if (this.MouseState.hoveredSide == 'right') {
+        for (let i = 0; i < line.length; i++) {
+          if (line[i][0] >= this.UpperBoundTime) {
+            // TODO: pass timeline X to linked view
+            this.BeginWarpToRequestAnimation(line[i]);
+            break;
+          }
+        }
+      }
+    }
+
+    let tx = this.MouseXToTimestamp(this.MouseState.x);
+    let t0 = Math.min(this.HighlightedRegion.t0, this.HighlightedRegion.t1),
+        t1 = Math.max(this.HighlightedRegion.t0, this.HighlightedRegion.t1);
+    if (this.MouseState.x > LEFT_MARGIN) {
+
+      // If clicking on the horizontal scroll bar, start panning the viewport
+      if (this.MouseState.hoveredSide == "top_horizontal_scrollbar" ||
+          this.MouseState.hoveredSide == "bottom_horizontal_scrollbar") {
+        this.MouseState.pressed = true;
+        this.MouseState.begin_drag_x = this.MouseState.x;
+        this.MouseState.begin_LowerBoundTime = this.LowerBoundTime;
+        this.MouseState.begin_UpperBoundTime = this.UpperBoundTime;
+      } else if (tx >= t0 && tx <= t1) {
+        // If clicking inside highlighted area, zoom around the area
+        this.BeginSetBoundaryAnimation(t0, t1);
+        this.Unhighlight();
+        this.IsCanvasDirty = true;
+
+        this.linked_views.forEach(function(v) {
+          v.BeginSetBoundaryAnimation(t0, t1, 0);
+          v.Unhighlight();
+          v.IsCanvasDirty = false;
+        });
+      } else {  // If in the timeline area, start a new dragging action
+        this.MouseState.hoveredSide = 'timeline';
+        this.MouseState.pressed = true;
+        this.HighlightedRegion.t0 = this.MouseXToTimestamp(this.MouseState.x);
+        this.HighlightedRegion.t1 = this.HighlightedRegion.t0;
+        this.IsCanvasDirty = true;
+      }
+    } else if (this.MouseState.x < SCROLL_BAR_WIDTH) {  // Todo: draagging the scroll bar
+      const THRESH = 4;
+      if (this.MouseState.y >= this.ScrollBarState.y0 - THRESH &&
+          this.MouseState.y <= this.ScrollBarState.y1 + THRESH) {
+        this.MouseState.pressed = true;
+        this.MouseState.drag_begin_y = this.MouseState.y;
+        this.MouseState.drag_begin_title_start_idx = this.VisualLineStartIdx;
+        this.MouseState.hoveredSide = 'scrollbar';
+      }
+    }
+
+    // Collapse or expand a "header"
+    if (this.MouseState.x < LEFT_MARGIN &&
+        this.MouseState.hoveredVisibleLineIndex != undefined) {
+      const x = this.VisualLineIndexToDataLineIndex(this.VisualLineStartIdx + this.MouseState.hoveredVisibleLineIndex);
+      if (x != undefined) {
+        const tidx = x[0];
+        if (this.Titles[tidx] != undefined && this.Titles[tidx].header == true) {
+
+          // Currently, only DBus pane supports column headers, so we can hard-code the DBus re-group function (rather than to figure out which pane we're in)
+          this.HeaderCollapsed[this.Titles[tidx].title] = !(this.HeaderCollapsed[this.Titles[tidx].title]);
+          OnGroupByConditionChanged_DBus();
+        }
+      }
+    }
+  }
+
+  // Assume event.button == 0 (left mouse button)
+  OnMouseUp() {
+    this.MouseState.EndDragScrollBar();
+    this.MouseState.pressed = false;
+    this.IsCanvasDirty = true;
+    this.UnhighlightIfEmpty();
+    this.IsHighlightDirty = true;
+    this.MouseState.hoveredSide = undefined;
+  }
+
+  UnhighlightIfEmpty() {
+    if (this.HighlightedRegion.t0 == this.HighlightedRegion.t1) {
+      this.Unhighlight();
+      this.IsCanvasDirty = true;
+      return true;
+    } else
+      return false;
+  }
+
+  OnMouseWheel(event) {
+    event.preventDefault();
+    const v = this;
+
+    let is_mouse_on_horizontal_scrollbar = false;
+    if (this.MouseState.y > 0 && this.MouseState.y < TOP_HORIZONTAL_SCROLLBAR_HEIGHT)
+      is_mouse_on_horizontal_scrollbar = true;
+    if (this.MouseState.y > this.Canvas.height - BOTTOM_HORIZONTAL_SCROLLBAR_HEIGHT &&
+        this.MouseState.y < this.Canvas.height)
+      is_mouse_on_horizontal_scrollbar = true;
+
+    if (/*v.IsMouseOverTimeline()*/ is_mouse_on_horizontal_scrollbar) {
+      let dz = 0;
+      if (event.deltaY > 0) {  // Scroll down, zoom out
+        dz = -0.3;
+      } else if (event.deltaY < 0) {  // Scroll up, zoom in
+        dz = 0.3;
+      }
+      v.Zoom(dz, v.MouseXToTimestamp(v.MouseState.x));
+    } else {
+      if (event.deltaY > 0) {
+        v.ScrollY(1);
+      } else if (event.deltaY < 0) {
+        v.ScrollY(-1);
+      }
+    }
+  }
+
+  ScrollY(delta) {
+    this.VisualLineStartIdx += delta;
+    if (this.VisualLineStartIdx < 0) {
+      this.VisualLineStartIdx = 0;
+    } else if (this.VisualLineStartIdx >= this.TotalVisualHeight()) {
+      this.VisualLineStartIdx = this.TotalVisualHeight() - 1;
+    }
+  }
+
+  // This function is called in Render to draw a line of Intervals.
+  // It is made into its own function for brevity in Render().
+  // It depends on too much context so it doesn't look very clean though
+  do_RenderIntervals(ctx, intervals_j, j, dy0, dy1, 
+    data_line_idx, visual_line_offset_within_data_line,
+    isAggregateSelection,
+    vars) {
+    // To reduce the number of draw calls while preserve the accuracy in
+    // the visual presentation, combine rectangles that are within 1 pixel
+    // into one
+    let last_dx_begin = LEFT_MARGIN;
+    let last_dx_end = LEFT_MARGIN; 
+
+    for (let i = 0; i < intervals_j.length; i++) {
+      let lb = intervals_j[i][0], ub = intervals_j[i][1];
+      const yoffset = intervals_j[i][4];
+      if (yoffset != visual_line_offset_within_data_line)
+        continue;
+      if (lb > ub)
+        continue;  // Unmatched (only enter & no exit timestamp)
+
+      let isHighlighted = false;
+      let durationUsec =
+          (intervals_j[i][1] - intervals_j[i][0]) * 1000000;
+      let lbub = [lb, ub];
+      if (this.IsHighlighted()) {
+        if (IsIntersected(lbub, vars.highlightedInterval)) {
+          vars.numIntersected++;
+          isHighlighted = true;
+          vars.currHighlightedReqs.push(intervals_j[i][2]);
+        }
+      }
+
+      if (ub < this.LowerBoundTime) {
+        vars.numOverflowEntriesToTheLeft++;
+        continue;
+      }
+      if (lb > this.UpperBoundTime) {
+        vars.numOverflowEntriesToTheRight++;
+        continue;
+      }
+      // Failed request
+      if (ub == undefined && lb < this.UpperBoundTime) {
+        vars.numOverflowEntriesToTheLeft++;
+        continue;
+      }
+
+      let dx0 = MapXCoord(
+              lb, LEFT_MARGIN, RIGHT_MARGIN, this.LowerBoundTime,
+              this.UpperBoundTime),
+          dx1 = MapXCoord(
+              ub, LEFT_MARGIN, RIGHT_MARGIN, this.LowerBoundTime,
+              this.UpperBoundTime);
+
+      dx0 = Math.max(dx0, LEFT_MARGIN);
+      dx1 = Math.min(dx1, RIGHT_MARGIN);
+      let dw = Math.max(0, dx1 - dx0);
+
+      if (isHighlighted) {
+        ctx.fillStyle = 'rgba(128,128,255,0.5)';
+        ctx.fillRect(dx0, dy0, dw, dy1 - dy0);
+      }
+
+      let isCurrentReqHovered = false;
+      // Intersect with mouse using pixel coordinates
+
+      // When the mouse position is within 4 pixels distance from an entry, consider
+      // the mouse to be over that entry and show the information popup
+      const X_TOLERANCE = 4;
+
+      if (vars.theHoveredReq == undefined &&
+          IsIntersectedPixelCoords(
+              [dx0 - X_TOLERANCE, dx0 + dw + X_TOLERANCE],
+              [this.MouseState.x, this.MouseState.x]) &&
+          IsIntersectedPixelCoords(
+              [dy0, dy1], [this.MouseState.y, this.MouseState.y])) {
+        ctx.fillStyle = 'rgba(255,255,0,0.5)';
+        ctx.fillRect(dx0, dy0, dw, dy1 - dy0);
+        vars.theHoveredReq = intervals_j[i][2];
+        vars.theHoveredInterval = intervals_j[i];
+        isCurrentReqHovered = true;
+      }
+
+      ctx.lineWidth = 0.5;
+
+
+      // If this request is taking too long/is quick enough, use red/green
+      let entry = HistogramThresholds[this.Titles[data_line_idx].title];
+
+      let isError = false;
+      if (intervals_j[i][3] == 'error') {
+        isError = true;
+      }
+
+      if (entry != undefined) {
+        if (entry[0][1] != undefined && durationUsec < entry[0][1]) {
+          ctx.strokeStyle = '#0F0';
+        } else if (
+            entry[1][1] != undefined && durationUsec > entry[1][1]) {
+          ctx.strokeStyle = '#A00';
+        } else {
+          ctx.strokeStyle = '#000';
+        }
+      } else {
+        ctx.strokeStyle = '#000';
+      }
+
+      const duration = intervals_j[i][1] - intervals_j[i][0];
+      if (!isNaN(duration)) {
+        if (isError) {
+          ctx.fillStyle = 'rgba(192, 128, 128, 0.8)';
+          ctx.fillRect(dx0, dy0, dw, dy1 - dy0);
+          ctx.strokeStyle = 'rgba(192, 128, 128, 1)';
+        } else {
+          ctx.fillStyle = undefined;
+          ctx.strokeStyle = '#000';
+        }
+
+        // This keeps track of the current "cluster" of requests
+        // that might visually overlap (i.e less than 1 pixel wide).
+        // This can greatly reduce overdraw and keep render time under
+        // a reasonable bound.
+        if (!ShouldShowDebugInfo()) {
+          if (dx0+dw - last_dx_begin > 1 ||
+              i == intervals_j.length - 1) {
+            ctx.strokeRect(last_dx_begin, dy0, 
+              /*dx0+dw-last_dx_begin*/
+              last_dx_end - last_dx_begin, // At least 1 pixel wide
+              dy1-dy0);
+            last_dx_begin = dx0;
+          }
+        } else {
+          ctx.strokeRect(dx0, dy0, dw, dy1 - dy0);
+        }
+        last_dx_end = dx0 + dw;
+        this.numVisibleRequests++;
+      } else {
+        // This entry has only a beginning and not an end
+        // perhaps the original method call did not return
+        if (isCurrentReqHovered) {
+          ctx.fillStyle = 'rgba(192, 192, 0, 0.8)';
+        } else {
+          ctx.fillStyle = 'rgba(255, 128, 128, 0.8)';
+        }
+        ctx.beginPath();
+        ctx.arc(dx0, (dy0 + dy1) / 2, HISTOGRAM_H * 0.17, 0, 2 * Math.PI);
+        ctx.fill();
+      }
+
+
+      // Affects whether this req will be reflected in the aggregate info
+      //     section
+      if ((isAggregateSelection == false) ||
+          (isAggregateSelection == true && isHighlighted == true)) {
+        if (!isNaN(duration)) {
+          vars.numVisibleRequestsCurrLine++;
+          vars.totalSecsCurrLine += duration;
+        } else {
+          vars.numFailedRequestsCurrLine++;
+        }
+
+        // If a histogram exists for Titles[j], process the highlighted
+        //     histogram buckets
+        if (GetHistoryHistogram()[this.Titles[data_line_idx].title] != undefined) {
+          let histogramEntry = GetHistoryHistogram()[this.Titles[data_line_idx].title];
+          let bucketInterval = (histogramEntry[1] - histogramEntry[0]) /
+              histogramEntry[2].length;
+          let bucketIndex =
+              Math.floor(
+                  (durationUsec - histogramEntry[0]) / bucketInterval) /
+              histogramEntry[2].length;
+
+          if (this.IpmiVizHistHighlighted[this.Titles[data_line_idx].title] == undefined) {
+            this.IpmiVizHistHighlighted[this.Titles[data_line_idx].title] = new Set();
+          }
+          let entry = this.IpmiVizHistHighlighted[this.Titles[data_line_idx].title];
+          entry.add(bucketIndex);
+        }
+      }
+    }  // end for (i=0 to interval_j.length-1)
+    
+    if (!ShouldShowDebugInfo()) {
+      ctx.strokeRect(last_dx_begin, dy0, 
+        /*dx0+dw-last_dx_begin*/
+        last_dx_end - last_dx_begin, // At least 1 pixel wide
+        dy1-dy0);
+    }
+  }
+
+  // For the header:
+  do_RenderHeader(ctx, header, j, dy0, dy1, 
+    data_line_idx, visual_line_offset_within_data_line,
+    isAggregateSelection,
+    vars) {
+
+    const dy = (dy0+dy1) / 2;
+    ctx.fillStyle = "rgba(192,192,255, 1)";
+
+    ctx.strokeStyle = "rgba(192,192,255, 1)"
+
+    const title_text = header.title + " (" + header.intervals_idxes.length + ")";
+    let skip_render = false;
+
+    ctx.save();
+
+    if (this.HeaderCollapsed[header.title] == false) {  // Expanded
+      const x0 = LEFT_MARGIN - LINE_HEIGHT;
+      ctx.fillRect(0, dy-LINE_HEIGHT/2, x0, LINE_HEIGHT);
+
+      ctx.beginPath();
+      ctx.moveTo(x0, dy0);
+      ctx.lineTo(x0, dy1);
+      ctx.lineTo(x0 + LINE_HEIGHT, dy1);
+      ctx.fill();
+      ctx.closePath();
+
+      ctx.beginPath();
+      ctx.lineWidth = 1.5;
+      ctx.moveTo(0, dy1);
+      ctx.lineTo(RIGHT_MARGIN, dy1);
+      ctx.stroke();
+      ctx.closePath();
+
+      ctx.fillStyle = '#003';
+      ctx.textBaseline = 'center';
+      ctx.textAlign = 'right';
+      ctx.fillText(title_text, LEFT_MARGIN - LINE_HEIGHT, dy);
+
+      // Don't draw the timelines so visual clutter is reduced
+      skip_render = true;
+    } else {
+      const x0 = LEFT_MARGIN - LINE_HEIGHT / 2;
+      ctx.fillRect(0, dy-LINE_HEIGHT/2, x0, LINE_HEIGHT);
+      
+      ctx.beginPath();
+      ctx.lineWidth = 1.5;
+      ctx.moveTo(x0, dy0);
+      ctx.lineTo(x0 + LINE_HEIGHT/2, dy);
+      ctx.lineTo(x0, dy1);
+      ctx.closePath();
+      ctx.fill();
+
+      /*
+      ctx.beginPath();
+      ctx.moveTo(0, dy);
+      ctx.lineTo(RIGHT_MARGIN, dy);
+      ctx.stroke();
+      ctx.closePath();
+      */
+
+      ctx.fillStyle = '#003';
+      ctx.textBaseline = 'center';
+      ctx.textAlign = 'right';
+      ctx.fillText(title_text, LEFT_MARGIN - LINE_HEIGHT, dy);
+    }
+
+    ctx.fillStyle = "rgba(160,120,255,0.8)";
+
+    ctx.restore();
+
+    // Draw the merged intervals
+    // Similar to drawing the actual messages in do_Render(), but no collision detection against the mouse, and no hovering tooltip processing involved
+    const merged_intervals = header.merged_intervals;
+    let dxx0 = undefined, dxx1 = undefined;
+    for (let i=0; i<merged_intervals.length; i++) {
+      const lb = merged_intervals[i][0], ub = merged_intervals[i][1], weight = merged_intervals[i][2];
+      let duration = ub-lb;
+      let duration_usec = duration * 1000000;
+      const lbub = [lb, ub];
+      
+      let isHighlighted = false;
+      if (this.IsHighlighted()) {
+        if (IsIntersected(lbub, vars.highlightedInterval)) {
+          vars.numIntersected += weight;
+          isHighlighted = true;
+        }
+      }
+
+      if (ub < this.LowerBoundTime) continue;
+      if (lb > this.UpperBoundTime) continue;
+
+      // Render only if collapsed
+      if (!skip_render) {
+        let dx0 = MapXCoord(
+          lb, LEFT_MARGIN, RIGHT_MARGIN, this.LowerBoundTime,
+          this.UpperBoundTime),
+            dx1 = MapXCoord(
+          ub, LEFT_MARGIN, RIGHT_MARGIN, this.LowerBoundTime,
+          this.UpperBoundTime);
+        dx0 = Math.max(dx0, LEFT_MARGIN);
+        dx1 = Math.min(dx1, RIGHT_MARGIN);
+        let dw = Math.max(1, dx1 - dx0);  // At least 1 pixel wide during rendering
+
+        // Draw this interval
+        //ctx.fillRect(dx0, dy0, dw, dy1-dy0);
+        if (dxx0 == undefined || dxx1 == undefined) {
+          dxx0 = dx0;
+        }
+
+        const MERGE_THRESH = 0.5;  // Pixels
+
+        let should_draw = true;
+        if (dxx1 == undefined || dx0 < dxx1 + MERGE_THRESH) should_draw = false;
+        if (i == merged_intervals.length - 1) {
+          should_draw = true;
+          dxx1 = dx1 + MERGE_THRESH;
+        }
+
+        if (should_draw) {
+          //console.log(dxx0 + ", " + dy0 + ", " + (dx1-dxx0) + ", " + LINE_HEIGHT);
+          ctx.fillRect(dxx0, dy0, dxx1-dxx0, LINE_HEIGHT);
+          dxx0 = undefined; dxx1 = undefined;
+        } else {
+          // merge
+          dxx1 = dx1 + MERGE_THRESH;
+        }
+      }
+
+      if ((isAggregateSelection == false) ||
+          (isAggregateSelection == true && isHighlighted == true)) {
+        vars.totalSecsCurrLine += duration;
+        vars.numVisibleRequestsCurrLine += weight;
+      }
+    }
+  }
+
+  Render(ctx) {
+    // Wait for initialization
+    if (this.Canvas == undefined) return;
+
+    // Update
+    let toFixedPrecision = 2;
+    const extent = this.UpperBoundTime - this.LowerBoundTime;
+    {
+      if (extent < 0.1) {
+        toFixedPrecision = 4;
+      } else if (extent < 1) {
+        toFixedPrecision = 3;
+      }
+    }
+
+    let dx = this.CurrDeltaX;
+    if (dx != 0) {
+      if (this.CurrShiftFlag) dx *= 5;
+      this.LowerBoundTime += dx * extent;
+      this.UpperBoundTime += dx * extent;
+      this.IsCanvasDirty = true;
+    }
+
+    // Hovered interval for display
+    let theHoveredReq = undefined;
+    let theHoveredInterval = undefined;
+    let currHighlightedReqs = [];
+
+    let dz = this.CurrDeltaZoom;
+    this.Zoom(dz);
+    this.UpdateAnimation();
+
+    this.LastTimeLowerBound = this.LowerBoundTime;
+    this.LastTimeUpperBound = this.UpperBoundTime;
+
+    if (this.IsCanvasDirty) {
+      this.IsCanvasDirty = false;
+      // Shorthand for HighlightedRegion.t{0,1}
+      let t0 = undefined, t1 = undefined;
+
+      // Highlight
+      let highlightedInterval = [];
+      let numIntersected =
+          0;  // How many requests intersect with highlighted area
+      if (this.IsHighlighted()) {
+        t0 = Math.min(this.HighlightedRegion.t0, this.HighlightedRegion.t1);
+        t1 = Math.max(this.HighlightedRegion.t0, this.HighlightedRegion.t1);
+        highlightedInterval = [t0, t1];
+      }
+      this.IpmiVizHistHighlighted = {};
+
+      const width = this.Canvas.width;
+      const height = this.Canvas.height;
+
+      ctx.globalCompositeOperation = 'source-over';
+      ctx.clearRect(0, 0, width, height);
+      ctx.strokeStyle = '#000';
+      ctx.fillStyle = '#000';
+      ctx.lineWidth = 1;
+
+      ctx.font = '12px Monospace';
+
+      // Highlight current line
+      if (this.MouseState.hoveredVisibleLineIndex != undefined) {
+        const hv_lidx = this.MouseState.hoveredVisibleLineIndex + this.VisualLineStartIdx;
+        if (hv_lidx >= 0 &&
+            hv_lidx < this.Titles.length) {
+          ctx.fillStyle = 'rgba(32,32,32,0.2)';
+          let dy = YBEGIN + LINE_SPACING * this.MouseState.hoveredVisibleLineIndex -
+              LINE_SPACING / 2;
+          ctx.fillRect(0, dy, RIGHT_MARGIN, LINE_SPACING);
+        }
+      }
+
+      // Draw highlighted background over time labels when the mouse is hovering over
+      // the time axis
+      ctx.fillStyle = "#FF9";
+      if (this.MouseState.hoveredSide == "top_horizontal_scrollbar") {
+        ctx.fillRect(LEFT_MARGIN, 0, RIGHT_MARGIN-LEFT_MARGIN, TOP_HORIZONTAL_SCROLLBAR_HEIGHT);
+      } else if (this.MouseState.hoveredSide == "bottom_horizontal_scrollbar") {
+        ctx.fillRect(LEFT_MARGIN, height-BOTTOM_HORIZONTAL_SCROLLBAR_HEIGHT, RIGHT_MARGIN-LEFT_MARGIN, BOTTOM_HORIZONTAL_SCROLLBAR_HEIGHT);
+      }
+
+      ctx.fillStyle = '#000';
+      // Time marks at the beginning & end of the visible range
+      ctx.textBaseline = 'bottom';
+      ctx.textAlign = 'left';
+      ctx.fillText(
+          '' + this.LowerBoundTime.toFixed(toFixedPrecision) + 's',
+          LEFT_MARGIN + 3, height);
+      ctx.textAlign = 'end';
+      ctx.fillText(
+          '' + this.UpperBoundTime.toFixed(toFixedPrecision) + 's',
+          RIGHT_MARGIN - 3, height);
+
+      ctx.textBaseline = 'top';
+      ctx.textAlign = 'left';
+      ctx.fillText(
+          '' + this.LowerBoundTime.toFixed(toFixedPrecision) + 's',
+          LEFT_MARGIN + 3, TEXT_Y0);
+      ctx.textAlign = 'right';
+      ctx.fillText(
+          '' + this.UpperBoundTime.toFixed(toFixedPrecision) + 's',
+          RIGHT_MARGIN - 3, TEXT_Y0);
+
+      let y = YBEGIN;
+      let numVisibleRequests = 0;
+
+      ctx.beginPath();
+      ctx.moveTo(LEFT_MARGIN, 0);
+      ctx.lineTo(LEFT_MARGIN, height);
+      ctx.stroke();
+
+      ctx.beginPath();
+      ctx.moveTo(RIGHT_MARGIN, 0);
+      ctx.lineTo(RIGHT_MARGIN, height);
+      ctx.stroke();
+
+      // Column Titles
+      ctx.fillStyle = '#000';
+      let columnTitle = '(All requests)';
+      if (this.GroupByStr.length > 0) {
+        columnTitle = this.GroupByStr;
+      }
+      ctx.textAlign = 'right';
+      ctx.textBaseline = 'top';
+      // Split into lines
+      {
+        let lines = this.ToLines(columnTitle, this.TitleDispLengthLimit)
+        for (let i = 0; i < lines.length; i++) {
+          ctx.fillText(lines[i], LEFT_MARGIN - 3, 3 + i * LINE_HEIGHT);
+        }
+      }
+
+      if (this.IsTimeDistributionEnabled) {
+        // "Histogram" title
+        ctx.fillStyle = '#000';
+        ctx.textBaseline = 'top';
+        ctx.textAlign = 'center';
+        ctx.fillText('Time Distribution', HISTOGRAM_X, TEXT_Y0);
+
+        ctx.textAlign = 'right'
+        ctx.fillText('In dataset /', HISTOGRAM_X, TEXT_Y0 + LINE_SPACING - 2);
+
+        ctx.fillStyle = '#00F';
+
+        ctx.textAlign = 'left'
+        if (this.IsHighlighted()) {
+          ctx.fillText(
+              ' In selection', HISTOGRAM_X, TEXT_Y0 + LINE_SPACING - 2);
+        }
+        else {
+          ctx.fillText(' In viewport', HISTOGRAM_X, TEXT_Y0 + LINE_SPACING - 2);
+        }
+      }
+
+      ctx.fillStyle = '#000';
+
+      // Time Axis Breaks
+      const breakWidths = [
+        86400,  10800,  3600,    1800,    1200,   600,   300,   120,
+        60,     30,     10,      5,       2,      1,     0.5,   0.2,
+        0.1,    0.05,   0.02,    0.01,    0.005,  0.002, 0.001, 0.0005,
+        0.0002, 0.0001, 0.00005, 0.00002, 0.00001
+      ];
+      const BreakDrawLimit = 1000;  // Only draw up to this many grid lines
+
+      let bidx = 0;
+      while (bidx < breakWidths.length &&
+             breakWidths[bidx] > this.UpperBoundTime - this.LowerBoundTime) {
+        bidx++;
+      }
+      let breakWidth = breakWidths[bidx + 1];
+      if (bidx < breakWidths.length) {
+        let t2 = 0;  // Cannot name as "t0" otherwise clash
+        bidx = 0;
+        while (bidx < breakWidths.length) {
+          while (t2 + breakWidths[bidx] < this.LowerBoundTime) {
+            t2 += breakWidths[bidx];
+          }
+          if (t2 + breakWidths[bidx] >= this.LowerBoundTime &&
+              t2 + breakWidths[bidx] <= this.UpperBoundTime) {
+            break;
+          }
+          bidx++;
+        }
+        let draw_count = 0;
+        if (bidx < breakWidths.length) {
+          for (; t2 < this.UpperBoundTime; t2 += breakWidth) {
+            if (t2 > this.LowerBoundTime) {
+              ctx.beginPath();
+              let dx = MapXCoord(
+                  t2, LEFT_MARGIN, RIGHT_MARGIN, this.LowerBoundTime,
+                  this.UpperBoundTime);
+              ctx.strokeStyle = '#C0C0C0';
+              ctx.moveTo(dx, 0);
+              ctx.lineTo(dx, height);
+              ctx.stroke();
+              ctx.closePath();
+              ctx.fillStyle = '#C0C0C0';
+
+              ctx.textAlign = 'left';
+              ctx.textBaseline = 'bottom';
+              let label2 = t2.toFixed(toFixedPrecision) + 's';
+              let w = ctx.measureText(label2).width;
+              if (dx + w > RIGHT_MARGIN) ctx.textAlign = 'right';
+              ctx.fillText(label2, dx, height);
+
+              ctx.textBaseline = 'top';
+              ctx.fillText(label2, dx, TEXT_Y0);
+
+              draw_count++;
+              if (draw_count > BreakDrawLimit) break;
+            }
+          }
+        }
+      }
+
+      // Whether we aggregate selected requests or visible requests
+      let isAggregateSelection = false;
+      if (this.IsHighlighted()) isAggregateSelection = true;
+      let numVisibleRequestsPerLine = {}; // DataLineIndex -> Count
+      let numFailedRequestsPerLine = {};
+      let totalSecondsPerLine = {};
+
+      // Range of Titles that were displayed
+      let title_start_idx = this.VisualLineStartIdx, title_end_idx = title_start_idx;
+
+      const tvh = this.TotalVisualHeight();
+
+      // This is used to handle Intervals that have overlapping entries
+      let last_data_line_idx = -999;//this.VisualLineIndexToDataLineIndex(this.VisualLineStartIdx);
+
+      // 'j' iterates over the "visual rows" that need to be displayed.
+      // A "visual row" might be one of:
+      // 1. a "header" line
+      // 2. an actual row of data (in the Intervals variable)
+      for (let j = this.VisualLineStartIdx; j < tvh; j++) {
+        const tmp = this.VisualLineIndexToDataLineIndex(j);
+        if (tmp == undefined) break;
+        const data_line_idx = tmp[0];
+        const visual_line_offset_within_data_line = tmp[1];
+       
+        const is_different_data_line = (data_line_idx != last_data_line_idx);
+        last_data_line_idx = data_line_idx;
+
+        if (is_different_data_line && data_line_idx != -999) { // Only draw line title and histogram per data line index not visual line index
+          ctx.textBaseline = 'middle';
+          ctx.textAlign = 'right';
+          let desc_width = 0;
+          if (NetFnCmdToDescription[this.Titles[data_line_idx].title] != undefined) {
+            let desc = ' (' + NetFnCmdToDescription[this.Titles[data_line_idx].title] + ')';
+            desc_width = ctx.measureText(desc).width;
+            ctx.fillStyle = '#888';  // Grey
+            ctx.fillText(desc, LEFT_MARGIN - 3, y);
+          }
+
+
+          // Plot histogram
+          if (this.IsTimeDistributionEnabled == true) {
+            const t = this.Titles[data_line_idx].title;
+            if (GetHistoryHistogram()[t] != undefined) {
+              if (this.IpmiVizHistogramImageData[t] == undefined) {
+                let tmp = RenderHistogramForImageData(ctx, t);
+                this.IpmiVizHistogramImageData[t] = tmp[0];
+                HistogramThresholds[t] = tmp[1];
+              }
+              this.RenderHistogram(ctx, t, HISTOGRAM_X, y);
+              ctx.textAlignment = 'right';
+            } else {
+            }
+          }
+
+          // If is HEADER: do not draw here, darw in do_RenderHeader()
+          if (this.Titles[data_line_idx].header == false) {
+            ctx.textAlignment = 'right';
+            ctx.textBaseline = 'middle';
+            ctx.fillStyle = '#000000';  // Revert to Black
+            ctx.strokeStyle = '#000000';
+            let tj_draw = this.Titles[data_line_idx].title;
+            const title_disp_length_limit = this.GetTitleWidthLimit();
+            if (tj_draw != undefined && tj_draw.length > title_disp_length_limit) {
+              tj_draw = tj_draw.substr(0, title_disp_length_limit) + '...'
+            }
+            ctx.fillText(tj_draw, LEFT_MARGIN - 3 - desc_width, y);
+          }
+        } else if (is_different_data_line && data_line_idx == -999) {
+          continue;
+        }
+
+        let numOverflowEntriesToTheLeft = 0;  // #entries below the lower bound
+        let numOverflowEntriesToTheRight =
+            0;                               // #entries beyond the upper bound
+        let numVisibleRequestsCurrLine = 0;  // #entries visible
+        let totalSecsCurrLine = 0;           // Total duration in seconds
+        let numFailedRequestsCurrLine = 0;
+
+        const intervals_idxes = this.Titles[data_line_idx].intervals_idxes;
+        
+        let intervals_j = undefined;
+        if (intervals_idxes.length == 1) {
+          intervals_j = this.Intervals[intervals_idxes[0]];
+        }
+
+        // Draw the contents in the set of intervals
+        // The drawing method depends on whether this data line is a header or not
+        
+        // Save the context for reference for the rendering routines
+        let vars = {
+          "theHoveredReq": theHoveredReq,
+          "theHoveredInterval": theHoveredInterval,
+          "numIntersected": numIntersected,
+          "numOverflowEntriesToTheLeft": numOverflowEntriesToTheLeft,
+          "numOverflowEntriesToTheRight": numOverflowEntriesToTheRight,
+          "currHighlightedReqs": currHighlightedReqs,
+          "totalSecondsPerLine": totalSecondsPerLine,
+          "highlightedInterval": highlightedInterval,
+          "numVisibleRequestsCurrLine": numVisibleRequestsCurrLine,
+          "totalSecsCurrLine": totalSecsCurrLine,
+        }  // Emulate a reference
+
+        let dy0 = y - LINE_HEIGHT / 2, dy1 = y + LINE_HEIGHT / 2;
+        if (this.Titles[data_line_idx].header == false) {
+          if (intervals_j != undefined) {
+            this.do_RenderIntervals(ctx, intervals_j, j, dy0, dy1,
+              data_line_idx, visual_line_offset_within_data_line, isAggregateSelection, vars);
+          }
+        } else {
+          this.do_RenderHeader(ctx, this.Titles[data_line_idx],
+            j, dy0, dy1,
+            data_line_idx, visual_line_offset_within_data_line, isAggregateSelection, vars);
+        }
+
+        // Update the context variables with updated values
+        theHoveredReq = vars.theHoveredReq;
+        theHoveredInterval = vars.theHoveredInterval;
+        numIntersected = vars.numIntersected;
+        numOverflowEntriesToTheLeft = vars.numOverflowEntriesToTheLeft;
+        numOverflowEntriesToTheRight = vars.numOverflowEntriesToTheRight;
+        currHighlightedReqs = vars.currHighlightedReqs;
+        totalSecondsPerLine = vars.totalSecondsPerLine;
+        highlightedInterval = vars.highlightedInterval;
+        numVisibleRequestsCurrLine = vars.numVisibleRequestsCurrLine;
+        totalSecsCurrLine = vars.totalSecsCurrLine;
+
+        // Triangle markers for entries outside of the viewport
+        {
+          const PAD = 2, H = LINE_SPACING;
+          if (this.MouseState.hoveredVisibleLineIndex + this.VisualLineStartIdx == data_line_idx &&
+              this.MouseState.hoveredSide == 'left') {
+            ctx.fillStyle = '#0000FF';
+          } else {
+            ctx.fillStyle = 'rgba(128,128,0,0.5)';
+          }
+          if (numOverflowEntriesToTheLeft > 0) {
+            ctx.beginPath();
+            ctx.moveTo(LEFT_MARGIN + PAD + H / 2, y - H / 2);
+            ctx.lineTo(LEFT_MARGIN + PAD, y);
+            ctx.lineTo(LEFT_MARGIN + PAD + H / 2, y + H / 2);
+            ctx.closePath();
+            ctx.fill();
+            ctx.textAlign = 'left';
+            ctx.textBaseline = 'center';
+            ctx.fillText(
+                '+' + numOverflowEntriesToTheLeft,
+                LEFT_MARGIN + 2 * PAD + H / 2, y);
+          }
+
+          if (this.MouseState.hoveredVisibleLineIndex + this.VisualLineStartIdx == j &&
+              this.MouseState.hoveredSide == 'right') {
+            ctx.fillStyle = '#0000FF';
+          } else {
+            ctx.fillStyle = 'rgba(128,128,0,0.5)';
+          }
+          if (numOverflowEntriesToTheRight > 0) {
+            ctx.beginPath();
+            ctx.moveTo(RIGHT_MARGIN - PAD - H / 2, y - H / 2);
+            ctx.lineTo(RIGHT_MARGIN - PAD, y);
+            ctx.lineTo(RIGHT_MARGIN - PAD - H / 2, y + H / 2);
+            ctx.closePath();
+            ctx.fill();
+            ctx.textAlign = 'right';
+            ctx.fillText(
+                '+' + numOverflowEntriesToTheRight,
+                RIGHT_MARGIN - 2 * PAD - H / 2, y);
+          }
+        }
+        y = y + LINE_SPACING;
+
+        numVisibleRequestsPerLine[data_line_idx] = numVisibleRequestsCurrLine;
+        numFailedRequestsPerLine[data_line_idx] = numFailedRequestsCurrLine;
+        totalSecondsPerLine[data_line_idx] = totalSecsCurrLine;
+
+        title_end_idx = j;
+        if (y > height) break;
+      }
+
+      {
+        let nbreaks = this.TotalVisualHeight();
+        // Draw a scroll bar on the left
+        if (!(title_start_idx == 0 && title_end_idx == nbreaks - 1)) {
+
+          const y0 = title_start_idx * height / nbreaks;
+          const y1 = (1 + title_end_idx) * height / nbreaks;
+
+          let highlighted = false;
+          const THRESH = 8;
+          if (this.MouseState.IsDraggingScrollBar()) {
+            highlighted = true;
+          }
+          this.ScrollBarState.highlighted = highlighted;
+
+          // If not dragging, let title_start_idx drive y0 and y1, else let the
+          // user's input drive y0 and y1 and title_start_idx
+          if (!this.MouseState.IsDraggingScrollBar()) {
+            this.ScrollBarState.y0 = y0;
+            this.ScrollBarState.y1 = y1;
+          }
+
+          if (highlighted) {
+            ctx.fillStyle = "#FF3";
+          } else {
+            ctx.fillStyle = this.AccentColor;
+          }
+          ctx.fillRect(0, y0, SCROLL_BAR_WIDTH, y1 - y0);
+
+        } else {
+          this.ScrollBarState.y0 = undefined;
+          this.ScrollBarState.y1 = undefined;
+          this.ScrollBarState.highlighted = false;
+        }
+      }
+
+      // Draw highlighted sections for the histograms
+      if (this.IsTimeDistributionEnabled) {
+        y = YBEGIN;
+        for (let j = this.TitleStartIdx; j < this.Intervals.length; j++) {
+          if (this.IpmiVizHistHighlighted[this.Titles[data_line_idx].title] != undefined) {
+            let entry = HistogramThresholds[this.Titles[data_line_idx].title];
+            const theSet =
+                Array.from(this.IpmiVizHistHighlighted[this.Titles[data_line_idx].title]);
+            for (let i = 0; i < theSet.length; i++) {
+              bidx = theSet[i];
+              if (entry != undefined) {
+                if (bidx < entry[0][0]) {
+                  if (bidx < 0) {
+                    bidx = 0;
+                  }
+                  ctx.fillStyle = 'rgba(0, 255, 0, 0.3)';
+                } else if (bidx > entry[1][0]) {
+                  if (bidx > 1) {
+                    bidx = 1;
+                  }
+                  ctx.fillStyle = 'rgba(255,0,0,0.3)';
+                } else {
+                  ctx.fillStyle = 'rgba(0,0,255,0.3)';
+                }
+              } else {
+                ctx.fillStyle = 'rgba(0,0,255,0.3)';
+              }
+              const dx = HISTOGRAM_X - HISTOGRAM_W / 2 + HISTOGRAM_W * bidx;
+
+              const r = HISTOGRAM_H * 0.17;
+              ctx.beginPath();
+              ctx.ellipse(dx, y, r, r, 0, 0, 3.14159 * 2);
+              ctx.fill();
+            }
+          }
+          y += LINE_SPACING;
+        }
+      }
+
+      // Render number of visible requests versus totals
+      ctx.textAlign = 'left';
+      ctx.textBaseline = 'top';
+      let totalOccs = 0, totalSecs = 0;
+      if (this.IsHighlighted()) {
+        ctx.fillStyle = '#00F';
+        ctx.fillText('# / time', 3, TEXT_Y0);
+        ctx.fillText('in selection', 3, TEXT_Y0 + LINE_SPACING - 2);
+      } else {
+        ctx.fillStyle = '#000';
+        ctx.fillText('# / time', 3, TEXT_Y0);
+        ctx.fillText('in viewport', 3, TEXT_Y0 + LINE_SPACING - 2);
+      }
+
+      let timeDesc = '';
+      ctx.textBaseline = 'middle';
+      last_data_line_idx = -999;
+
+      for (let j = this.VisualLineStartIdx, i = 0;
+               j < tvh && (YBEGIN + i*LINE_SPACING)<height; j++, i++) {
+        const x = this.VisualLineIndexToDataLineIndex(j);
+        if (x == undefined) break;
+        const data_line_idx = x[0];
+        if (data_line_idx == undefined) break;
+        if (data_line_idx != last_data_line_idx) {
+          let y1 = YBEGIN + LINE_SPACING * (i);
+          let totalSeconds = totalSecondsPerLine[data_line_idx];
+          if (totalSeconds < 1) {
+            timeDesc = (totalSeconds * 1000.0).toFixed(toFixedPrecision) + 'ms';
+          } else {
+            timeDesc = totalSeconds.toFixed(toFixedPrecision) + 's';
+          }
+
+          const n0 = numVisibleRequestsPerLine[data_line_idx];
+          const n1 = numFailedRequestsPerLine[data_line_idx];
+          let txt = '';
+          if (n1 > 0) {
+            txt = '' + n0 + '+' + n1 + ' / ' + timeDesc;
+          } else {
+            txt = '' + n0 + ' / ' + timeDesc;
+          }
+
+          const tw = ctx.measureText(txt).width;
+          const PAD = 8;
+
+          ctx.fillStyle = '#000';
+          ctx.fillText(txt, 3, y1);
+          totalOccs += numVisibleRequestsPerLine[data_line_idx];
+          totalSecs += totalSeconds;
+        }
+        last_data_line_idx = data_line_idx;
+      }
+
+      // This does not get displayed correctly, so disabling for now
+      //timeDesc = '';
+      //if (totalSecs < 1) {
+      //  timeDesc = '' + (totalSecs * 1000).toFixed(toFixedPrecision) + 'ms';
+      //} else {
+      //  timeDesc = '' + totalSecs.toFixed(toFixedPrecision) + 's';
+      //}
+
+      //ctx.fillText('Sum:', 3, y + 2 * LINE_SPACING);
+      //ctx.fillText('' + totalOccs + ' / ' + timeDesc, 3, y + 3 * LINE_SPACING);
+
+      // Update highlighted requests
+      if (this.IsHighlightDirty) {
+        this.HighlightedRequests = currHighlightedReqs;
+        this.IsHighlightDirty = false;
+
+        // Todo: This callback will be different for the DBus pane
+        OnHighlightedChanged(HighlightedRequests);
+      }
+
+      // Render highlight statistics
+      if (this.IsHighlighted()) {
+        ctx.fillStyle = 'rgba(128,128,255,0.5)';
+        let x0 = MapXCoord(
+            t0, LEFT_MARGIN, RIGHT_MARGIN, this.LowerBoundTime,
+            this.UpperBoundTime);
+        let x1 = MapXCoord(
+            t1, LEFT_MARGIN, RIGHT_MARGIN, this.LowerBoundTime,
+            this.UpperBoundTime);
+        ctx.fillRect(x0, 0, x1 - x0, height);
+
+        let label0 = '' + t0.toFixed(toFixedPrecision) + 's';
+        let label1 = '' + t1.toFixed(toFixedPrecision) + 's';
+        let width0 = ctx.measureText(label0).width;
+        let width1 = ctx.measureText(label1).width;
+        let dispWidth = x1 - x0;
+        // Draw time marks outside or inside?
+        ctx.fillStyle = '#0000FF';
+        ctx.textBaseline = 'top';
+        if (dispWidth > width0 + width1) {
+          ctx.textAlign = 'left';
+          ctx.fillText(label0, x0, LINE_SPACING + TEXT_Y0);
+          ctx.textAlign = 'right';
+          ctx.fillText(label1, x1, LINE_SPACING + TEXT_Y0);
+        } else {
+          ctx.textAlign = 'right';
+          ctx.fillText(label0, x0, LINE_SPACING + TEXT_Y0);
+          ctx.textAlign = 'left';
+          ctx.fillText(label1, x1, LINE_SPACING + TEXT_Y0);
+        }
+
+        // This was calculated earlier
+        ctx.textAlign = 'center';
+        label1 = 'Duration: ' + (t1 - t0).toFixed(toFixedPrecision) + 's';
+        ctx.fillText(label1, (x0 + x1) / 2, height - LINE_SPACING * 2);
+      }
+
+      // Hovering cursor
+      // Only draw when the mouse is not over any hotizontal scroll bar
+      let should_hide_cursor = false;
+
+      if (this.MouseState.hoveredSide == "top_horizontal_scrollbar" ||
+          this.MouseState.hoveredSide == "bottom_horizontal_scrollbar") {
+        should_hide_cursor = true;
+      }
+      this.linked_views.forEach((v) => {
+        if (v.MouseState.hoveredSide == "top_horizontal_scrollbar" ||
+            v.MouseState.hoveredSide == "bottom_horizontal_scrollbar") {
+          should_hide_cursor = true;
+        }
+      })
+
+      if (this.MouseState.hovered == true &&
+          this.MouseState.hoveredSide == undefined &&
+          should_hide_cursor == false) {
+        ctx.beginPath();
+        ctx.strokeStyle = '#0000FF';
+        ctx.lineWidth = 1;
+        if (this.IsHighlighted()) {
+          ctx.moveTo(this.MouseState.x, 0);
+          ctx.lineTo(this.MouseState.x, height);
+        } else {
+          ctx.moveTo(this.MouseState.x, LINE_SPACING * 2);
+          ctx.lineTo(this.MouseState.x, height - LINE_SPACING * 2);
+        }
+        ctx.stroke();
+
+        if (this.IsHighlighted() == false) {
+          let dispWidth = this.MouseState.x - LEFT_MARGIN;
+          let label = '' +
+              this.MouseXToTimestamp(this.MouseState.x)
+                  .toFixed(toFixedPrecision) +
+              's';
+          let width0 = ctx.measureText(label).width;
+          ctx.fillStyle = '#0000FF';
+          ctx.textBaseline = 'bottom';
+          ctx.textAlign = 'center';
+          ctx.fillText(label, this.MouseState.x, height - LINE_SPACING);
+          ctx.textBaseline = 'top';
+          ctx.fillText(label, this.MouseState.x, LINE_SPACING + TEXT_Y0);
+        }
+      }
+
+      // Tooltip box next to hovered entry
+      if (theHoveredReq !== undefined) {
+        this.RenderToolTip(
+            ctx, theHoveredReq, theHoveredInterval, toFixedPrecision, height);
+      }
+    }  // End IsCanvasDirty
+  }
+};
+
+// The extended classes have their own way of drawing popups for hovered entries
+class IPMITimelineView extends TimelineView {
+  RenderToolTip(
+      ctx, theHoveredReq, theHoveredInterval, toFixedPrecision, height) {
+    if (theHoveredReq == undefined) {
+      return;
+    }
+    const PAD = 2, DELTA_Y = 14;
+
+    let labels = [];
+    let netFn = theHoveredReq[0];
+    let cmd = theHoveredReq[1];
+    let t0 = theHoveredInterval[0];
+    let t1 = theHoveredInterval[1];
+
+    labels.push('Netfn and CMD : (' + netFn + ', ' + cmd + ')');
+    let key = netFn + ', ' + cmd;
+
+    if (NetFnCmdToDescription[key] != undefined) {
+      labels.push('Description   : ' + NetFnCmdToDescription[key]);
+    }
+
+    if (theHoveredReq.offset != undefined) {
+      labels.push('Offset      : ' + theHoveredReq.offset);
+    }
+
+    let req = theHoveredReq[4];
+    labels.push('Request Data  : ' + req.length + ' bytes');
+    if (req.length > 0) {
+      labels.push('Hex   : ' + ToHexString(req, '', ' '));
+      labels.push('ASCII : ' + ToASCIIString(req));
+    }
+    let resp = theHoveredReq[5];
+    labels.push('Response Data : ' + theHoveredReq[5].length + ' bytes');
+    if (resp.length > 0) {
+      labels.push('Hex   : ' + ToHexString(resp, '', ' '));
+      labels.push('ASCII : ' + ToASCIIString(resp));
+    }
+    labels.push('Start         : ' + t0.toFixed(toFixedPrecision) + 's');
+    labels.push('End           : ' + t1.toFixed(toFixedPrecision) + 's');
+    labels.push('Duration      : ' + ((t1 - t0) * 1000).toFixed(3) + 'ms');
+
+
+    let w = 1, h = LINE_SPACING * labels.length + 2 * PAD;
+    for (let i = 0; i < labels.length; i++) {
+      w = Math.max(w, ctx.measureText(labels[i]).width);
+    }
+    let dy = this.MouseState.y + DELTA_Y;
+    if (dy + h > height) {
+      dy = height - h;
+    }
+    let dx = this.MouseState.x;
+    if (RIGHT_MARGIN - dx < w) dx -= (w + 2 * PAD);
+
+    ctx.fillStyle = 'rgba(0,0,0,0.5)';
+    ctx.fillRect(dx, dy, w + 2 * PAD, h);
+
+    ctx.textAlign = 'left';
+    ctx.textBaseline = 'middle';
+    ctx.fillStyle = '#FFFFFF';
+    for (let i = 0; i < labels.length; i++) {
+      ctx.fillText(
+          labels[i], dx + PAD, dy + PAD + i * LINE_SPACING + LINE_SPACING / 2);
+    }
+  }
+};
+
+class DBusTimelineView extends TimelineView {
+  RenderToolTip(
+      ctx, theHoveredReq, theHoveredInterval, toFixedPrecision, height) {
+    if (theHoveredReq == undefined) {
+      return;
+    }
+    const PAD = 2, DELTA_Y = 14;
+
+    let labels = [];
+    let msg_type = theHoveredReq[0];
+    let serial = theHoveredReq[2];
+    let sender = theHoveredReq[3];
+    let destination = theHoveredReq[4];
+    let path = theHoveredReq[5];
+    let iface = theHoveredReq[6];
+    let member = theHoveredReq[7];
+
+    let t0 = theHoveredInterval[0];
+    let t1 = theHoveredInterval[1];
+
+    labels.push('Message type: ' + msg_type);
+    labels.push('Serial      : ' + serial);
+    labels.push('Sender      : ' + sender);
+    labels.push('Destination : ' + destination);
+    labels.push('Path        : ' + path);
+    labels.push('Interface   : ' + iface);
+    labels.push('Member      : ' + member);
+
+    let w = 1, h = LINE_SPACING * labels.length + 2 * PAD;
+    for (let i = 0; i < labels.length; i++) {
+      w = Math.max(w, ctx.measureText(labels[i]).width);
+    }
+    let dy = this.MouseState.y + DELTA_Y;
+    if (dy + h > height) {
+      dy = height - h;
+    }
+    let dx = this.MouseState.x;
+    if (RIGHT_MARGIN - dx < w) dx -= (w + 2 * PAD);
+
+    ctx.fillStyle = 'rgba(0,0,0,0.5)';
+    ctx.fillRect(dx, dy, w + 2 * PAD, h);
+
+    ctx.textAlign = 'left';
+    ctx.textBaseline = 'middle';
+    ctx.fillStyle = '#FFFFFF';
+    for (let i = 0; i < labels.length; i++) {
+      ctx.fillText(
+          labels[i], dx + PAD, dy + PAD + i * LINE_SPACING + LINE_SPACING / 2);
+    }
+  }
+};
+
+class BoostASIOHandlerTimelineView extends TimelineView {
+  RenderToolTip(
+      ctx, theHoveredReq, theHoveredInterval, toFixedPrecision, height) {
+    if (theHoveredReq == undefined) {
+      return;
+    }
+    const PAD = 2, DELTA_Y = 14;
+
+    let labels = [];
+    let create_time = theHoveredReq[2];
+    let enter_time = theHoveredReq[3];
+    let exit_time = theHoveredReq[4];
+    let desc = theHoveredReq[5];
+
+    let t0 = theHoveredInterval[0];
+    let t1 = theHoveredInterval[1];
+
+    labels.push('Creation time: ' + create_time);
+    labels.push('Entry time   : ' + enter_time);
+    labels.push('Exit time    : ' + exit_time);
+    labels.push('Creation->Entry : ' + (enter_time - create_time));
+    labels.push('Entry->Exit     : ' + (exit_time - enter_time));
+    labels.push('Description  : ' + desc);
+
+    let w = 1, h = LINE_SPACING * labels.length + 2 * PAD;
+    for (let i = 0; i < labels.length; i++) {
+      w = Math.max(w, ctx.measureText(labels[i]).width);
+    }
+    let dy = this.MouseState.y + DELTA_Y;
+    if (dy + h > height) {
+      dy = height - h;
+    }
+    let dx = this.MouseState.x;
+    if (RIGHT_MARGIN - dx < w) dx -= (w + 2 * PAD);
+
+    ctx.fillStyle = 'rgba(0,0,0,0.5)';
+    ctx.fillRect(dx, dy, w + 2 * PAD, h);
+
+    ctx.textAlign = 'left';
+    ctx.textBaseline = 'middle';
+    ctx.fillStyle = '#FFFFFF';
+    for (let i = 0; i < labels.length; i++) {
+      ctx.fillText(
+          labels[i], dx + PAD, dy + PAD + i * LINE_SPACING + LINE_SPACING / 2);
+    }
+  }
+}