[go: nahoru, domu]

Add chrome://ukm debug ui

BUG=843181

Change-Id: I8b43a72535dd09a175df731a94dc8e216374e7ff
Reviewed-on: https://chromium-review.googlesource.com/c/1306660
Commit-Queue: Nik Bhagat <nikunjb@chromium.org>
Reviewed-by: Jochen Eisinger <jochen@chromium.org>
Reviewed-by: calamity <calamity@chromium.org>
Reviewed-by: Steven Holte <holte@chromium.org>
Reviewed-by: Mikel Astiz <mastiz@chromium.org>
Cr-Commit-Position: refs/heads/master@{#606660}
diff --git a/BUILD.gn b/BUILD.gn
index 68fa462..e4d8f64d 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -1223,6 +1223,7 @@
       "chrome/browser/resources:closure_compile",
       "content/browser/resources:closure_compile",
       "ui/webui/resources:closure_compile",
+      "components/ukm/debug:closure_compile",
     ]
     if (is_chromeos) {
       data_deps += [ "ui/file_manager:closure_compile" ]
diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/sync/UkmTest.java b/chrome/android/javatests/src/org/chromium/chrome/browser/sync/UkmTest.java
index 43bee7a..134ef80 100644
--- a/chrome/android/javatests/src/org/chromium/chrome/browser/sync/UkmTest.java
+++ b/chrome/android/javatests/src/org/chromium/chrome/browser/sync/UkmTest.java
@@ -69,8 +69,8 @@
     public boolean isUkmEnabled(Tab normalTab) throws Exception {
         String state = getElementContent(normalTab, "state");
         Assert.assertTrue(
-                "UKM state: " + state, state.equals("\"True\"") || state.equals("\"False\""));
-        return state.equals("\"True\"");
+                "UKM state: " + state, state.equals("\"ENABLED\"") || state.equals("\"DISABLED\""));
+        return state.equals("\"ENABLED\"");
     }
 
     public String getUkmClientId(Tab normalTab) throws Exception {
diff --git a/chrome/browser/browser_resources.grd b/chrome/browser/browser_resources.grd
index e726146..2a91fce 100644
--- a/chrome/browser/browser_resources.grd
+++ b/chrome/browser/browser_resources.grd
@@ -158,6 +158,7 @@
       <include name="IDR_DOWNLOAD_INTERNALS_VISUALS_JS" file="resources\download_internals\download_internals_visuals.js" type="BINDATA" compress="gzip" />
       <include name="IDR_UKM_INTERNALS_HTML" file="../../components/ukm/debug/ukm_internals.html" flattenhtml="true" allowexternalscript="true" compress="gzip" type="BINDATA" />
       <include name="IDR_UKM_INTERNALS_JS" file="../../components/ukm/debug/ukm_internals.js" flattenhtml="true" compress="gzip" type="BINDATA" />
+      <include name="IDR_UKM_INTERNALS_CSS" file="../../components/ukm/debug/ukm_internals.css" flattenhtml="true" compress="gzip" type="BINDATA" />
       <if expr="not is_android">
         <include name="IDR_MD_DOWNLOADS_1X_INCOGNITO_MARKER_PNG" file="resources\md_downloads\1x\incognito_marker.png" type="BINDATA" />
         <include name="IDR_MD_DOWNLOADS_2X_INCOGNITO_MARKER_PNG" file="resources\md_downloads\2x\incognito_marker.png" type="BINDATA" />
diff --git a/chrome/browser/ui/webui/ukm/ukm_internals_ui.cc b/chrome/browser/ui/webui/ukm/ukm_internals_ui.cc
index 0b69b7b..1823905 100644
--- a/chrome/browser/ui/webui/ukm/ukm_internals_ui.cc
+++ b/chrome/browser/ui/webui/ukm/ukm_internals_ui.cc
@@ -29,6 +29,7 @@
       content::WebUIDataSource::Create(chrome::kChromeUIUkmHost);
 
   source->AddResourcePath("ukm_internals.js", IDR_UKM_INTERNALS_JS);
+  source->AddResourcePath("ukm_internals.css", IDR_UKM_INTERNALS_CSS);
   source->SetDefaultResource(IDR_UKM_INTERNALS_HTML);
   source->UseGzip();
   return source;
diff --git a/components/ukm/debug/.eslintrc.js b/components/ukm/debug/.eslintrc.js
new file mode 100644
index 0000000..baeb4a6c
--- /dev/null
+++ b/components/ukm/debug/.eslintrc.js
@@ -0,0 +1,11 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+module.exports = {
+  'env': {'browser': true,  'es6': true,},
+  'rules': {
+    'no-var': 'error',
+  },
+};
+
diff --git a/components/ukm/debug/PRESUBMIT.py b/components/ukm/debug/PRESUBMIT.py
new file mode 100644
index 0000000..2bc036d
--- /dev/null
+++ b/components/ukm/debug/PRESUBMIT.py
@@ -0,0 +1,20 @@
+# Copyright 2018 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+
+def _CommonChecks(input_api, output_api):
+  import web_dev_style.presubmit_support
+  return (
+      web_dev_style.presubmit_support.CheckStyleESLint(input_api, output_api) +
+      input_api.canned_checks.CheckPatchFormatted(
+          input_api, output_api, check_js=True))
+
+
+def CheckChangeOnUpload(input_api, output_api):
+  return _CommonChecks(input_api, output_api)
+
+
+def CheckChangeOnCommit(input_api, output_api):
+  return _CommonChecks(input_api, output_api)
+
diff --git a/components/ukm/debug/ukm_debug_data_extractor.cc b/components/ukm/debug/ukm_debug_data_extractor.cc
index 4877914..f26e9e4 100644
--- a/components/ukm/debug/ukm_debug_data_extractor.cc
+++ b/components/ukm/debug/ukm_debug_data_extractor.cc
@@ -20,6 +20,8 @@
 
 namespace {
 
+static const uint64_t BIT_FILTER_LAST32 = 0xffffffffULL;
+
 struct SourceData {
   UkmSource* source;
   std::vector<mojom::UkmEntry*> entries;
@@ -38,8 +40,8 @@
 
   const auto it = decode_map.find(entry.event_hash);
   if (it == decode_map.end()) {
-    entry_value.SetKey("name",
-                       base::Value(static_cast<double>(entry.event_hash)));
+    entry_value.SetKey(
+        "name", UkmDebugDataExtractor::UInt64AsPairOfInt(entry.event_hash));
   } else {
     entry_value.SetKey("name", base::Value(it->second.name));
 
@@ -49,8 +51,8 @@
       base::DictionaryValue metric_value;
       metric_value.SetKey("name",
                           base::Value(GetName(it->second, metric.first)));
-      metric_value.SetKey("value",
-                          base::Value(static_cast<double>(metric.second)));
+      metric_value.SetKey(
+          "value", UkmDebugDataExtractor::UInt64AsPairOfInt(metric.second));
       metrics_list_storage->push_back(std::move(metric_value));
     }
     entry_value.SetKey("metrics", std::move(metrics_list));
@@ -65,6 +67,17 @@
 UkmDebugDataExtractor::~UkmDebugDataExtractor() = default;
 
 // static
+base::Value UkmDebugDataExtractor::UInt64AsPairOfInt(uint64_t v) {
+  // Convert int64_t to pair of int. Passing int64_t in base::Value is not
+  // supported. The pair of int will be passed as a ListValue.
+  base::Value::ListStorage int_pair;
+  int_pair.push_back(
+      base::Value(static_cast<int>((v >> 32) & BIT_FILTER_LAST32)));
+  int_pair.push_back(base::Value(static_cast<int>(v & BIT_FILTER_LAST32)));
+  return base::Value(int_pair);
+}
+
+// static
 base::Value UkmDebugDataExtractor::GetStructuredData(
     const UkmService* ukm_service) {
   if (!ukm_service)
@@ -72,10 +85,10 @@
 
   base::DictionaryValue ukm_data;
   ukm_data.SetKey("state", base::Value(ukm_service->recording_enabled_));
-  ukm_data.SetKey("client_id",
-                  base::Value(static_cast<double>(ukm_service->client_id_)));
+  ukm_data.SetKey("client_id", UkmDebugDataExtractor::UInt64AsPairOfInt(
+                                   ukm_service->client_id_));
   ukm_data.SetKey("session_id",
-                  base::Value(static_cast<double>(ukm_service->session_id_)));
+                  base::Value(static_cast<int>(ukm_service->session_id_)));
 
   std::map<SourceId, SourceData> source_data;
   for (const auto& kv : ukm_service->recordings_.sources) {
@@ -93,10 +106,12 @@
 
     base::DictionaryValue source_value;
     if (src) {
-      source_value.SetKey("id", base::Value(static_cast<double>(src->id())));
+      source_value.SetKey("id",
+                          UkmDebugDataExtractor::UInt64AsPairOfInt(src->id()));
       source_value.SetKey("url", base::Value(src->url().spec()));
     } else {
-      source_value.SetKey("id", base::Value(static_cast<double>(kv.first)));
+      source_value.SetKey("id",
+                          UkmDebugDataExtractor::UInt64AsPairOfInt(kv.first));
     }
 
     base::ListValue entries_list;
diff --git a/components/ukm/debug/ukm_debug_data_extractor.h b/components/ukm/debug/ukm_debug_data_extractor.h
index dc2810d..9d06209 100644
--- a/components/ukm/debug/ukm_debug_data_extractor.h
+++ b/components/ukm/debug/ukm_debug_data_extractor.h
@@ -26,6 +26,13 @@
   // Returns UKM data structured in a DictionaryValue.
   static base::Value GetStructuredData(const UkmService* ukm_service);
 
+  // Convert uint64 to pair of int32 to match the spec of Value. JS doesn't
+  // support uint64 while most of UKM metrics are 64 bit numbers. So,
+  // they will be passed as a pair of 32 bit ints. The first item is the
+  // 32 bit representation of the high 32 bit and the second item is the lower
+  // 32 bit of the 64 bit number.
+  static base::Value UInt64AsPairOfInt(uint64_t v);
+
  private:
   DISALLOW_COPY_AND_ASSIGN(UkmDebugDataExtractor);
 };
diff --git a/components/ukm/debug/ukm_internals.css b/components/ukm/debug/ukm_internals.css
new file mode 100644
index 0000000..e8efc0d
--- /dev/null
+++ b/components/ukm/debug/ukm_internals.css
@@ -0,0 +1,127 @@
+/* Copyright 2016 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ *
+ * This is the stylesheet used by chrome://ukm debug page.
+ */
+
+div {
+  margin: auto;
+  padding: 2px;
+}
+
+div.ukm_collection_status {
+  background: linear-gradient(0deg, steelblue, lightblue);
+  padding: 16px;
+  color: white;
+  width: auto;
+}
+
+div.right_align {
+  float: right;
+  width: auto;
+}
+
+div.left_align {
+  display: inline;
+}
+
+#sources>div:nth-child(odd) {
+  background: lightsteelblue;
+}
+
+#sources>div:nth-child(even) {
+  background: lightgrey;
+}
+
+span#state, span#clientid {
+  text-decoration: underline;
+  font-weight: bold;
+}
+
+span.url {
+  font-weight: bold;
+}
+
+span.sourceid {
+  float: right;
+}
+
+div.url_card {
+  padding: 0px;
+}
+
+div.entries {
+  display: none;
+  overflow: hidden;
+}
+
+div.entry {
+ width: 470px;
+ padding: 0px 0px 10px 10px;
+ display: inline-block;
+}
+
+div.collapsible_header {
+  background-color: #eee;
+  cursor: pointer;
+}
+
+div.collapsible_header:hover {
+  background-color: #ccc;
+}
+
+td {
+  font-family: "Lucida Console", Monaco, monospace;
+  font-size: 0.8em;
+  overflow-wrap: break-word;
+  border-top: 1px solid black;
+}
+
+td.entry_name {
+  min-width: 320px;
+}
+
+td.metric_name {
+  min-width: 320px;
+  border-left: 1px solid black;
+}
+
+
+td.metric_value {
+  text-align: right;
+  min-width: 120px;
+  border-left: 1px solid black;
+}
+
+table.entry_table {
+  width: 780px;
+  border: solid 1px black;
+}
+
+select.hex_list {
+  width: 100px;
+  font-size: 0.7em;
+  font-family: "Lucida Console", Monaco, monospace;
+  height: 22px;
+}
+
+div.source_container {
+  padding: 0px;
+}
+
+div.source_container:first-child {
+  padding-top: 2px;
+}
+
+input.text {
+  width: 480px;
+}
+
+input.checkbox {
+}
+
+div.labels {
+  width: 120px;
+  display: inline-block;
+}
diff --git a/components/ukm/debug/ukm_internals.html b/components/ukm/debug/ukm_internals.html
index e8ca44d0..0d6414ab 100644
--- a/components/ukm/debug/ukm_internals.html
+++ b/components/ukm/debug/ukm_internals.html
@@ -3,22 +3,52 @@
      found in the LICENSE file.-->
 <!doctype html>
 <html lang="en">
