Perf - Migrate cluster-page-sk away from Polymer.

Bug: skia:9219
Change-Id: I32ef32d644190fe5948cc8faadee1b3e133c625b
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/232538
Reviewed-by: Kevin Lubick <kjlubick@google.com>
Commit-Queue: Joe Gregorio <jcgregorio@google.com>
diff --git a/go.sum b/go.sum
index 6559426..062dc72 100644
--- a/go.sum
+++ b/go.sum
@@ -1353,6 +1353,7 @@
 k8s.io/utils v0.0.0-20190712204705-3dccf664f023/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew=
 k8s.io/utils v0.0.0-20190801114015-581e00157fb1 h1:+ySTxfHnfzZb9ys375PXNlLhkJPLKgHajBU0N62BDvE=
 k8s.io/utils v0.0.0-20190801114015-581e00157fb1/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew=
+rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE=
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 sigs.k8s.io/structured-merge-diff v0.0.0-20190426204423-ea680f03cc65/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI=
 sigs.k8s.io/structured-merge-diff v0.0.0-20190521201008-1c46bef2e9c8/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI=
diff --git a/perf/modules/cluster-page-sk/cluster-page-sk-demo.html b/perf/modules/cluster-page-sk/cluster-page-sk-demo.html
new file mode 100644
index 0000000..a06ba9b
--- /dev/null
+++ b/perf/modules/cluster-page-sk/cluster-page-sk-demo.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>cluster-page-sk</title>
+  <meta charset="utf-8" />
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <script type="text/javascript" charset="utf-8">
+    var sk = {};
+    sk.perf = {};
+    sk.perf.commit_range_url = "";
+    sk.perf.key_order = ['config'];
+  </script>
+</head>
+<body>
+  <h1>cluster-page-sk</h1>
+  <!-- the element will appear here. -->
+
+  <error-toast-sk> </error-toast-sk>
+  <h2>Events</h2>
+  <pre id=events></pre>
+  <script type="text/javascript" charset="utf-8">
+    customElements.whenDefined('cluster-page-sk').then(() => {
+      // Insert the element later, which should given enough time for fetchMock to be in place.
+      document.querySelector('h1').insertAdjacentElement('afterend', document.createElement('cluster-page-sk'));
+
+      document.querySelector('cluster-page-sk').addEventListener('some-event-name', (e) => {
+        document.querySelector('#events').textContent = JSON.stringify(e.detail, null, '  ');
+      });
+
+    });
+  </script>
+</body>
+</html>
diff --git a/perf/modules/cluster-page-sk/cluster-page-sk-demo.js b/perf/modules/cluster-page-sk/cluster-page-sk-demo.js
new file mode 100644
index 0000000..4c0f6bc
--- /dev/null
+++ b/perf/modules/cluster-page-sk/cluster-page-sk-demo.js
@@ -0,0 +1,140 @@
+import './index.js'
+import 'elements-sk/error-toast-sk'
+import { fetchMock } from '@bundled-es-modules/fetch-mock';
+
+fetchMock.post('/_/count/', async function() {
+  // Wait 1s before returning the content so we can see the spinner in action.
+  return await new Promise(res => setTimeout(() => res({count: Math.floor(Math.random()*2000)}), 1000))
+});
+
+fetchMock.get('/_/initpage/?tz=America/New_York', () => {
+  return {
+    "dataframe": {
+      "traceset": null,
+      "header": null,
+      "paramset": {
+        "arch": [
+          "WASM",
+          "arm",
+          "arm64",
+          "asmjs",
+          "wasm",
+          "x86",
+          "x86_64"
+        ],
+        "bench_type": [
+          "BRD",
+          "deserial",
+          "micro",
+          "playback",
+          "recording",
+          "skandroidcodec",
+          "skcodec",
+          "tracing"
+        ],
+        "browser": [
+          "Chrome"
+        ],
+        "clip": [
+          "0_0_1000_1000"
+        ],
+        "compiled_language": [
+          "asmjs",
+          "wasm"
+        ],
+        "compiler": [
+          "Clang",
+          "EMCC",
+          "GCC",
+          "MSVC",
+          "emsdk",
+          "none"
+        ],
+        "config": [
+          "8888",
+          "angle_d3d11_es2",
+          "angle_d3d11_es2_msaa8",
+          "angle_gl_es2",
+          "angle_gl_es2_msaa8",
+          "commandbuffer",
+          "default",
+          "enarrow",
+          "esrgb",
+          "f16",
+          "gl",
+          "gles",
+          "glesmsaa4",
+          "glessrgb",
+          "glmsaa4",
+          "glmsaa8",
+          "glsrgb",
+          "meta",
+          "mtl",
+        ],
+        "configuration": [
+          "Debug",
+          "Presubmit",
+          "Release",
+          "devrel",
+          "eng",
+          "sdk"
+        ],
+        "cpu_or_gpu": [
+          "CPU",
+          "GPU"
+        ],
+      },
+      "skip": 0
+    },
+    "ticks": [],
+    "skps": [],
+    "msg": "",
+  };
+});
+
+fetchMock.post('/_/cidRange/', () => {
+  console.log('fetch /_/cidRange');
+  return [
+    {
+      "offset":43389,
+      "source":"master",
+      "author":"Avinash Parchuri (aparchur@google.com)",
+      "message":"3a543aa - 23h 34m - Reland \"[skottie] Add onTextProperty support into ",
+      "url":"https://skia.googlesource.com/skia/+/3a543aafd4e68af182ef88572086c094cd63f0b2",
+      "hash":"3a543aafd4e68af182ef88572086c094cd63f0b2",
+      "ts":1565099441
+    },
+    {
+      "offset":43390,
+      "source":"master",
+      "author":"Robert Phillips (robertphillips@google.com)",
+      "message":"bdb0919 - 21h 15m - Use GrComputeTightCombinedBufferSize in GrMtlGpu::",
+      "url":"https://skia.googlesource.com/skia/+/bdb0919dcc6a700b41492c53ecf06b40983d13d7",
+      "hash":"bdb0919dcc6a700b41492c53ecf06b40983d13d7",
+      "ts":1565107798
+    },
+    {
+      "offset":43391,
+      "source":"master",
+      "author":"Hal Canary (halcanary@google.com)",
+      "message":"e45bf6a - 20h 33m - experimental/editor: interface no longer uses stri",
+      "url":"https://skia.googlesource.com/skia/+/e45bf6a603b7990f418eaf19ef0e2a2e59a9f449",
+      "hash":"e45bf6a603b7990f418eaf19ef0e2a2e59a9f449",
+      "ts":1565110328
+    },
+  ];
+});
+
+
+fetchMock.get('https://skia.org/loginstatus/', () => {
+  return {
+    "Email":"jcgregorio@google.com",
+    "ID":"110642259984599645813",
+    "LoginURL":"https://accounts.google.com/...",
+    "IsAGoogler":true,
+    "IsAdmin":true,
+    "IsEditor":false,
+    "IsViewer":true,
+  }
+});
+
diff --git a/perf/modules/cluster-page-sk/cluster-page-sk.js b/perf/modules/cluster-page-sk/cluster-page-sk.js
new file mode 100644
index 0000000..23e4a6c
--- /dev/null
+++ b/perf/modules/cluster-page-sk/cluster-page-sk.js
@@ -0,0 +1,369 @@
+/**
+ * @module module/cluster-page-sk
+ * @description <h2><code>cluster-page-sk</code></h2>
+ *
+ *   The top level element for clustering traces.
+ *
+ */
+import { ElementSk } from '../../../infra-sk/modules/ElementSk'
+import { errorMessage } from 'elements-sk/errorMessage'
+import { html, render } from 'lit-html'
+import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow'
+import { stateReflector } from 'common-sk/modules/stateReflector'
+
+import 'elements-sk/spinner-sk'
+import 'elements-sk/checkbox-sk'
+import 'elements-sk/styles/buttons'
+
+import '../../../infra-sk/modules/sort-sk'
+
+import '../algo-select-sk'
+import '../cluster-summary2-sk'
+import '../commit-detail-picker-sk'
+import '../day-range-sk'
+import '../query-count-sk'
+import '../query-sk'
+import '../query-summary-sk'
+
+const _summaryRows = (ele) => {
+  const ret = ele._summaries.map((summary) => {
+    return html`<cluster-summary2-sk .full_summary=${summary} notriage></cluster-summary2-sk>`;
+  });
+  if (!ret.length) {
+    ret.push(html`
+      <p class=info>
+        No clusters found.
+      </p>
+    `);
+  }
+  return ret;
+}
+
+const template = (ele) => html`
+  <h2>Commit</h2>
+  <h3>Appears in Date Range</h3>
+  <div class=day-range-with-spinner>
+    <day-range-sk
+      id=range
+      @day-range-change=${ele._rangeChange}
+      begin=${ele._state.begin}
+      end=${ele._state.end}
+      ></day-range-sk>
+    <spinner-sk ?active=${ele._updating_commits}></spinner-sk>
+  </div>
+  <h3>Commit</h3>
+  <div>
+    <commit-detail-picker-sk
+      @commit-selected=${ele._commitSelected}
+      .selected=${ele._selected_commit_index}
+      .details=${ele._cids}
+      id=commit
+      ></commit-picker-sk>
+  </div>
+
+  <h2>Algorithm</h2>
+  <algo-select-sk
+    algo=${ele._state.algo}
+    @algo-change=${(e) => ele._state.algo = e.detail.algo}
+    ></algo-select-sk>
+
+  <h2>Query</h2>
+  <div class=query-action>
+    <query-sk
+      @query-change=${ele._queryChanged}
+      .key_order=${sk.perf.key_order}
+      .paramset=${ele._paramset}
+      current_query=${ele._state.query}
+      ></query-sk>
+    <div id=selections>
+      <h3>Selections</h3>
+      <query-summary-sk id=summary selection=${ele._state.query}></query-summary-sk>
+      <div>
+        Matches:
+          <query-count-sk
+            url='/_/count/'
+            current_query=${ele._state.query}
+            @paramset-changed=${ele._paramsetChanged}
+            ></query-count-sk>
+      </div>
+      <button @click=${ele._start} class=action id=start ?disabled=${!!ele._requestId} >
+        Run
+      </button>
+      <div>
+        <spinner-sk ?active=${!!ele._requestId}></spinner-sk>
+        <span>${ele._status}</span>
+      </div>
+    </div>
+  </div>
+
+  <details>
+    <summary id=advanced>
+      <h2>Advanced</h2>
+    </summary>
+    <div id=inputs>
+      <label>
+        K (A value of 0 means the server chooses).
+        <input
+          type=number
+          min=0
+          max=100
+          .value=${ele._state.k}
+          @input=${(e) => ele._state.k = e.target.value}>
+      </label>
+      <label>
+        Number of commits to include on either side.
+        <input
+          type=number
+          min=1
+          max=25
+          .value=${ele._state.radius}
+          @input=${(e) => ele._state.radius = e.target.value}>
+      </label>
+      <label>
+        Clusters are interesting if regression score &gt;= this.
+        <input
+          type=number
+          min=0
+          max=500
+          .value=${ele._state.interesting}
+          @input=${(e) => ele._state.interesting = e.target.value}>
+      </label>
+      <checkbox-sk
+        ?checked=${ele._state.sparse}
+        @input=${(e) => ele._state.sparse = e.target.checked}
+        label='Data is sparse, so only include commits that have data.'
+        >
+      </checkbox-sk>
+    </div>
+  </details>
+
+  <h2>Results</h2>
+  <sort-sk target=clusters>
+    <button data-key=clustersize>Cluster Size </button>
+    <button data-key=stepregression data-default=up>Regression </button>
+    <button data-key=stepsize>Step Size </button>
+    <button data-key=steplse>Least Squares</button>
+  </sort-sk>
+  <div id=clusters @open-keys=${ele._openKeys}>
+    ${_summaryRows(ele)}
+  </div>
+  `;
+
+window.customElements.define('cluster-page-sk', class extends ElementSk {
+  constructor() {
+    super(template);
+
+    // The computed clusters.
+    this._summaries = [];
+
+    // The commits to choose from.
+    this._cids = [];
+
+    // Which commit is selected.
+    this._selected_commit_index = -1;
+
+    // The paramset to build queries from.
+    this._paramset = {};
+
+    // The id of the current cluster request. Will be the empty string if
+    // there is no pending request.
+    this._requestId = '';
+
+    // The status of a running request.
+    this._status = '';
+
+    // True if we are fetching a new list of _cids from the server.
+    this._updating_commits = false;
+
+    // The state that gets reflected to the URL.
+    this._state = {
+      begin: Math.floor(Date.now()/1000 - 24*60*60),
+      end: Math.floor(Date.now()/1000),
+      source: '',
+      offset: -1,
+      radius: '' + sk.perf.radius,
+      query: '',
+      k: '0',
+      algo: 'kmeans',
+      interesting: '' + sk.perf.interesting,
+      sparse: false,
+    };
+
+    // Only update _cids if the date range is different from the last fetch.
+    this._lastRange = {
+      begin: null,
+      end: null,
+    };
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._render();
+    this._clusters = this.querySelector('#clusters');
+
+    const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
+    fetch(`/_/initpage/?tz=${tz}`).then(jsonOrThrow).then((json) => {
+      this._paramset = json.dataframe.paramset;
+      this._render();
+    }).catch(errorMessage);
+
+    this._stateHasChanged = stateReflector(() => this._state, (state) => {
+      this._state = state;
+      this._render();
+      this._updateCommitSelections();
+    });
+    // There are a lot of pieces of _state, so just keep the URL up to date by polling.
+    this._keepURLUpdated();
+  }
+
+  _keepURLUpdated() {
+    this._stateHasChanged();
+    window.setTimeout(() => this._keepURLUpdated(), 100);
+  }
+
+  _queryChanged(e) {
+    this._state.query = e.detail.q;
+    this._render();
+  }
+
+  _paramsetChanged(e) {
+    this._paramset = e.detail;
+    this._render();
+  }
+
+  _openKeys(e) {
+    const query = {
+      keys:       e.detail.shortcut,
+      begin:      e.detail.begin,
+      end:        e.detail.end,
+      xbaroffset: e.detail.xbar.offset,
+      num_commits: 50,
+      request_type: 1,
+    };
+    window.open(`/e/?${sk.query.fromObject(query)}`, '_blank');
+  }
+
+  _rangeChange(e) {
+    this._state.begin = e.detail.begin;
+    this._state.end = e.detail.end;
+    this._updateCommitSelections();
+  }
+
+  _commitSelected(e) {
+    this._state.source = e.detail.commit.source;
+    this._state.offset = e.detail.commit.offset;
+  }
+
+  _updateCommitSelections() {
+    if (this._lastRange.begin === this._state.begin && this._lastRange.end === this._state.end) {
+      return;
+    }
+    this._lastRange = {
+      begin: this._state.begin,
+      end: this._state.end,
+    };
+    const body = {
+      begin: this._state.begin,
+      end: this._state.end,
+      source: this._state.source,
+      offset: this._state.offset,
+    };
+    this._updating_commits = true;
+    fetch('/_/cidRange/', {
+      method: 'POST',
+      body: JSON.stringify(body),
+      headers:{
+        'Content-Type': 'application/json'
+      }
+    }).then(jsonOrThrow).then((cids) => {
+      this._updating_commits = false;
+      cids.reverse();
+      this._cids = cids;
+
+      this._selected_commit_index = -1;
+      // Look for commit id in this._cids.
+      for (let i = 0; i < cids.length; i++) {
+        if (cids[i].source == this._state.source && cids[i].offset == this._state.offset) {
+          this._selected_commit_index = i;
+          break
+        }
+      }
+
+      if (!this._state.begin) {
+        this._state.begin   = cids[cids.length-1].ts;
+        this._state.end     = cids[0].ts;
+      }
+      this._render();
+    }).catch((msg) => {
+      if (msg) {
+        errorMessage(msg, 10000);
+      }
+      this._updating_commits = false;
+      this._render();
+    });
+  }
+
+  _catch(msg) {
+    this._requestId = '';
+    this._status = '';
+    if (msg) {
+      sk.errorMessage(msg, 10000);
+    }
+    this._render();
+  }
+
+  _checkClusterRequestStatus(cb) {
+    fetch(`/_/cluster/status/${this._requestId}`).then(jsonOrThrow).then((json) => {
+      if (json.state === 'Running') {
+        this._status = json.message;
+        this._render();
+        window.setTimeout(() => this._checkClusterRequestStatus(cb), 300);
+      } else {
+        if (json.value) {
+          cb(json.value);
+        }
+        this._catch(json.message);
+      }
+    }).catch(msg => this._catch(msg));
+  }
+
+  _start() {
+    if (this._requestId) {
+      errorMessage('There is a pending query already running.');
+      return;
+    }
+    const body = {
+      source: this._state.source,
+      offset: this._state.offset,
+      radius: +this._state.radius,
+      query: this._state.query,
+      k: +this._state.k,
+      tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
+      algo: this._state.algo,
+      interesting: +this._state.interesting,
+      sparse: this._state.sparse,
+    };
+    this._summaries = [];
+    fetch('/_/cluster/start', {
+      method: 'POST',
+      body: JSON.stringify(body),
+      headers:{
+        'Content-Type': 'application/json'
+      }
+    }).then(jsonOrThrow).then((json) => {
+      this._requestId = json.id;
+      this._checkClusterRequestStatus((summaries) => {
+        this._summaries = [];
+        summaries.summary.Clusters.forEach((cl) => {
+          cl.ID = -1;
+          this._summaries.push({
+            summary: cl,
+            frame: summaries.frame,
+          });
+        });
+        this._render();
+      });
+    }).catch(msg => this._catch(msg));
+  }
+
+});
diff --git a/perf/modules/cluster-page-sk/cluster-page-sk.scss b/perf/modules/cluster-page-sk/cluster-page-sk.scss
new file mode 100644
index 0000000..17c0ed4
--- /dev/null
+++ b/perf/modules/cluster-page-sk/cluster-page-sk.scss
@@ -0,0 +1,24 @@
+@import '~elements-sk/colors';
+
+cluster-page-sk {
+  algo-select-sk {
+    max-width: 10em;
+    display: block;
+  }
+
+  summary h2 {
+    display: inline-block;
+  }
+
+  div#inputs > * {
+    display: block;
+    margin: 1em;
+  }
+  div#inputs > label > input {
+    display: block;
+  }
+
+  .query-action {
+    display: flex;
+  }
+}
diff --git a/perf/modules/cluster-page-sk/index.js b/perf/modules/cluster-page-sk/index.js
new file mode 100644
index 0000000..d45c7a9
--- /dev/null
+++ b/perf/modules/cluster-page-sk/index.js
@@ -0,0 +1,2 @@
+import './cluster-page-sk.js'
+import './cluster-page-sk.scss'
diff --git a/perf/modules/commit-detail-panel-sk/commit-detail-panel-sk.js b/perf/modules/commit-detail-panel-sk/commit-detail-panel-sk.js
index 194a70e..bd94913 100644
--- a/perf/modules/commit-detail-panel-sk/commit-detail-panel-sk.js
+++ b/perf/modules/commit-detail-panel-sk/commit-detail-panel-sk.js
@@ -8,6 +8,7 @@
  *
  *     <pre>
  *     {
+ *       selected: 2,
  *       description: "foo (foo@example.org) 62W Commit from foo.",
  *       commit: {
  *         author: "foo (foo@example.org)",
@@ -87,6 +88,7 @@
     }
     this.selected = +ele.dataset['id']
     let detail = {
+      selected: ele.dataset.id,
       description: ele.textContent.trim(),
       commit: this._details[this.selected],
     }
diff --git a/perf/modules/commit-detail-picker-sk/commit-detail-picker-sk.js b/perf/modules/commit-detail-picker-sk/commit-detail-picker-sk.js
index d4de190..06c1063 100644
--- a/perf/modules/commit-detail-picker-sk/commit-detail-picker-sk.js
+++ b/perf/modules/commit-detail-picker-sk/commit-detail-picker-sk.js
@@ -23,8 +23,21 @@
 import { html, render } from 'lit-html'
 import { ElementSk } from '../../../infra-sk/modules/ElementSk'
 
+
+function _titleFrom(ele) {
+  const index = ele.selected;
+  if (index === -1) {
+    return 'Choose a commit.';
+  }
+  const d = ele._details[index];
+  if (!d) {
+    return 'Choose a commit.';
+  }
+  return `${d.author} -  ${d.message}`;
+}
+
 const template = (ele) => html`
-  <button @click=${ele._open}>${ele._title}</button>
+  <button @click=${ele._open}>${_titleFrom(ele)}</button>
   <dialog-sk>
     <commit-detail-panel-sk @commit-selected='${ele._panelSelect}' .details='${ele._details}' selectable selected=${ele.selected}></commit-detail-panel-sk>
     <button @click=${ele._close}>Close</button>
@@ -47,6 +60,7 @@
 
   _panelSelect(e) {
     this._title = e.detail.description;
+    this.selected = e.detail.selected;
     this._render();
   }
 
@@ -65,8 +79,10 @@
   }
 
   /** @prop selected {string} Mirrors the selected attribute. */
-  get selected() { return this.getAttribute('selected'); }
-  set selected(val) { this.setAttribute('selected', val); }
+  get selected() { return +this.getAttribute('selected'); }
+  set selected(val) {
+    this.setAttribute('selected', val);
+  }
 
   attributeChangedCallback(name, oldValue, newValue) {
     this._render();
diff --git a/perf/modules/explore-sk/explore-sk.js b/perf/modules/explore-sk/explore-sk.js
index 92474ca..2de9513 100644
--- a/perf/modules/explore-sk/explore-sk.js
+++ b/perf/modules/explore-sk/explore-sk.js
@@ -115,7 +115,8 @@
                 <h3>Selections</h3>
                 <query-summary-sk id=summary></query-summary-sk>
                 <div class=query-counts>
-                  Matches: <query-count-sk url='/_/count/'></query-count-sk>
+                  Matches: <query-count-sk url='/_/count/' @paramset-changed=${ele._paramsetChanged}>
+                  </query-count-sk>
                 </div>
                 <button @click=${ele._add} class=action>Plot</button>
               </div>
@@ -234,6 +235,10 @@
     }).catch(errorMessage);
   }
 
+  _paramsetChanged(e) {
+    this._query.paramset = e.detail;
+  }
+
   _queryChangeDelayedHandler(e) {
     this._query_count.current_query = e.detail.q;
   }
diff --git a/perf/modules/perf-scaffold-sk/perf-scaffold-sk.scss b/perf/modules/perf-scaffold-sk/perf-scaffold-sk.scss
index 02d0529..58abfed 100644
--- a/perf/modules/perf-scaffold-sk/perf-scaffold-sk.scss
+++ b/perf/modules/perf-scaffold-sk/perf-scaffold-sk.scss
@@ -8,6 +8,22 @@
     font-size: 12px;
   }
 
+  h1 {
+    font-size: 20px;
+  }
+
+  h2 {
+    font-size: 18px;
+  }
+
+  h3 {
+    font-size: 16px;
+  }
+
+  h4 {
+    font-size: 14px;
+  }
+
   nav-button-sk button {
     fill: var(--white);
     color: var(--white);
diff --git a/perf/modules/query-count-sk/query-count-sk.js b/perf/modules/query-count-sk/query-count-sk.js
index 1c7fb47..a4a2e89 100644
--- a/perf/modules/query-count-sk/query-count-sk.js
+++ b/perf/modules/query-count-sk/query-count-sk.js
@@ -8,6 +8,9 @@
  *
  * @attr {string} url - The URL to POST the query to.
  *
+ * @evt paramset-changed - An event with the updated paramset in e.detail
+ *   from the fetch response.
+ *
  */
 import { html, render } from 'lit-html'
 import { ElementSk } from '../../../infra-sk/modules/ElementSk'
@@ -44,7 +47,7 @@
     if (!this._connected) {
       return;
     }
-    if (!this.url || !this.current_query) {
+    if (!this.url) {
       return;
     }
     if (this._requestInProgress) {
@@ -72,6 +75,7 @@
       if (this._last_query != this.current_query) {
         this._fetch();
       }
+      this.dispatchEvent(new CustomEvent('paramset-changed', {detail: json.paramset, bubbles: true }));
     }).catch((msg) => {
       this._requestInProgress = false;
       this._render();
diff --git a/perf/pages/main.js b/perf/pages/main.js
index 440ca07..a91579e 100644
--- a/perf/pages/main.js
+++ b/perf/pages/main.js
@@ -7,6 +7,7 @@
 import '../modules/algo-select-sk'
 import '../modules/alert-config-sk'
 import '../modules/alerts-page-sk'
+import '../modules/cluster-page-sk'
 import '../modules/cluster-summary2-sk'
 import '../modules/commit-detail-panel-sk'
 import '../modules/commit-detail-picker-sk'
diff --git a/perf/res/imp/cluster-page.html b/perf/res/imp/cluster-page.html
deleted file mode 100644
index 515b131..0000000
--- a/perf/res/imp/cluster-page.html
+++ /dev/null
@@ -1,396 +0,0 @@
-<!-- The <cluster-page-sk> custom element declaration.
-
-  The top level element for clustering traces.
-
-  Attributes:
-    None.
-
-  Events:
-    None.
-
-  Methods:
-    None.
-
--->
-<link rel="import" href="/res/imp/bower_components/iron-flex-layout/iron-flex-layout-classes.html">
-<link rel="import" href="/res/imp/bower_components/paper-checkbox/paper-checkbox.html">
-<link rel="import" href="/res/imp/bower_components/paper-spinner/paper-spinner.html">
-<link rel="import" href="/res/imp/bower_components/paper-input/paper-input.html">
-<link rel="import" href="/res/imp/bower_components/iron-selector/iron-selector.html">
-
-<link rel="import" href="/res/common/imp/details-summary.html">
-<link rel="import" href="/res/common/imp/sort.html" />
-<link rel="stylesheet" href="/res/common/css/md.css">
-
-<dom-module id="cluster-page-sk">
-  <style include="iron-flex iron-flex-alignment iron-positioning">
-    day-range-sk {
-      display: block;
-    }
-
-    label {
-      width: 4em;
-      display: inline-block;
-      text-align: right;
-    }
-
-    #status {
-      display: inline-block;
-      margin: 0.5em;
-    }
-
-    cluster-summary2-sk {
-      box-shadow: 4px 4px 10px 1px rgba(0,0,0,0.75);
-      display: block;
-      padding: 1em;
-      margin: 1em;
-      width: 80em;
-    }
-
-    #advanced h2 {
-      display: inline-block;
-      margin: 0;
-    }
-
-    #advanced {
-      height: 2em;
-      display: inline-block;
-      vertical-align: bottom;
-    }
-
-    #inputs {
-      margin-left: 2em;
-    }
-
-    #inputs paper-input {
-      width: 20em;
-    }
-
-    .iron-selected {
-      background: #eee;
-    }
-
-    iron-selector div {
-      width: 10em;
-      margin: 0.3em 1em;
-      padding: 0.2em;
-    }
-
-    .info {
-      margin: 1em;
-      font-size: 18px;
-      color: #E7298A;
-    }
-
-    paper-checkbox {
-      --paper-checkbox-checked-color: #1f78b4;
-      --paper-checkbox-checked-ink-color: #1f78b4;
-    }
-
-    algo-select-sk {
-      width: 20em;
-      display: block;
-    }
-  </style>
-  <template>
-    <h2>Commit</h2>
-    <h3>Appears in Date Range</h3>
-    <div class="layout horizontal">
-      <day-range-sk id=range on-day-range-change="_rangeChange"></day-range-sk>
-      <paper-spinner id=spinner></paper-spinner>
-    </div>
-    <h3>Commit</h3>
-    <div>
-      <commit-detail-picker-sk on-commit-selected="_commitSelected" id=commit></commit-picker-sk>
-    </div>
-
-    <h2>Algorithm</h2>
-    <algo-select-sk on-algo-change=_algoChange algo="[[state.algo]]"></algo-select-sk>
-
-    <h2>Query</h2>
-    <div class="layout horizontal">
-      <query-sk id=query on-query-change="_queryChange" on-query-change-delayed="_queryChangeDelayed"></query-sk>
-      <div class="layout vertical" id=selections>
-        <h3>Selections</h3>
-        <query-summary-sk id=summary></query-summary-sk>
-        <div>
-          Matches: <span id=matches></span>
-        </div>
-        <button on-tap="_start" class=action id=start>Run</button>
-        <div class="layout horizontal center">
-          <paper-spinner id=clusterSpinner></paper-spinner>
-          <span id=status></span>
-        </div>
-      </div>
-    </div>
-
-    <details-sk>
-      <summary-sk id=advanced>
-        <h2>Advanced</h2>
-      </summary-sk>
-      <div id=inputs>
-        <paper-input type=number min=0 max=100  value="{{state.k}}"           label="K (A value of 0 means the server chooses)."></paper-input>
-        <paper-input type=number min=1 max=25   value="{{state.radius}}"      label="Number of commits to include on either side."></paper-input>
-        <paper-input type=number min=0 max=500  value="{{state.interesting}}" label="Clusters are interesting if regression score >= this."></paper-input>
-        <paper-checkbox checked="{{state.sparse}}">Data is sparse, so only include commits that have data.</paper-checkbox>
-      </div>
-    </details-sk>
-
-    <h2>Results</h2>
-    <sort-sk target=clusters node_name="CLUSTER-SUMMARY2-SK">
-      <button data-key="clustersize">Cluster Size </button>
-      <button data-key="stepregression" data-default=up>Regression </button>
-      <button data-key="stepsize">Step Size </button>
-      <button data-key="steplse">Least Squares</button>
-    </sort-sk>
-    <div id=clusters>
-      <template id=results is="dom-repeat" items="{{_summaries}}">
-        <cluster-summary2-sk full_summary="[[item]]" notriage></cluster-summary2-sk>
-      </template>
-      <template is="dom-if" if="{{_isZeroLength(_summaries,_requestId)}}">
-        <p class=info>
-          No clusters found.
-        </p>
-      </template>
-    </div>
-  </template>
-</dom-module>
-
-<script>
-  Polymer({
-    is: "cluster-page-sk",
-
-    properties: {
-      _dataframe: {
-        type: Object,
-        value: function() { return {
-          traceset: {},
-        }; },
-      },
-      // Keep track of whether a request is inflight to count the number of traces that match the current query.
-      _countInProgress: {
-        type: Boolean,
-        value: false,
-      },
-      // The state that goes into the URL.
-      //
-      state: {
-        type: Object,
-        value: function() { return {
-          begin: Math.floor(Date.now()/1000 - 24*60*60),
-          end: Math.floor(Date.now()/1000),
-          source: "",
-          offset: -1,
-          radius: "" + sk.perf.radius,
-          query: "",
-          k: "" + 0,
-          algo: "kmeans",
-          interesting: "" + sk.perf.interesting,
-          sparse: false,
-        }; },
-      },
-      // The id of the current cluster request. Will be the empty string
-      // if there is no pending request.
-      _requestId: {
-        type: String,
-        value: "",
-      },
-      _cids: {
-        type: Array,
-        value: function() { return [] },
-      },
-      // Keep track of whether a request is inflight to count the number of traces that match the current query.
-      _countInProgress: {
-        type: Boolean,
-        value: false,
-      },
-      _requestId: {
-        type: String,
-        value: "",
-      }
-    },
-
-    ready: function() {
-      var tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
-      sk.get("/_/initpage/?tz=" + tz).then(JSON.parse).then(function(json) {
-        this.$.query.key_order = sk.perf.key_order;
-        this.$.query.paramset = json.dataframe.paramset;
-      }.bind(this)).catch(sk.errorMessage);
-
-
-      // From this point on reflect the state to the URL.
-      sk.stateReflector(this, this._updateCommitSelections.bind(this));
-
-      this.$.clusters.addEventListener("open-keys", function(e) {
-        var query = {
-          keys:       e.detail.shortcut,
-          begin:      e.detail.begin,
-          end:        e.detail.end,
-          xbaroffset: e.detail.xbar.offset,
-          num_commits: 50,
-          request_type: 1,
-        };
-        window.open('/e/?' + sk.query.fromObject(query), '_blank');
-      }.bind(this));
-    },
-
-    // _catch for sk.post and sk.get requests around clustering.
-    _catch: function(msg) {
-      this._requestId = "";
-      this.$.clusterSpinner.active = false;
-      this.$.start.disabled = false;
-      if (msg) {
-        sk.errorMessage(msg, 10000);
-      }
-      this.$.status.textContent = "";
-    },
-
-    _start: function() {
-      if (this._requestId != "") {
-        sk.errorMessage("There is a pending query already running.");
-        return
-      }
-      var body = {
-        source: this.state.source,
-        offset: this.state.offset,
-        radius: +this.state.radius,
-        query: this.state.query,
-        k: +this.state.k,
-        tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
-        algo: this.state.algo,
-        interesting: +this.state.interesting,
-        sparse: this.state.sparse,
-      };
-      this._summaries = [];
-      this.$.results.render();
-      this.$.clusterSpinner.active = true;
-      this.$.start.disabled = true;
-      sk.post("/_/cluster/start", JSON.stringify(body), "application/json").then(JSON.parse).then(function(json) {
-        this._requestId = json.id;
-        this._checkClusterRequestStatus(function(summaries) {
-          var fullSummaries = [];
-          summaries.summary.Clusters.forEach(function(cl) {
-            cl.ID = -1;
-            fullSummaries.push({
-              summary: cl,
-              frame: summaries.frame,
-            });
-          });
-          this.set('_summaries', fullSummaries);
-        }.bind(this));
-      }.bind(this)).catch(this._catch.bind(this));
-    },
-
-    _algoChange: function(e) {
-      this.state.algo = e.detail.algo;
-    },
-
-    _checkClusterRequestStatus: function(cb) {
-      sk.get("/_/cluster/status/"+this._requestId).then(JSON.parse).then(function(json) {
-        if (json.state == "Running") {
-          this.$.status.textContent = json.message;
-          window.setTimeout(this._checkClusterRequestStatus.bind(this, cb), 300);
-        } else {
-          if (json.value) {
-            cb(json.value);
-          }
-          this._catch(json.message);
-        }
-      }.bind(this)).catch(this._catch.bind(this));
-    },
-
-    _updateCommitSelections: function() {
-      this.$.range.begin = this.state.begin;
-      this.$.range.end = this.state.end;
-      this.$.query.current_query = this.state.query;
-      var body = {
-        begin: this.state.begin,
-        end: this.state.end,
-        source: this.state.source,
-        offset: this.state.offset,
-      };
-      this.$.spinner.active = true;
-      sk.post("/_/cidRange/", JSON.stringify(body), "application/json").then(JSON.parse).then(function(cids) {
-        this.$.spinner.active = false;
-        cids.reverse();
-        this._cids = cids;
-        this.$.commit.details = cids;
-
-        var index = -1;
-        // Look for commit id in this._cids.
-        for (var i = 0; i < cids.length; i++) {
-          if (cids[i].source == this.state.source && cids[i].offset == this.state.offset) {
-            index = i;
-            break
-          }
-        }
-        // If there is then select via index.
-        if (index != -1) {
-          this.$.commit.selected = index;
-        }
-
-        if (this.state.begin == 0) {
-          this.state.begin   = cids[cids.length-1].ts;
-          this.$.range.begin = cids[cids.length-1].ts;
-          this.state.end     = cids[0].ts;
-          this.$.range.end   = cids[0].ts;
-        }
-      }.bind(this)).catch(function(msg) {
-        if (msg) {
-          sk.errorMessage(msg, 10000);
-        }
-        this.$.spinner.active = false;
-      }.bind(this));
-      this._updateCount();
-    },
-
-    _commitSelected: function(e) {
-      this.state.source = e.detail.commit.source;
-      this.state.offset = e.detail.commit.offset;
-    },
-
-    _queryChange: function(e) {
-      this.state.query = e.detail.q;
-      this.$.summary.selection = e.detail.q;
-    },
-
-    _queryChangeDelayed: function(e) {
-      this._updateCount();
-    },
-
-    _rangeChange: function(e) {
-      if (!this.state) {
-        return
-      }
-      this.state.begin = e.detail.begin;
-      this.state.end = e.detail.end;
-      this._updateCommitSelections();
-    },
-
-    _updateCount: function() {
-      if (this._countInProgress === true) {
-        return
-      }
-      this._countInProgress = true;
-      let body = {
-        q: this.$.query.current_query,
-        begin: this.state.begin,
-        end: this.state.end,
-      };
-      sk.post("/_/count/", JSON.stringify(body)).then(JSON.parse).then(function(json) {
-        this._countInProgress = false;
-        this.$.matches.textContent = json.count;
-        if (json.paramset) {
-          this.$.query.paramset = json.paramset;
-        }
-      }.bind(this)).catch(function() {
-        this._countInProgress = false;
-      });
-    },
-
-    _isZeroLength: function(ar, _requestId) {
-      return ar.length == 0 && this._requestId == "";
-    }
-
-  });
-</script>
diff --git a/perf/templates/clusters2.html b/perf/templates/clusters2.html
index 57a80f9..4f60a70 100644
--- a/perf/templates/clusters2.html
+++ b/perf/templates/clusters2.html
@@ -6,7 +6,46 @@
       this.sk = this.sk || {};
       this.sk.perf = {{.context}};
     </script>
-    {{template "header.html" .}}
+    <!-- Can't use header.html here until everything is ported away from Polymer. -->
+    <meta charset="utf-8">
+    <meta name="theme-color" content="#1f78b4">
+    <link rel="shortcut icon" href="/res/img/favicon.ico" />
+    <link rel="manifest" href="/res/manifest.json">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <script src="/res/js/core.js" type="text/javascript" charset="utf-8"></script>
+    <style type="text/css" media="screen">
+      body {
+        margin: 0;
+        padding: 0;
+      }
+
+      .main * {
+        font-size: 12px;
+      }
+
+      .main h2 {
+        font-size: 16px !important;
+      }
+
+      .main h3 {
+        font-size: 14px !important;
+      }
+
+      .main button {
+        text-transform: none !important;
+      }
+    </style>
+    <script>
+      // Check that service workers are registered
+      if ('serviceWorker' in navigator) {
+        window.addEventListener('load', () => {
+          navigator.serviceWorker.register('/service-worker.js');
+        });
+      }
+    </script>
+    <link href="/dist/main-bundle.css" rel="stylesheet">
+    <script type="text/javascript" src="/dist/main-bundle.js"></script>
   </head>
   <body>
     <perf-scaffold-sk>