-<meta charset="utf-8">
-<script src="chrome://resources/js/cr.js"></script>
-<script src="chrome://resources/js/promise_resolver.js"></script>
-<script src="chrome://resources/js/util.js"></script>
-<if expr="is_ios">
-    <!-- TODO(crbug.com/487000): Remove this once injected by web. -->
-    <script src="chrome://resources/js/ios/web_ui.js"></script>
-</if>
-<title>UKM Debug page</title>
-<h1>UKM Debug page</h1>
-<div>
-  <p>Is Enabled:<span id="state"></span></p>
-  <p>Client Id:<span id="clientid"></span></p>
-  <p>Session Id:<span id="sessionid"></span></p>
-  <h2>Sources</h2>
+  <meta charset="utf-8">
+  <script src="chrome://resources/js/cr.js"></script>
+  <script src="chrome://resources/js/promise_resolver.js"></script>
+  <script src="chrome://resources/js/util.js"></script>
+  <if expr="is_ios">
+      <!-- TODO(crbug.com/487000): Remove this once injected by web. -->
+      <script src="chrome://resources/js/ios/web_ui.js"></script>
+  </if>
+  <link rel="stylesheet" type="text/css" href="ukm_internals.css">
+  <title>UKM Debug page</title>
+  <div class="ukm_collection_status">
+    <div>
+      <div class="left_align"> Metrics Collection is
+        <span id="state"></span>
+        (Session: <span id="sessionid"></span>)
+      </div>
+      <div class="right_align">ClientId:
+        <span id="clientid"/>
+      </div>
+    </div>
+  </div>
+  <div id="warnings"></div>
   <div id="sources"></div>
-</div>
-<script src="ukm_internals.js"></script>
+  <div>
+    <div class='labels'> Recorder Ids: </div>
+    <select id="thread_ids" class="hex_list"></select>
+  </div>
+  <div>
+    <div class='labels'> Metrics Filter: </div>
+    <input id="metrics_select" type="text" value=".*" class="text" pattern="[A-Za-z0-9\._\*\|\?\+]*" maxlength="256"></input>
+  </div>
+  <div>
+    <div class='labels'> URL Filter: </div>
+    <input id="url_select" type="text" value=".*" class="text" pattern="[A-Za-z0-9\._\*\|\?\+]*" maxlength="256"></input>
+  </div>
+  <div>
+    <input id="hide_no_metrics" class="checkbox" type="checkbox" checked> Hide Sources With No Metric </input>
+  </div>
+  <div>
+    <input id="hide_no_url" class="checkbox" type="checkbox" checked> Hide Sources With No URL </input>
+  </div>
+  <div>
+    <input id="include_cache" class="checkbox" type="checkbox" checked> Include Cached Sources (since this page load) </input>
+  </div>
+  <button id="toggle_expand"></button>
+  <button id="clear">Clear</button>
+  <button id="refresh">Refresh</button>
+  <script src="ukm_internals.js"></script>
 </html>
diff --git a/components/ukm/debug/ukm_internals.js b/components/ukm/debug/ukm_internals.js
index e259e2a..55434b2 100644
--- a/components/ukm/debug/ukm_internals.js
+++ b/components/ukm/debug/ukm_internals.js
@@ -5,10 +5,10 @@
 /**
  * @typedef {{
  *   name: string,
- *   value: string
+ *   value: !Array<number>
  * }}
  */
-var Metric;
+let Metric;
 
 /**
  * @typedef {{
@@ -16,27 +16,313 @@
  *   metrics: !Array<!Metric>
  * }}
  */
-var UkmEntry;
+let UkmEntry;
 
 /**
  * @typedef {{
  *   url: string,
- *   id: string,
+ *   id: !Array<number>,
  *   entries: !Array<UkmEntry>,
  * }}
  */
-var UkmDataSource;
+let UkmDataSource;
 
 /**
  * The Ukm data sent from the browser.
  * @typedef {{
  *   state: boolean,
- *   client_id: string,
+ *   client_id: !Array<number>,
  *   session_id: string,
  *   sources: !Array<!UkmDataSource>,
  * }}
  */
-var UkmData;
+let UkmData;
+
+/**
+ * Stores source id and number of entries shown. If there is a new source id
+ * or there are new entries in Ukm recorder, then all the entries for
+ * the new source ID will be displayed.
+ * @type{Map<string, number>}
+ */
+const ClearedSources = new Map();
+
+/**
+ * Cached sources to persist beyond the log cut. This will ensure that the data
+ * on the page don't disappear if there is a log cut. The caching will
+ * start when the page is loaded and when the data is refreshed.
+ * Stored data is sourceid -> UkmDataSource with array of distinct entries.
+ * @type{Map<string, !UkmDataSource>}
+ */
+const CachedSources = new Map();
+
+/**
+ * Text for empty url.
+ * @type {string}
+ */
+const URL_EMPTY = 'missing';
+
+/**
+ * Converts a pair of JS 32 bin number to 64 bit hex string. This is used to
+ * pass 64 bit numbers from UKM like client id and 64 bit metrics to
+ * the javascript.
+ * @param {!Array<number>} num A pair of javascript signed int.
+ * @return {string} unsigned int64 as hex number or a decimal number if the
+ *     value is smaller than 32bit.
+ */
+function as64Bit(num) {
+  if (num.length != 2) {
+    return '0';
+  }
+  if (!num[0]) {
+    return num[1].toString();  // Return the lsb as String.
+  } else {
+    const hi = (num[0] >>> 0).toString(16).padStart(8, '0');
+    const lo = (num[1] >>> 0).toString(16).padStart(8, '0');
+    return `0x${hi}${lo}`;
+  }
+}
+
+/**
+ * Sets the display option of all the elements in HtmlCollection to the value
+ * passed.
+ * @param {!NodeList<!Element>} collection Collection of Elements.
+ */
+function setDisplayStyle(collection, display_value) {
+  for (const el of collection)
+    el.style.display = display_value;
+}
+
+/**
+ * Remove all the child elements.
+ * @param {!Element} parent Parent element whose children will get removed.
+ */
+function removeChildren(parent) {
+  while (parent.firstChild) {
+    parent.removeChild(parent.firstChild);
+  }
+}
+
+/**
+ * Create card for URL.
+ * @param {!Array<!UkmDataSource>} sourcesForUrl Sources that are for same URL.
+ * @param {string} url URL or Source id as hex string if the URL is missing.
+ * @param {!Element} sourcesDiv Sources div where this card will be added to.
+ * @param {!Map<string, ?string>} displayState Map from source id to value
+ *     of display property of the entries div.
+ */
+function createUrlCard(sourcesForUrl, url, sourcesDiv, displayState) {
+  const sourceDiv = createElementWithClassName('div', 'url_card');
+  sourcesDiv.appendChild(sourceDiv);
+  if (!sourcesForUrl || sourcesForUrl.length === 0)
+    return;
+  for (const source of sourcesForUrl) {
+    // This div allows hiding of the metrics per URL.
+    const sourceContainer = /** @type {!Element} */ (createElementWithClassName(
+        'div', 'source_container'));
+    sourceDiv.appendChild(sourceContainer);
+    createUrlHeader(source.url, source.id, sourceContainer);
+    createSourceCard(
+        source, sourceContainer, displayState.get(as64Bit(source.id)));
+  }
+}
+
+/**
+ * Create header containing URL and source ID data.
+ * @param {?string} url URL.
+ * @param {!Array<number>} id SourceId as hex.
+ * @param {!Element} sourceDiv Div under which header will get added.
+ */
+function createUrlHeader(url, id, sourceDiv) {
+  const headerElement = createElementWithClassName('div', 'collapsible_header');
+  sourceDiv.appendChild(headerElement);
+  const urlElement = createElementWithClassName('span', 'url');
+  urlElement.innerText = url ? url : URL_EMPTY;
+  headerElement.appendChild(urlElement);
+  const idElement = createElementWithClassName('span', 'sourceid');
+  idElement.innerText = as64Bit(id);
+  headerElement.appendChild(idElement);
+  // Make the click on header toggle entries div.
+  headerElement.addEventListener('click', () => {
+    const content = headerElement.nextElementSibling;
+    if (content.style.display === 'block') {
+      content.style.display = 'none';
+    } else {
+      content.style.display = 'block';
+    }
+  });
+}
+
+/**
+ * Create a card with UKM Source data.
+ * @param {!UkmDataSource} source UKM source data.
+ * @param {!Element} sourceDiv Source div where this card will be added to.
+ * @param {?string} displayState If display style of this source id is modified
+ *     then the state of the display style.
+ */
+function createSourceCard(source, sourceDiv, displayState) {
+  const metricElement =
+      /** @type {!Element} */ (createElementWithClassName('div', 'entries'));
+  sourceDiv.appendChild(metricElement);
+  const sortedEntry =
+      source.entries.sort((x, y) => x.name.localeCompare(y.name));
+  for (const entry of sortedEntry) {
+    createEntryTable(entry, metricElement);
+  }
+  if (displayState) {
+    metricElement.style.display = displayState;
+  } else {
+    if ($('toggle_expand').textContent === 'Collapse')
+      metricElement.style.display = 'block';
+    else
+      metricElement.style.display = 'none';
+  }
+}
+
+
+/**
+ * Create UKM Entry Table.
+ * @param {!UkmEntry} entry A Ukm metrics Entry.
+ * @param {!Element} sourceDiv Element whose children will be the entries.
+ */
+function createEntryTable(entry, sourceDiv) {
+  // Add first column to the table.
+  const entryTable = createElementWithClassName('table', 'entry_table');
+  entryTable.setAttribute('value', entry.name);
+  sourceDiv.appendChild(entryTable);
+  const firstRow = document.createElement('tr');
+  entryTable.appendChild(firstRow);
+  const entryName = createElementWithClassName('td', 'entry_name');
+  entryName.setAttribute('rowspan', 0);
+  entryName.textContent = entry.name;
+  firstRow.appendChild(entryName);
+
+  // Add metrics columns.
+  for (const metric of entry.metrics) {
+    const nextRow = document.createElement('tr');
+    const metricName = createElementWithClassName('td', 'metric_name');
+    metricName.textContent = metric.name;
+    nextRow.appendChild(metricName);
+    const metricValue = createElementWithClassName('td', 'metric_value');
+    metricValue.textContent = as64Bit(metric.value);
+    nextRow.appendChild(metricValue);
+    entryTable.appendChild(nextRow);
+  }
+}
+
+/**
+ * Collect all sources for a particular URL together. It will also sort the
+ * urls alphabetically.
+ * If the URL field is missing, the source ID will be used as the
+ * URL for the purpose of grouping and sorting.
+ * @param {!Array<!UkmDataSource>} sources List of UKM data for a source .
+ * @return {!Map<string, !Array<!UkmDataSource>>} Mapping in the sorted
+ *     order of URL from URL to list of sources for the URL.
+ */
+function urlToSourcesMapping(sources) {
+  const unsorted = new Map();
+  for (const source of sources) {
+    const key = source.url ? source.url : as64Bit(source.id);
+    if (!unsorted.has(key)) {
+      unsorted.set(key, [source]);
+    } else {
+      unsorted.get(key).push(source);
+    }
+  }
+  // Sort the map by URLs.
+  return new Map(Array.from(unsorted).sort(
+      (s1,s2) => s1[0].localeCompare(s2[0])));
+}
+
+
+/**
+ * Adds a button to Expand/Collapse all URLs.
+ */
+function addExpandToggleButton() {
+  const toggleExpand = $('toggle_expand');
+  toggleExpand.textContent = 'Expand';
+  toggleExpand.addEventListener('click', () => {
+    if (toggleExpand.textContent == 'Expand') {
+      toggleExpand.textContent = 'Collapse';
+      setDisplayStyle(document.getElementsByClassName('entries'), 'block');
+    } else {
+      toggleExpand.textContent = 'Expand';
+      setDisplayStyle(document.getElementsByClassName('entries'), 'none');
+    }
+  });
+}
+
+/**
+ * Adds a button to clear all the existing URLs. Note that the hiding is
+ * done in the UI only. So refreshing the page will show all the UKM again.
+ * To get the new UKMs after hitting Clear click the refresh button.
+ */
+function addClearButton() {
+  const clearButton = $('clear');
+  clearButton.addEventListener('click', () => {
+    // Note it won't be able to clear if UKM logs got cut during this call.
+    cr.sendWithPromise('requestUkmData').then((/** @type {UkmData} */ data) => {
+      updateUkmCache(data);
+      for (const s of CachedSources.values())
+        ClearedSources.set(as64Bit(s.id), s.entries.length);
+    });
+    $('toggle_expand').textContent = 'Expand';
+    updateUkmData();
+  });
+}
+
+/**
+ * Populate thread ids from the high bit of source id in sources.
+ * @param {!Array<!UkmDataSource>} sources Array of UKM source.
+ */
+function populateThreadIds(sources) {
+  const threadIdSelect = $('thread_ids');
+  const currentOptions =
+      new Set(Array.from(threadIdSelect.options).map(o => o.value));
+  // The first 32 bit of the ID is the recorder ID, convert it to a positive
+  // bit patterns and then to hex. Ids that were not seen earlier will get
+  // added to the end of the option list.
+  const newIds = new Set(sources.map(e => (e.id[0] >>> 0).toString(16)));
+  const options = ['All', ...Array.from(newIds).sort()];
+
+  for (const id of options) {
+    if (!currentOptions.has(id)) {
+      const option = document.createElement("option");
+      option.textContent = id;
+      option.setAttribute('value', id);
+      threadIdSelect.add(option);
+    }
+  }
+}
+
+/**
+ * This function tries to preserve UKM logs around UKM log uploads. There is
+ * no way of knowing if duplicate entries for a log are actually produced
+ * again after the log cut or if they older records since we don't maintain
+ * timestamp with entries. So only distinct entries will be recorded in the
+ * cache. i.e if two entries have exactly the same set of metrics then one
+ * of the entry will not be kept in the cache.
+ * @param {UkmData} data New UKM data to add to cache.
+ */
+function updateUkmCache(data) {
+  for (const source of data.sources) {
+    const key = as64Bit(source.id);
+    if (!CachedSources.has(key)) {
+      const mergedSource = {id: source.id, entries: source.entries};
+      if (source.url)
+        mergedSource.url = source.url;
+      CachedSources.set(key, mergedSource);
+    } else {
+      // Merge distinct entries from the source.
+      const existingEntries =
+          new Set(CachedSources.get(key).entries.map(e => JSON.stringify(e)));
+      for (const entry of source.entries) {
+        if (!existingEntries.has(JSON.stringify(entry))) {
+          CachedSources.get(key).entries.push(entry);
+        }
+      }
+    }
+  }
+}
 
 /**
  * Fetches data from the Ukm service and updates the DOM to display it as a
@@ -44,35 +330,107 @@
  */
 function updateUkmData() {
   cr.sendWithPromise('requestUkmData').then((/** @type {UkmData} */ data) => {
-    $('state').innerText = data.state ? 'True' : 'False';
-    $('clientid').innerText = data.client_id;
+    updateUkmCache(data);
+    if ($('include_cache').checked) {
+      data.sources = [...CachedSources.values()];
+    }
+    $('state').innerText = data.state? 'ENABLED' : 'DISABLED';
+    $('clientid').innerText = as64Bit(data.client_id);
     $('sessionid').innerText = data.session_id;
 
-    let sourceDiv = $('sources');
-    for (const source of data.sources) {
-      const sourceElement = document.createElement('h3');
-      if (source.url !== undefined)
-        sourceElement.innerText = `Id: ${source.id} Url: ${source.url}`;
-      else
-        sourceElement.innerText = `Id: ${source.id}`;
-      sourceDiv.appendChild(sourceElement);
-
-      for (const entry of source.entries) {
-        const entryElement = document.createElement('h4');
-        entryElement.innerText = `Entry: ${entry.name}`;
-        sourceDiv.appendChild(entryElement);
-
-        if (entry.metrics === undefined)
-          continue;
-        for (const metric of entry.metrics) {
-          const metricElement = document.createElement('h5');
-          metricElement.innerText =
-              `Metric: ${metric.name} Value: ${metric.value}`;
-          sourceDiv.appendChild(metricElement);
-        }
-      }
+    const sourcesDiv = /** @type {!Element} */ ($('sources'));
+    const currentDisplayState = new Map();
+    for (const el of document.getElementsByClassName('source_container')) {
+      currentDisplayState.set(el.querySelector('.sourceid').textContent,
+                              el.querySelector('.entries').style.display);
     }
+    removeChildren(sourcesDiv);
+    const urlToSources = urlToSourcesMapping(
+        filterSourcesUsingFormOptions(data.sources));
+    for (const url of urlToSources.keys()) {
+      createUrlCard(
+          urlToSources.get(url), url, sourcesDiv, currentDisplayState);
+    }
+    populateThreadIds(data.sources);
   });
 }
 
-document.addEventListener('DOMContentLoaded', updateUkmData);
+/**
+ * Filter sources that have been recorded previously. If it sees a source id
+ * where number of entries has decreased then it will add a warning.
+ * @param {!Array<!UkmDataSource>} sources All the sources currently in
+ *   UKM recorder.
+ * @return {!Array<!UkmDataSource>} Sources which are new or have a new entry
+ *   logged for them.
+ */
+function filterSourcesUsingFormOptions(sources) {
+  // Filter sources based on if they have been cleared.
+  const newSources = sources.filter(source => (
+      // Keep sources if it is newly generated since clearing earlier.
+      !ClearedSources.has(as64Bit(source.id)) ||
+      // Keep sources if it has increased entities since clearing earlier.
+      (source.entries.length > ClearedSources.get(as64Bit(source.id)))
+  ));
+
+  // Applies the filter from Metrics selector.
+  const newSourcesWithEntriesCleared = newSources.map(source => {
+    const metricsFilterValue = $('metrics_select').value;
+    if (metricsFilterValue) {
+      const metricsRe = new RegExp(metricsFilterValue);
+      source.entries = source.entries.filter(e => metricsRe.test(e.name));
+    }
+    return source;
+  });
+
+  // Filter sources based on the status of check-boxes.
+  const filteredSources = newSourcesWithEntriesCleared.filter(source => (
+      (!$('hide_no_url').checked || source.url) &&
+      (!$('hide_no_metrics').checked || source.entries.length)
+  ));
+
+  // Filter sources based on thread id.
+  const threadsFilteredSource = filteredSources.filter(source => {
+    const selectedOption =
+        $('thread_ids').options[$('thread_ids').selectedIndex];
+    return !selectedOption ||
+        (selectedOption.value === 'All') ||
+        ((source.id[0] >>> 0).toString() === selectedOption.value);
+  });
+
+  // Filter URLs based on URL selector input.
+  return threadsFilteredSource.filter(source => {
+    const urlFilterValue = $('url_select').value;
+    if (urlFilterValue) {
+      const urlRe = new RegExp(urlFilterValue);
+      // Will also match missing URLs by default.
+      return !source.url || urlRe.test(source.url);
+    }
+    return true;
+  });
+}
+
+/**
+ * DomContentLoaded handler.
+ */
+function onLoad() {
+  addExpandToggleButton();
+  addClearButton();
+  updateUkmData();
+  $('refresh').addEventListener('click', updateUkmData);
+  $('hide_no_metrics').addEventListener('click', updateUkmData);
+  $('hide_no_url').addEventListener('click', updateUkmData);
+  $('thread_ids').addEventListener('click', updateUkmData);
+  $('include_cache').addEventListener('click', updateUkmData);
+  $('metrics_select').addEventListener('keyup', e => {
+    if (e.key === 'Enter')
+      updateUkmData();
+  });
+  $('url_select').addEventListener('keyup', e => {
+    if (e.key === 'Enter')
+      updateUkmData();
+  });
+}
+
+document.addEventListener('DOMContentLoaded', onLoad);
+
+setInterval(updateUkmData, 120000);  // Refresh every 2 minutes.