[gold] Port ignores-page-sk to TypeScript.

Bug: skia:10246
Change-Id: I206104a202db702fa7c1f460b941a98d1e476286
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/401224
Commit-Queue: Leandro Lovisolo <lovisolo@google.com>
Reviewed-by: Kevin Lubick <kjlubick@google.com>
diff --git a/golden/go/web/frontend/generate_typescript_rpc_types/main.go b/golden/go/web/frontend/generate_typescript_rpc_types/main.go
index f6c8778..c95fc4c 100644
--- a/golden/go/web/frontend/generate_typescript_rpc_types/main.go
+++ b/golden/go/web/frontend/generate_typescript_rpc_types/main.go
@@ -64,6 +64,12 @@
 	// Response for the /json/v1/changelists RPC endpoint.
 	generator.Add(frontend.ChangelistsResponse{})
 
+	// Payload for the /json/v1/ignores/add and /json/v1/ignores/save RPC endpoints.
+	generator.Add(frontend.IgnoreRuleBody{})
+
+	// Response for the /json/v1/ignores RPC endpoint.
+	generator.Add(frontend.IgnoresResponse{})
+
 	generator.AddUnionWithName(expectations.AllLabel, "Label")
 	generator.AddUnionWithName(common.AllRefClosest, "RefClosest")
 }
diff --git a/golden/go/web/frontend/types.go b/golden/go/web/frontend/types.go
index 8d6c907..9ff1e18 100644
--- a/golden/go/web/frontend/types.go
+++ b/golden/go/web/frontend/types.go
@@ -168,6 +168,11 @@
 	Digests []types.Digest `json:"digests"`
 }
 
+// IgnoresResponse is the response for /json/v1/ignores.
+type IgnoresResponse struct {
+	Rules []IgnoreRule `json:"rules"`
+}
+
 // IgnoreRule represents an ignore.Rule as well as how many times the rule
 // was applied. This allows for the decoupling of the rule as stored in the
 // DB from how we present it to the UI.
diff --git a/golden/go/web/web.go b/golden/go/web/web.go
index 0c8aef6..bf71344 100644
--- a/golden/go/web/web.go
+++ b/golden/go/web/web.go
@@ -728,25 +728,29 @@
 		return
 	}
 
-	sendJSONResponse(w, ignores)
+	response := frontend.IgnoresResponse{
+		Rules: ignores,
+	}
+
+	sendJSONResponse(w, response)
 }
 
 // getIgnores fetches the ignores from the store and optionally counts how many
 // times they are applied.
-func (wh *Handlers) getIgnores(ctx context.Context, withCounts bool) ([]*frontend.IgnoreRule, error) {
+func (wh *Handlers) getIgnores(ctx context.Context, withCounts bool) ([]frontend.IgnoreRule, error) {
 	rules, err := wh.IgnoreStore.List(ctx)
 	if err != nil {
 		return nil, skerr.Wrapf(err, "fetching ignores from store")
 	}
 
 	// We want to make a slice of pointers because addIgnoreCounts will add the counts in-place.
-	ret := make([]*frontend.IgnoreRule, 0, len(rules))
+	ret := make([]frontend.IgnoreRule, 0, len(rules))
 	for _, r := range rules {
 		fr, err := frontend.ConvertIgnoreRule(r)
 		if err != nil {
 			return nil, skerr.Wrap(err)
 		}
-		ret = append(ret, &fr)
+		ret = append(ret, fr)
 	}
 
 	if withCounts {
@@ -762,7 +766,7 @@
 // addIgnoreCounts goes through the whole tile and counts how many traces each of the rules
 // applies to. This uses the most recent index, so there may be some discrepancies in the counts
 // if a new rule has been added since the last index was computed.
-func (wh *Handlers) addIgnoreCounts(ctx context.Context, rules []*frontend.IgnoreRule) error {
+func (wh *Handlers) addIgnoreCounts(ctx context.Context, rules []frontend.IgnoreRule) error {
 	defer metrics2.FuncTimer().Stop()
 	sklog.Debugf("adding counts to %d rules", len(rules))
 
@@ -830,11 +834,11 @@
 		}
 		mutex.Lock()
 		defer mutex.Unlock()
-		for i, r := range rules {
-			r.Count += ruleCounts[i].Count
-			r.UntriagedCount += ruleCounts[i].UntriagedCount
-			r.ExclusiveCount += ruleCounts[i].ExclusiveCount
-			r.ExclusiveUntriagedCount += ruleCounts[i].ExclusiveUntriagedCount
+		for i := range rules {
+			(&rules[i]).Count += ruleCounts[i].Count
+			(&rules[i]).UntriagedCount += ruleCounts[i].UntriagedCount
+			(&rules[i]).ExclusiveCount += ruleCounts[i].ExclusiveCount
+			(&rules[i]).ExclusiveUntriagedCount += ruleCounts[i].ExclusiveUntriagedCount
 		}
 		return nil
 	})
diff --git a/golden/go/web/web_test.go b/golden/go/web/web_test.go
index f46cec2..93d4c06 100644
--- a/golden/go/web/web_test.go
+++ b/golden/go/web/web_test.go
@@ -989,9 +989,9 @@
 	}, dlr)
 }
 
-// TestGetIgnores_NoCounts_SunnyDay_Success tests the case where we simply return the list of the
-// current ignore rules, without counting any of the traces to which they apply.
-func TestGetIgnores_NoCounts_SunnyDay_Success(t *testing.T) {
+// TestListIgnoreRules_NoCounts_SunnyDay_Success tests the case where we simply return the list of
+// the current ignore rules, without counting any of the traces to which they apply.
+func TestListIgnoreRules_NoCounts_SunnyDay_Success(t *testing.T) {
 	unittest.SmallTest(t)
 
 	mis := &mock_ignore.Store{}
@@ -1000,45 +1000,52 @@
 	mis.On("List", testutils.AnyContext).Return(makeIgnoreRules(), nil)
 
 	wh := Handlers{
+		anonymousCheapQuota: rate.NewLimiter(rate.Inf, 1),
 		HandlersConfig: HandlersConfig{
 			IgnoreStore: mis,
 		},
 	}
 
-	xir, err := wh.getIgnores(context.Background(), false)
+	expectedResponse := frontend.IgnoresResponse{
+		Rules: []frontend.IgnoreRule{
+			{
+				ID:        "1234",
+				CreatedBy: "user@example.com",
+				UpdatedBy: "user2@example.com",
+				Expires:   firstRuleExpire,
+				Query:     "device=delta",
+				Note:      "Flaky driver",
+			},
+			{
+				ID:        "5678",
+				CreatedBy: "user2@example.com",
+				UpdatedBy: "user@example.com",
+				Expires:   secondRuleExpire,
+				Query:     "name=test_two&source_type=gm",
+				Note:      "Not ready yet",
+			},
+			{
+				ID:        "-1",
+				CreatedBy: "user3@example.com",
+				UpdatedBy: "user3@example.com",
+				Expires:   thirdRuleExpire,
+				Query:     "matches=nothing",
+				Note:      "Oops, this matches nothing",
+			},
+		},
+	}
+
+	w := httptest.NewRecorder()
+	r := httptest.NewRequest(http.MethodGet, "/json/v1/ignores", nil)
+	wh.ListIgnoreRules(w, r)
+	b, err := json.Marshal(expectedResponse)
 	require.NoError(t, err)
-	clearParsedQueries(xir)
-	assert.Equal(t, []*frontend.IgnoreRule{
-		{
-			ID:        "1234",
-			CreatedBy: "user@example.com",
-			UpdatedBy: "user2@example.com",
-			Expires:   firstRuleExpire,
-			Query:     "device=delta",
-			Note:      "Flaky driver",
-		},
-		{
-			ID:        "5678",
-			CreatedBy: "user2@example.com",
-			UpdatedBy: "user@example.com",
-			Expires:   secondRuleExpire,
-			Query:     "name=test_two&source_type=gm",
-			Note:      "Not ready yet",
-		},
-		{
-			ID:        "-1",
-			CreatedBy: "user3@example.com",
-			UpdatedBy: "user3@example.com",
-			Expires:   thirdRuleExpire,
-			Query:     "matches=nothing",
-			Note:      "Oops, this matches nothing",
-		},
-	}, xir)
+	assertJSONResponseWas(t, http.StatusOK, string(b), w)
 }
 
-// TestGetIgnores_WithCounts_SunnyDay_Success tests the case where we get the list of current ignore
-// rules and count the traces to which those rules apply.
-func TestGetIgnores_WithCounts_SunnyDay_Success(t *testing.T) {
+// TestListIgnoreRules_WithCounts_SunnyDay_Success tests the case where we get the list of current
+// ignore rules and count the traces to which those rules apply.
+func TestListIgnoreRules_WithCounts_SunnyDay_Success(t *testing.T) {
 	unittest.SmallTest(t)
 
 	mes := &mock_expectations.Store{}
@@ -1061,6 +1068,7 @@
 	mis.On("List", testutils.AnyContext).Return(makeIgnoreRules(), nil)
 
 	wh := Handlers{
+		anonymousExpensiveQuota: rate.NewLimiter(rate.Inf, 1),
 		HandlersConfig: HandlersConfig{
 			ExpectationsStore: mes,
 			IgnoreStore:       mis,
@@ -1068,52 +1076,58 @@
 		},
 	}
 
-	xir, err := wh.getIgnores(context.Background(), true /* = withCounts*/)
+	expectedResponse := frontend.IgnoresResponse{
+		Rules: []frontend.IgnoreRule{
+			{
+				ID:                      "1234",
+				CreatedBy:               "user@example.com",
+				UpdatedBy:               "user2@example.com",
+				Expires:                 firstRuleExpire,
+				Query:                   "device=delta",
+				Note:                    "Flaky driver",
+				Count:                   2,
+				ExclusiveCount:          1,
+				UntriagedCount:          1,
+				ExclusiveUntriagedCount: 0,
+			},
+			{
+				ID:                      "5678",
+				CreatedBy:               "user2@example.com",
+				UpdatedBy:               "user@example.com",
+				Expires:                 secondRuleExpire,
+				Query:                   "name=test_two&source_type=gm",
+				Note:                    "Not ready yet",
+				Count:                   4,
+				ExclusiveCount:          3,
+				UntriagedCount:          2,
+				ExclusiveUntriagedCount: 1,
+			},
+			{
+				ID:                      "-1",
+				CreatedBy:               "user3@example.com",
+				UpdatedBy:               "user3@example.com",
+				Expires:                 thirdRuleExpire,
+				Query:                   "matches=nothing",
+				Note:                    "Oops, this matches nothing",
+				Count:                   0,
+				ExclusiveCount:          0,
+				UntriagedCount:          0,
+				ExclusiveUntriagedCount: 0,
+			},
+		},
+	}
+
+	w := httptest.NewRecorder()
+	r := httptest.NewRequest(http.MethodGet, "/json/v1/ignores?counts=1", nil)
+	wh.ListIgnoreRules(w, r)
+	b, err := json.Marshal(expectedResponse)
 	require.NoError(t, err)
-	clearParsedQueries(xir)
-	assert.Equal(t, []*frontend.IgnoreRule{
-		{
-			ID:                      "1234",
-			CreatedBy:               "user@example.com",
-			UpdatedBy:               "user2@example.com",
-			Expires:                 firstRuleExpire,
-			Query:                   "device=delta",
-			Note:                    "Flaky driver",
-			Count:                   2,
-			ExclusiveCount:          1,
-			UntriagedCount:          1,
-			ExclusiveUntriagedCount: 0,
-		},
-		{
-			ID:                      "5678",
-			CreatedBy:               "user2@example.com",
-			UpdatedBy:               "user@example.com",
-			Expires:                 secondRuleExpire,
-			Query:                   "name=test_two&source_type=gm",
-			Note:                    "Not ready yet",
-			Count:                   4,
-			ExclusiveCount:          3,
-			UntriagedCount:          2,
-			ExclusiveUntriagedCount: 1,
-		},
-		{
-			ID:                      "-1",
-			CreatedBy:               "user3@example.com",
-			UpdatedBy:               "user3@example.com",
-			Expires:                 thirdRuleExpire,
-			Query:                   "matches=nothing",
-			Note:                    "Oops, this matches nothing",
-			Count:                   0,
-			ExclusiveCount:          0,
-			UntriagedCount:          0,
-			ExclusiveUntriagedCount: 0,
-		},
-	}, xir)
+	assertJSONResponseWas(t, http.StatusOK, string(b), w)
 }
 
-// TestGetIgnores_WithCountsOnBigTile_SunnyDay_NoRaceConditions uses an artificially bigger tile to
-// process to make sure the counting code has no races in it when sharded.
-func TestGetIgnores_WithCountsOnBigTile_SunnyDay_NoRaceConditions(t *testing.T) {
+// TestListIgnoreRules_WithCountsOnBigTile_SunnyDay_NoRaceConditions uses an artificially bigger
+// tile to process to make sure the counting code has no races in it when sharded.
+func TestListIgnoreRules_WithCountsOnBigTile_SunnyDay_NoRaceConditions(t *testing.T) {
 	unittest.SmallTest(t)
 
 	mes := &mock_expectations.Store{}
@@ -1134,6 +1148,7 @@
 	mis.On("List", testutils.AnyContext).Return(makeIgnoreRules(), nil)
 
 	wh := Handlers{
+		anonymousExpensiveQuota: rate.NewLimiter(rate.Inf, 1),
 		HandlersConfig: HandlersConfig{
 			ExpectationsStore: mes,
 			IgnoreStore:       mis,
@@ -1141,10 +1156,15 @@
 		},
 	}
 
-	xir, err := wh.getIgnores(context.Background(), true /* = withCounts*/)
-	require.NoError(t, err)
+	w := httptest.NewRecorder()
+	r := httptest.NewRequest(http.MethodGet, "/json/v1/ignores?counts=1", nil)
+	wh.ListIgnoreRules(w, r)
+	responseBytes := assertJSONResponseAndReturnBody(t, http.StatusOK, w)
+	response := frontend.IgnoresResponse{}
+	require.NoError(t, json.Unmarshal(responseBytes, &response))
+
 	// Just check the length, other unit tests will validate the correctness.
-	assert.Len(t, xir, 3)
+	assert.Len(t, response.Rules, 3)
 }
 
 // TestHandlersThatRequireLogin_NotLoggedIn_UnauthorizedError tests a list of handlers to make sure
@@ -3175,27 +3195,26 @@
 	}
 }
 
-// clearParsedQueries removes the implementation detail parts of the IgnoreRule that don't make
-// sense to assert against.
-func clearParsedQueries(xir []*frontend.IgnoreRule) {
-	for _, ir := range xir {
-		ir.ParsedQuery = nil
-	}
-}
-
-// assertJSONResponseWasOK asserts that the given ResponseRecorder was given the appropriate JSON
-// headers and saw a status OK (200) response.
-func assertJSONResponseWas(t *testing.T, status int, body string, w *httptest.ResponseRecorder) {
+// assertJSONResponseAndReturnBody asserts that the given ResponseRecorder was given the
+// appropriate JSON and the expected status code, and returns the response body.
+func assertJSONResponseAndReturnBody(t *testing.T, expectedStatusCode int, w *httptest.ResponseRecorder) []byte {
 	resp := w.Result()
-	assert.Equal(t, status, resp.StatusCode)
+	assert.Equal(t, expectedStatusCode, resp.StatusCode)
 	assert.Equal(t, jsonContentType, resp.Header.Get(contentTypeHeader))
 	assert.Equal(t, allowAllOrigins, resp.Header.Get(accessControlHeader))
 	assert.Equal(t, noSniffContent, resp.Header.Get(contentTypeOptionsHeader))
 	respBody, err := ioutil.ReadAll(resp.Body)
 	require.NoError(t, err)
+	return respBody
+}
+
+// assertJSONResponseWas asserts that the given ResponseRecorder was given the appropriate JSON
+// headers and the expected status code and response body.
+func assertJSONResponseWas(t *testing.T, expectedStatusCode int, expectedBody string, w *httptest.ResponseRecorder) {
+	actualBody := assertJSONResponseAndReturnBody(t, expectedStatusCode, w)
 	// The JSON encoder includes a newline "\n" at the end of the body, which is awkward to include
 	// in the literals passed in above, so we add that here
-	assert.Equal(t, body+"\n", string(respBody))
+	assert.Equal(t, expectedBody+"\n", string(actualBody))
 }
 
 func assertImageResponseWas(t *testing.T, expected []byte, w *httptest.ResponseRecorder) {
diff --git a/golden/modules/ignores-page-sk/ignores-page-sk-demo.js b/golden/modules/ignores-page-sk/ignores-page-sk-demo.ts
similarity index 88%
rename from golden/modules/ignores-page-sk/ignores-page-sk-demo.js
rename to golden/modules/ignores-page-sk/ignores-page-sk-demo.ts
index 650cc35..24f78ff 100644
--- a/golden/modules/ignores-page-sk/ignores-page-sk-demo.js
+++ b/golden/modules/ignores-page-sk/ignores-page-sk-demo.ts
@@ -1,7 +1,6 @@
 import './index';
 import '../gold-scaffold-sk';
 
-import { $$ } from 'common-sk/modules/dom';
 import { delay } from '../demo_util';
 import { ignoreRules_10, fakeNow } from './test_data';
 import { manyParams } from '../shared_demo_data';
@@ -25,8 +24,8 @@
 // By adding these elements after all the fetches are mocked out, they should load ok.
 const newScaf = document.createElement('gold-scaffold-sk');
 newScaf.setAttribute('testing_offline', 'true');
-const body = $$('body');
-body.insertBefore(newScaf, body.childNodes[0]); // Make it the first element in body.
+// Make it the first element in body.
+document.body.insertBefore(newScaf, document.body.childNodes[0]);
 const page = document.createElement('ignores-page-sk');
 page.setAttribute('page_size', '10');
 newScaf.appendChild(page);
diff --git a/golden/modules/ignores-page-sk/ignores-page-sk.js b/golden/modules/ignores-page-sk/ignores-page-sk.js
deleted file mode 100644
index 0433949..0000000
--- a/golden/modules/ignores-page-sk/ignores-page-sk.js
+++ /dev/null
@@ -1,247 +0,0 @@
-/**
- * @module module/ignores-page-sk
- * @description <h2><code>ignores-page-sk</code></h2>
- *
- * Page to view/edit/delete ignore rules.
- */
-
-import * as human from 'common-sk/modules/human';
-import dialogPolyfill from 'dialog-polyfill';
-
-import { $$ } from 'common-sk/modules/dom';
-import { classMap } from 'lit-html/directives/class-map';
-import { define } from 'elements-sk/define';
-import { html } from 'lit-html';
-import { stateReflector } from 'common-sk/modules/stateReflector';
-import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow';
-import { ElementSk } from '../../../infra-sk/modules/ElementSk';
-import { escapeAndLinkify } from '../../../infra-sk/modules/linkify';
-import {
-  humanReadableQuery, sendBeginTask, sendEndTask, sendFetchError,
-} from '../common';
-
-import '../../../infra-sk/modules/confirm-dialog-sk';
-import '../edit-ignore-rule-sk';
-import 'elements-sk/checkbox-sk';
-import 'elements-sk/icon/delete-icon-sk';
-import 'elements-sk/icon/info-outline-icon-sk';
-import 'elements-sk/icon/mode-edit-icon-sk';
-import 'elements-sk/styles/buttons';
-
-const template = (ele) => html`
-<div class=controls>
-  <checkbox-sk label="Only count traces with untriaged digests"
-               ?checked=${!ele._countAllTraces} @click=${ele._toggleCountAll}></checkbox-sk>
-
-  <button @click=${ele._newIgnoreRule} class=create>Create new ignore rule</button>
-</div>
-
-<confirm-dialog-sk></confirm-dialog-sk>
-
-<dialog id=edit-ignore-rule-dialog>
-  <h2>${ele._ruleID ? 'Edit Ignore Rule' : 'Create Ignore Rule'}</h2>
-  <edit-ignore-rule-sk .paramset=${ele._paramset}></edit-ignore-rule-sk>
-  <button @click=${() => ele._editIgnoreRuleDialog.close()}>Cancel</button>
-  <button id=ok class=action @click=${ele._saveIgnoreRule}>
-    ${ele._ruleID ? 'Update' : 'Create'}
-  </button>
-</dialog>
-
-<table>
-  <thead>
-    <tr>
-      <th colspan=2>Filter</th>
-      <th>Note</th>
-      <th> Traces matched <br> exclusive/all
-        <info-outline-icon-sk class=small-icon
-            title="'all' is the number of traces that a given ignore rule applies to. 'exclusive' \
-is the number of traces which are matched by the given ignore rule and no other ignore rule of the \
-rules in this list. If the checkbox is checked to only count traces with untriaged digests, it \
-means 'untriaged digests at head', which is typically an indication of a flaky test/config.">
-        </info-outline-icon-sk>
-      </th>
-      <th>Expires in</th>
-      <th>Created by</th>
-      <th>Updated by</th>
-    </tr>
-  </thead>
-  <tbody>
-  ${ele._rules.map((r) => ruleTemplate(ele, r))}
-  </tbody>
-</table>`;
-
-const ruleTemplate = (ele, r) => {
-  const isExpired = Date.parse(r.expires) < Date.now();
-  return html`
-<tr class=${classMap({ expired: isExpired })}>
-  <td class=mutate-icons>
-    <mode-edit-icon-sk title="Edit this rule."
-        @click=${() => ele._editIgnoreRule(r)}></mode-edit-icon-sk>
-    <delete-icon-sk title="Delete this rule."
-        @click=${() => ele._deleteIgnoreRule(r)}></delete-icon-sk>
-  </td>
-  <td class=query><a href=${`/list?include=true&query=${encodeURIComponent(r.query)}`}
-    >${humanReadableQuery(r.query)}</a></td>
-  <td>${escapeAndLinkify(r.note) || '--'}</td>
-  <td class=matches title="These counts are recomputed every few minutes.">
-    ${ele._countAllTraces ? r.exclusiveCountAll : r.exclusiveCount} /
-    ${ele._countAllTraces ? r.countAll : r.count}
-  </td>
-  <td class=${classMap({ expired: isExpired })}>
-    ${isExpired ? 'Expired' : human.diffDate(r.expires)}
-  </td>
-  <td title=${`Originally created by ${r.name}`}>${trimEmail(r.name)}</td>
-  <td title=${`Last updated by ${r.updatedBy}`}>
-    ${r.name === r.updatedBy ? '' : trimEmail(r.updatedBy)}
-  </td>
-</tr>`;
-};
-
-function trimEmail(s) {
-  return `${s.split('@')[0]}@`;
-}
-
-define('ignores-page-sk', class extends ElementSk {
-  constructor() {
-    super(template);
-
-    this._rules = [];
-    this._paramset = {};
-    this._countAllTraces = false;
-
-    this._stateChanged = stateReflector(
-      /* getState */() => ({
-        // provide empty values
-        count_all: this._countAllTraces,
-      }), /* setState */(newState) => {
-        if (!this._connected) {
-          return;
-        }
-
-        // default values if not specified.
-        this._countAllTraces = newState.count_all || false;
-        this._fetch();
-        this._render();
-      },
-    );
-    // Allows us to abort fetches if we fetch again.
-    this._fetchController = null;
-    // This is the dialog element for creating or editing rules.
-    this._editIgnoreRuleDialog = null;
-    this._ruleID = '';
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    this._render();
-    this._editIgnoreRuleDialog = $$('#edit-ignore-rule-dialog', this);
-    dialogPolyfill.registerDialog(this._editIgnoreRuleDialog);
-  }
-
-  _deleteIgnoreRule(rule) {
-    const dialog = $$('confirm-dialog-sk', this);
-    dialog.open('Are you sure you want to delete '
-      + 'this ignore rule?').then(() => {
-      sendBeginTask(this);
-      fetch(`/json/v1/ignores/del/${rule.id}`, {
-        method: 'POST',
-      }).then(jsonOrThrow).then(() => {
-        this._fetch();
-        sendEndTask(this);
-      }).catch((e) => sendFetchError(this, e, 'deleting ignore'));
-    });
-  }
-
-  _editIgnoreRule(rule) {
-    const editor = $$('edit-ignore-rule-sk', this);
-    editor.reset();
-    editor.query = rule.query;
-    editor.note = rule.note;
-    editor.expires = rule.expires;
-    this._ruleID = rule.id;
-    this._render();
-    this._editIgnoreRuleDialog.showModal();
-  }
-
-  _fetch() {
-    if (this._fetchController) {
-      // Kill any outstanding requests
-      this._fetchController.abort();
-    }
-
-    // Make a fresh abort controller for each set of fetches.
-    // They cannot be re-used once aborted.
-    this._fetchController = new AbortController();
-    const extra = {
-      signal: this._fetchController.signal,
-    };
-
-    sendBeginTask(this);
-    sendBeginTask(this);
-
-    // We always want the counts of the ignore rules, thus the parameter counts=1.
-    fetch('/json/v1/ignores?counts=1', extra)
-      .then(jsonOrThrow)
-      .then((arr) => {
-        this._rules = arr || [];
-        this._render();
-        sendEndTask(this);
-      })
-      .catch((e) => sendFetchError(this, e, 'ignores'));
-
-    fetch('/json/v1/paramset', extra)
-      .then(jsonOrThrow)
-      .then((paramset) => {
-        this._paramset = paramset;
-        this._render();
-        sendEndTask(this);
-      })
-      .catch((e) => sendFetchError(this, e, 'paramset'));
-  }
-
-  _newIgnoreRule() {
-    const editor = $$('edit-ignore-rule-sk', this);
-    editor.reset();
-    this._ruleID = '';
-    this._render();
-    this._editIgnoreRuleDialog.showModal();
-  }
-
-  _saveIgnoreRule() {
-    const editor = $$('edit-ignore-rule-sk', this);
-    if (editor.verifyFields()) {
-      const body = {
-        duration: editor.expires,
-        filter: editor.query,
-        note: editor.note,
-      };
-      // TODO(kjlubick) remove the / from the json endpoint
-      let url = '/json/v1/ignores/add/';
-      if (this._ruleID) {
-        url = `/json/v1/ignores/save/${this._ruleID}`;
-      }
-
-      sendBeginTask(this);
-      fetch(url, {
-        method: 'POST',
-        headers: {
-          'Content-Type': 'application/json',
-        },
-        body: JSON.stringify(body),
-      }).then(jsonOrThrow).then(() => {
-        this._fetch();
-        sendEndTask(this);
-      }).catch((e) => sendFetchError(this, e, 'saving ignore'));
-
-      editor.reset();
-      this._editIgnoreRuleDialog.close();
-    }
-  }
-
-  _toggleCountAll(e) {
-    e.preventDefault();
-    this._countAllTraces = !this._countAllTraces;
-    this._stateChanged();
-    this._render();
-  }
-});
diff --git a/golden/modules/ignores-page-sk/ignores-page-sk.ts b/golden/modules/ignores-page-sk/ignores-page-sk.ts
new file mode 100644
index 0000000..3c21e05
--- /dev/null
+++ b/golden/modules/ignores-page-sk/ignores-page-sk.ts
@@ -0,0 +1,255 @@
+/**
+ * @module module/ignores-page-sk
+ * @description <h2><code>ignores-page-sk</code></h2>
+ *
+ * Page to view/edit/delete ignore rules.
+ */
+
+import * as human from 'common-sk/modules/human';
+import dialogPolyfill from 'dialog-polyfill';
+
+import { classMap } from 'lit-html/directives/class-map';
+import { define } from 'elements-sk/define';
+import { html } from 'lit-html';
+import { stateReflector } from 'common-sk/modules/stateReflector';
+import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow';
+import { ElementSk } from '../../../infra-sk/modules/ElementSk';
+import { escapeAndLinkify } from '../../../infra-sk/modules/linkify';
+import {
+  humanReadableQuery, sendBeginTask, sendEndTask, sendFetchError,
+} from '../common';
+import { IgnoreRule, IgnoreRuleBody, IgnoresResponse, ParamSet } from '../rpc_types';
+import { EditIgnoreRuleSk } from '../edit-ignore-rule-sk/edit-ignore-rule-sk';
+import { ConfirmDialogSk } from '../../../infra-sk/modules/confirm-dialog-sk/confirm-dialog-sk';
+
+import '../../../infra-sk/modules/confirm-dialog-sk';
+import '../edit-ignore-rule-sk';
+import 'elements-sk/checkbox-sk';
+import 'elements-sk/icon/delete-icon-sk';
+import 'elements-sk/icon/info-outline-icon-sk';
+import 'elements-sk/icon/mode-edit-icon-sk';
+import 'elements-sk/styles/buttons';
+
+function trimEmail(s: string) {
+  return `${s.split('@')[0]}@`;
+}
+
+export class IgnoresPageSk extends ElementSk {
+  private static template = (ele: IgnoresPageSk) => html`
+    <div class=controls>
+      <checkbox-sk label="Only count traces with untriaged digests"
+                   ?checked=${!ele.countAllTraces} @click=${ele.toggleCountAll}></checkbox-sk>
+
+      <button @click=${ele.newIgnoreRule} class=create>Create new ignore rule</button>
+    </div>
+
+    <confirm-dialog-sk></confirm-dialog-sk>
+
+    <dialog id=edit-ignore-rule-dialog>
+      <h2>${ele.ruleID ? 'Edit Ignore Rule' : 'Create Ignore Rule'}</h2>
+      <edit-ignore-rule-sk .paramset=${ele.paramset}></edit-ignore-rule-sk>
+      <button @click=${() => ele.editIgnoreRuleDialog?.close()}>Cancel</button>
+      <button id=ok class=action @click=${ele.saveIgnoreRule}>
+        ${ele.ruleID ? 'Update' : 'Create'}
+      </button>
+    </dialog>
+
+    <table>
+      <thead>
+        <tr>
+          <th colspan=2>Filter</th>
+          <th>Note</th>
+          <th> Traces matched <br> exclusive/all
+            <info-outline-icon-sk class=small-icon
+                title="'all' is the number of traces that a given ignore rule applies to. \
+    'exclusive' is the number of traces which are matched by the given ignore rule and no other \
+    ignore rule of the rules in this list. If the checkbox is checked to only count traces with \
+    untriaged digests, it means 'untriaged digests at head', which is typically an indication of \
+    a flaky test/config.">
+            </info-outline-icon-sk>
+          </th>
+          <th>Expires in</th>
+          <th>Created by</th>
+          <th>Updated by</th>
+        </tr>
+      </thead>
+      <tbody>
+      ${ele.rules.map((r) => IgnoresPageSk.ruleTemplate(ele, r))}
+      </tbody>
+    </table>
+  `;
+
+  private static ruleTemplate = (ele: IgnoresPageSk, r: IgnoreRule) => {
+    const isExpired = Date.parse(r.expires) < Date.now();
+    return html`
+      <tr class=${classMap({ expired: isExpired })}>
+        <td class=mutate-icons>
+          <mode-edit-icon-sk title="Edit this rule."
+              @click=${() => ele.editIgnoreRule(r)}></mode-edit-icon-sk>
+          <delete-icon-sk title="Delete this rule."
+              @click=${() => ele.deleteIgnoreRule(r)}></delete-icon-sk>
+        </td>
+        <td class=query><a href=${`/list?include=true&query=${encodeURIComponent(r.query)}`}
+          >${humanReadableQuery(r.query)}</a></td>
+        <td>${escapeAndLinkify(r.note) || '--'}</td>
+        <td class=matches title="These counts are recomputed every few minutes.">
+          ${ele.countAllTraces ? r.exclusiveCountAll : r.exclusiveCount} /
+          ${ele.countAllTraces ? r.countAll : r.count}
+        </td>
+        <td class=${classMap({ expired: isExpired })}>
+          ${isExpired ? 'Expired' : human.diffDate(r.expires)}
+        </td>
+        <td title=${`Originally created by ${r.name}`}>${trimEmail(r.name)}</td>
+        <td title=${`Last updated by ${r.updatedBy}`}>
+          ${r.name === r.updatedBy ? '' : trimEmail(r.updatedBy)}
+        </td>
+      </tr>
+    `;
+  };
+
+  private rules: IgnoreRule[] = [];
+  private paramset: ParamSet = {};
+  private countAllTraces = false;
+  private ruleID = '';
+
+  private editIgnoreRuleDialog?: HTMLDialogElement; // Dialog for creating or editing rules.
+  private editIgnoreRuleSk?: EditIgnoreRuleSk;
+  private confirmDialogSk?: ConfirmDialogSk;
+
+  private readonly stateChanged: () => void;
+  private fetchController?: AbortController; // Allows us to abort fetches if we fetch again.
+
+  constructor() {
+    super(IgnoresPageSk.template);
+
+    this.stateChanged = stateReflector(
+      /* getState */() => ({
+        // provide empty values
+        count_all: this.countAllTraces,
+      }), /* setState */(newState) => {
+        if (!this._connected) {
+          return;
+        }
+
+        // default values if not specified.
+        this.countAllTraces = newState.count_all as boolean || false;
+        this.fetch();
+        this._render();
+      },
+    );
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._render();
+    this.editIgnoreRuleDialog = this.querySelector<HTMLDialogElement>('#edit-ignore-rule-dialog')!;
+    dialogPolyfill.registerDialog(this.editIgnoreRuleDialog);
+    this.editIgnoreRuleSk = this.querySelector<EditIgnoreRuleSk>('edit-ignore-rule-sk')!;
+    this.confirmDialogSk = this.querySelector<ConfirmDialogSk>('confirm-dialog-sk')!;
+  }
+
+  private deleteIgnoreRule(rule: IgnoreRule) {
+    this.confirmDialogSk!.open('Are you sure you want to delete '
+      + 'this ignore rule?').then(() => {
+      sendBeginTask(this);
+      fetch(`/json/v1/ignores/del/${rule.id}`, {
+        method: 'POST',
+      }).then(jsonOrThrow).then(() => {
+        this.fetch();
+        sendEndTask(this);
+      }).catch((e) => sendFetchError(this, e, 'deleting ignore'));
+    });
+  }
+
+  private editIgnoreRule(rule: IgnoreRule) {
+    this.editIgnoreRuleSk!.reset();
+    this.editIgnoreRuleSk!.query = rule.query;
+    this.editIgnoreRuleSk!.note = rule.note;
+    this.editIgnoreRuleSk!.expires = rule.expires;
+    this.ruleID = rule.id;
+    this._render();
+    this.editIgnoreRuleDialog!.showModal();
+  }
+
+  private fetch() {
+    if (this.fetchController) {
+      // Kill any outstanding requests
+      this.fetchController.abort();
+    }
+
+    // Make a fresh abort controller for each set of fetches.
+    // They cannot be re-used once aborted.
+    this.fetchController = new AbortController();
+    const extra = {
+      signal: this.fetchController.signal,
+    };
+
+    sendBeginTask(this);
+    sendBeginTask(this);
+
+    // We always want the counts of the ignore rules, thus the parameter counts=1.
+    fetch('/json/v1/ignores?counts=1', extra)
+      .then(jsonOrThrow)
+      .then((response: IgnoresResponse) => {
+        this.rules = response.rules || [];
+        this._render();
+        sendEndTask(this);
+      })
+      .catch((e) => sendFetchError(this, e, 'ignores'));
+
+    fetch('/json/v1/paramset', extra)
+      .then(jsonOrThrow)
+      .then((paramset: ParamSet) => {
+        this.paramset = paramset;
+        this._render();
+        sendEndTask(this);
+      })
+      .catch((e) => sendFetchError(this, e, 'paramset'));
+  }
+
+  private newIgnoreRule() {
+    this.editIgnoreRuleSk!.reset();
+    this.ruleID = '';
+    this._render();
+    this.editIgnoreRuleDialog!.showModal();
+  }
+
+  private saveIgnoreRule() {
+    if (this.editIgnoreRuleSk!.verifyFields()) {
+      const body: IgnoreRuleBody = {
+        duration: this.editIgnoreRuleSk!.expires,
+        filter: this.editIgnoreRuleSk!.query,
+        note: this.editIgnoreRuleSk!.note,
+      };
+      // TODO(kjlubick) remove the / from the json endpoint
+      let url = '/json/v1/ignores/add/';
+      if (this.ruleID) {
+        url = `/json/v1/ignores/save/${this.ruleID}`;
+      }
+
+      sendBeginTask(this);
+      fetch(url, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify(body),
+      }).then(jsonOrThrow).then(() => {
+        this.fetch();
+        sendEndTask(this);
+      }).catch((e) => sendFetchError(this, e, 'saving ignore'));
+
+      this.editIgnoreRuleSk!.reset();
+      this.editIgnoreRuleDialog!.close();
+    }
+  }
+
+  private toggleCountAll(e: Event) {
+    e.preventDefault();
+    this.countAllTraces = !this.countAllTraces;
+    this.stateChanged();
+    this._render();
+  }
+}
+
+define('ignores-page-sk', IgnoresPageSk);
diff --git a/golden/modules/ignores-page-sk/ignores-page-sk_test.js b/golden/modules/ignores-page-sk/ignores-page-sk_test.ts
similarity index 73%
rename from golden/modules/ignores-page-sk/ignores-page-sk_test.js
rename to golden/modules/ignores-page-sk/ignores-page-sk_test.ts
index df25f94..03d197e 100644
--- a/golden/modules/ignores-page-sk/ignores-page-sk_test.js
+++ b/golden/modules/ignores-page-sk/ignores-page-sk_test.ts
@@ -9,13 +9,18 @@
   setQueryString,
   setUpElementUnderTest,
 } from '../../../infra-sk/modules/test_util';
+import { IgnoresPageSk } from './ignores-page-sk';
+import { ParamSet } from '../rpc_types';
 import { fakeNow, ignoreRules_10 } from './test_data';
+import { CheckOrRadio } from 'elements-sk/checkbox-sk/checkbox-sk';
+import { EditIgnoreRuleSk } from '../edit-ignore-rule-sk/edit-ignore-rule-sk';
+import { expect } from 'chai';
 
-describe('ignores-page-sk', () => {
-  const newInstance = setUpElementUnderTest('ignores-page-sk');
+describe.only('ignores-page-sk', () => {
+  const newInstance = setUpElementUnderTest<IgnoresPageSk>('ignores-page-sk');
 
   const regularNow = Date.now;
-  let ignoresPageSk;
+  let ignoresPageSk: IgnoresPageSk;
 
   beforeEach(async () => {
     // Clear out any query params we might have to not mess with our current state.
@@ -24,7 +29,7 @@
     fetchMock.get('/json/v1/ignores?counts=1', ignoreRules_10);
     // We only need a few params to make sure the edit-ignore-rule-dialog works properly and it
     // does not matter really what they are, so we use a small subset of actual params.
-    const someParams = {
+    const someParams: ParamSet = {
       alpha_type: ['Opaque', 'Premul'],
       arch: ['arm', 'arm64', 'x86', 'x86_64'],
     };
@@ -55,7 +60,7 @@
 
     it('creates links to test the filter', () => {
       const rows = $('table tbody tr', ignoresPageSk);
-      const queryLink = $$('.query a', rows[9]);
+      const queryLink = $$<HTMLAnchorElement>('.query a', rows[9])!;
       expect(queryLink.href).to.contain(
         'include=true&query=config%3Dglmsaa4%26cpu_or_gpu_value%3DTegraX1%26name%3Drg1024_green_grapes.svg',
       );
@@ -68,12 +73,12 @@
       const rows = $('table tbody tr', ignoresPageSk);
       const firstRow = rows[0];
       expect(firstRow.className).to.contain('expired');
-      let timeBox = $$('.expired', firstRow);
+      let timeBox = $$<HTMLElement>('.expired', firstRow)!;
       expect(timeBox.innerText).to.contain('Expired');
 
       const fourthRow = rows[4];
       expect(fourthRow.className).to.not.contain('expired');
-      timeBox = $$('.expired', fourthRow);
+      timeBox = $$<HTMLElement>('.expired', fourthRow)!;
       expect(timeBox).to.be.null;
     });
   }); // end describe('html layout')
@@ -82,21 +87,21 @@
     it('toggles between counting traces with untriaged digests and all traces', () => {
       let checkbox = findUntriagedDigestsCheckbox(ignoresPageSk);
       expect(checkbox.checked).to.be.true;
-      expect(findMatchesTextForRow(2, ignoresPageSk)).to.contain('0 / 4');
+      expect(findMatchesTextForRow(ignoresPageSk, 2)).to.contain('0 / 4');
       expectQueryStringToEqual('');
 
       clickUntriagedDigestsCheckbox(ignoresPageSk);
 
       checkbox = findUntriagedDigestsCheckbox(ignoresPageSk);
       expect(checkbox.checked).to.be.false;
-      expect(findMatchesTextForRow(2, ignoresPageSk)).to.contain('6 / 10');
+      expect(findMatchesTextForRow(ignoresPageSk, 2)).to.contain('6 / 10');
       expectQueryStringToEqual('?count_all=true');
 
       clickUntriagedDigestsCheckbox(ignoresPageSk);
 
       checkbox = findUntriagedDigestsCheckbox(ignoresPageSk);
       expect(checkbox.checked).to.be.true;
-      expect(findMatchesTextForRow(2, ignoresPageSk)).to.contain('0 / 4');
+      expect(findMatchesTextForRow(ignoresPageSk, 2)).to.contain('0 / 4');
       expectQueryStringToEqual('');
     });
 
@@ -120,17 +125,17 @@
       const dialog = findConfirmDeleteDialog(ignoresPageSk);
       expect(dialog.hasAttribute('open')).to.be.false;
 
-      const del = findDeleteForRow(2);
+      const del = findDeleteForRow(ignoresPageSk, 2);
       del.click();
 
       expect(dialog.hasAttribute('open')).to.be.true;
-      const msg = $$('.message', dialog);
+      const msg = $$<HTMLElement>('.message', dialog)!;
       expect(msg.innerText).to.contain('Are you sure you want to delete');
     });
 
     it('deletes an existing ignore rule', async () => {
       const idOfThirdRule = '7589748925671328782';
-      const del = findDeleteForRow(2);
+      const del = findDeleteForRow(ignoresPageSk, 2);
       del.click();
 
       fetchMock.post(`/json/v1/ignores/del/${idOfThirdRule}`, '{"deleted": "true"}');
@@ -167,7 +172,7 @@
 
     it('updates an existing ignore rule', async () => {
       const idOfThirdRule = '7589748925671328782';
-      const edit = findUpdateForRow(2);
+      const edit = findUpdateForRow(ignoresPageSk, 2);
       edit.click();
 
       const dialog = findCreateEditIgnoreRuleDialog(ignoresPageSk);
@@ -201,59 +206,58 @@
   });
 });
 
-function findUntriagedDigestsCheckbox(ele) {
-  return $$('.controls checkbox-sk', ele);
+function findUntriagedDigestsCheckbox(ele: IgnoresPageSk): CheckOrRadio {
+  return $$<CheckOrRadio>('.controls checkbox-sk', ele)!;
 }
 
-function findMatchesTextForRow(n, ele) {
-  const row = $('table tbody tr', ele)[n];
-  const cell = $$('td.matches', row);
+function findMatchesTextForRow(ele: IgnoresPageSk, n: number): string {
+  const row = $<HTMLTableRowElement>('table tbody tr', ele)[n];
+  const cell = $$<HTMLTableDataCellElement>('td.matches', row)!;
   // condense all whitespace and then trim to avoid the formatting of
   // the html from impacting the tests too much (e.g. extraneous \n)
   return cell.innerText;
 }
 
-function findDeleteForRow(n, ele) {
+function findDeleteForRow(ele: IgnoresPageSk, n: number): HTMLElement {
   const row = $('table tbody tr', ele)[n];
-  return $$('.mutate-icons delete-icon-sk', row);
+  return $$<HTMLElement>('.mutate-icons delete-icon-sk', row)!;
 }
 
-function findUpdateForRow(n, ele) {
+function findUpdateForRow(ele: IgnoresPageSk, n: number): HTMLElement {
   const row = $('table tbody tr', ele)[n];
-  return $$('.mutate-icons mode-edit-icon-sk', row);
+  return $$<HTMLElement>('.mutate-icons mode-edit-icon-sk', row)!;
 }
 
-function findConfirmDeleteDialog(ele) {
-  return $$('confirm-dialog-sk dialog', ele);
+function findConfirmDeleteDialog(ele: IgnoresPageSk): HTMLDialogElement {
+  return $$<HTMLDialogElement>('confirm-dialog-sk dialog', ele)!;
 }
 
-function findCreateEditIgnoreRuleDialog(ele) {
-  return $$('dialog#edit-ignore-rule-dialog', ele);
+function findCreateEditIgnoreRuleDialog(ele: IgnoresPageSk): HTMLDialogElement {
+  return $$<HTMLDialogElement>('dialog#edit-ignore-rule-dialog', ele)!;
 }
 
-function findConfirmSaveIgnoreRuleButton(ele) {
-  return $$('#edit-ignore-rule-dialog button#ok', ele);
+function findConfirmSaveIgnoreRuleButton(ele: IgnoresPageSk): HTMLButtonElement {
+  return $$<HTMLButtonElement>('#edit-ignore-rule-dialog button#ok', ele)!;
 }
 
-function clickUntriagedDigestsCheckbox(ele) {
+function clickUntriagedDigestsCheckbox(ele: IgnoresPageSk) {
   // We need to click on the input element to accurately mimic a user event. This is
   // because the checkbox-sk element listens for the click event created by the
   // internal input event.
-  const input = $$('input[type="checkbox"]', findUntriagedDigestsCheckbox(ele));
+  const input = $$<HTMLInputElement>('input[type="checkbox"]', findUntriagedDigestsCheckbox(ele))!;
   input.click();
 }
 
-function clickConfirmDeleteButton(ele) {
-  const ok = $$('button.confirm', findConfirmDeleteDialog(ele));
-  ok.click();
+function clickConfirmDeleteButton(ele: IgnoresPageSk) {
+  $$<HTMLButtonElement>('button.confirm', findConfirmDeleteDialog(ele))!.click();
 }
 
-function clickCreateIgnoreRuleButton(ele) {
-  $$('.controls button.create', ele).click();
+function clickCreateIgnoreRuleButton(ele: IgnoresPageSk) {
+  $$<HTMLButtonElement>('.controls button.create', ele)!.click();
 }
 
-function setIgnoreRuleProperties(ele, query, expires, note) {
-  const editor = $$('edit-ignore-rule-sk', findCreateEditIgnoreRuleDialog(ele));
+function setIgnoreRuleProperties(ele: IgnoresPageSk, query: string, expires: string, note: string) {
+  const editor = $$<EditIgnoreRuleSk>('edit-ignore-rule-sk', findCreateEditIgnoreRuleDialog(ele))!;
   editor.query = query;
   editor.expires = expires;
   editor.note = note;
diff --git a/golden/modules/ignores-page-sk/index.js b/golden/modules/ignores-page-sk/index.ts
similarity index 100%
rename from golden/modules/ignores-page-sk/index.js
rename to golden/modules/ignores-page-sk/index.ts
diff --git a/golden/modules/ignores-page-sk/test_data.js b/golden/modules/ignores-page-sk/test_data.js
deleted file mode 100644
index 996f416..0000000
--- a/golden/modules/ignores-page-sk/test_data.js
+++ /dev/null
@@ -1,124 +0,0 @@
-export const fakeNow = Date.parse('2019-12-30T00:00:00Z');
-
-export const ignoreRules_10 = [
-  {
-    id: '7589748926651362910',
-    name: 'alpha@example.com',
-    updatedBy: 'alpha@example.com',
-    expires: '2018-10-18T23:18:39Z',
-    query: 'config=gles\u0026model=iPhone7\u0026name=glyph_pos_h_s_this_is_a_super_long_test_name_or_key_value',
-    note: 'skia:7204',
-    countAll: 2,
-    exclusiveCountAll: 0,
-    count: 2,
-    exclusiveCount: 0,
-  },
-  {
-    id: '7589748926390545630',
-    name: 'alpha@example.com',
-    updatedBy: 'alpha@example.com',
-    expires: '2018-11-15T00:51:14Z',
-    query: 'config=angle_d3d9_es2\u0026cpu_or_gpu_value=RadeonR9M470X\u0026name=alpha_image\u0026name=colorfilterimagefilter_layer\u0026name=gradients_no_texture\u0026name=lighting\u0026name=multipicturedraw_sierpinski_simple\u0026name=radial_gradient\u0026name=shadermaskfilter_gradient\u0026name=shadermaskfilter_image\u0026name=srgb_colorfilter\u0026name=xfermodes3',
-    note: 'skia:7245',
-    countAll: 18,
-    exclusiveCountAll: 18,
-    count: 15,
-    exclusiveCount: 15,
-  },
-  {
-    id: '7589748925671328782',
-    name: 'alpha@example.com',
-    updatedBy: 'alpha@example.com',
-    expires: '2019-04-03T20:35:12Z',
-    query: 'config=glmsaa8\u0026cpu_or_gpu_value=GTX660\u0026cpu_or_gpu_value=GTX960\u0026cpu_or_gpu_value=QuadroP400\u0026name=car.svg',
-    note: 'skia:6545',
-    countAll: 10,
-    exclusiveCountAll: 6,
-    count: 4,
-    exclusiveCount: 0,
-  },
-  {
-    id: '7589748925255618990',
-    name: 'alpha@example.com',
-    updatedBy: 'beta@example.com',
-    expires: '2019-12-29T17:54:13Z',
-    query: 'config=enarrow\u0026config=esrgb\u0026config=f16\u0026config=glenarrow\u0026config=glessrgb\u0026config=glsrgb\u0026config=narrow',
-    note: '',
-    countAll: 135459,
-    exclusiveCountAll: 133449,
-    count: 62701,
-    exclusiveCount: 61895,
-  },
-  {
-    id: '7589748924822791934',
-    name: 'alpha@example.com',
-    updatedBy: 'alpha@example.com',
-    expires: '2019-12-30T08:56:24Z',
-    query: 'config=angle_gl_es2_msaa8\u0026config=angle_gl_es3_msaa8\u0026config=glmsaa8\u0026cpu_or_gpu_value=GTX660\u0026cpu_or_gpu_value=GTX960\u0026name=car.svg\u0026name=draw_image_set\u0026name=filltypespersp\u0026name=gallardo.svg\u0026name=rg1024_green_grapes.svg\u0026os=Win10',
-    note: 'skia:6813',
-    countAll: 60,
-    exclusiveCountAll: 40,
-    count: 36,
-    exclusiveCount: 28,
-  },
-  {
-    id: '7589748924681439406',
-    name: 'alpha@example.com',
-    updatedBy: 'alpha@example.com',
-    expires: '2020-01-01T02:15:23Z',
-    query: 'config=gles\u0026config=glesdft\u0026cpu_or_gpu=GPU\u0026cpu_or_gpu_value=Tegra3\u0026name=bleed_alpha_bmp\u0026name=bleed_alpha_bmp_shader\u0026name=bleed_alpha_image\u0026name=bleed_alpha_image_shader',
-    note: 'skia:5013',
-    countAll: 16,
-    exclusiveCountAll: 16,
-    count: 16,
-    exclusiveCount: 16,
-  },
-  {
-    id: '7589748924269153262',
-    name: 'alpha@example.com',
-    updatedBy: 'alpha@example.com',
-    expires: '2020-01-04T03:10:51Z',
-    query: 'config=glmsaa8\u0026cpu_or_gpu_value=GTX660\u0026cpu_or_gpu_value=GTX960\u0026name=dstreadshuffle\u0026name=rg1024_green_grapes.svg\u0026name=tiger-8.svg',
-    note: 'skia:6545',
-    countAll: 12,
-    exclusiveCountAll: 8,
-    count: 10,
-    exclusiveCount: 6,
-  },
-  {
-    id: '7589748924016733918',
-    name: 'alpha@example.com',
-    updatedBy: 'alpha@example.com',
-    expires: '2020-01-12T17:07:57Z',
-    query: 'config=angle_d3d11_es3\u0026model=LenovoYogaC630\u0026name=car.svg\u0026name=gallardo.svg',
-    note: 'skia:8976',
-    countAll: 2,
-    exclusiveCountAll: 2,
-    count: 2,
-    exclusiveCount: 2,
-  },
-  {
-    id: '7589748923887311614',
-    name: 'alpha@example.com',
-    updatedBy: 'gamma@example.com',
-    expires: '2020-01-17T16:54:11Z',
-    query: 'config=angle_d3d11_es2_msaa8\u0026config=angle_d3d11_es3_msaa8\u0026cpu_or_gpu_value=QuadroP400\u0026name=Chalkboard.svg\u0026name=bezier_quad_effects\u0026name=bleed\u0026name=bleed_alpha_bmp\u0026name=bleed_alpha_bmp_shader\u0026name=bleed_alpha_image\u0026name=bleed_alpha_image_shader\u0026name=bleed_image\u0026name=car.svg\u0026name=circular_arcs_fill\u0026name=circular_arcs_stroke_and_fill_butt\u0026name=circular_arcs_stroke_and_fill_round\u0026name=circular_arcs_stroke_and_fill_square\u0026name=circular_arcs_stroke_butt\u0026name=circular_arcs_stroke_round\u0026name=circular_arcs_stroke_square\u0026name=filltypespersp\u0026name=gallardo.svg\u0026name=glyph_pos_n_b\u0026name=glyph_pos_n_s\u0026name=hittestpath\u0026name=image_scale_aligned\u0026name=pictureimagegenerator\u0026name=repeated_bitmap\u0026name=rg1024_green_grapes.svg\u0026name=shadow_utils_gray\u0026name=shadow_utils_occl\u0026name=verylarge_picture_image\u0026name=verylargebitmap\u0026os=Win10',
-    note: 'skia:6813',
-    countAll: 116,
-    exclusiveCountAll: 92,
-    count: 100,
-    exclusiveCount: 76,
-  },
-  {
-    id: '7589748923749696270',
-    name: 'alpha@example.com',
-    updatedBy: 'alpha@example.com',
-    expires: '2020-01-25T17:15:25Z',
-    query: 'config=glmsaa4\u0026cpu_or_gpu_value=TegraX1\u0026name=rg1024_green_grapes.svg',
-    note: 'skia:6545',
-    countAll: 4,
-    exclusiveCountAll: 4,
-    count: 4,
-    exclusiveCount: 4,
-  },
-];
diff --git a/golden/modules/ignores-page-sk/test_data.ts b/golden/modules/ignores-page-sk/test_data.ts
new file mode 100644
index 0000000..5be5f8b
--- /dev/null
+++ b/golden/modules/ignores-page-sk/test_data.ts
@@ -0,0 +1,128 @@
+import { IgnoresResponse } from '../rpc_types';
+
+export const fakeNow = Date.parse('2019-12-30T00:00:00Z');
+
+export const ignoreRules_10: IgnoresResponse = {
+  rules: [
+    {
+      id: '7589748926651362910',
+      name: 'alpha@example.com',
+      updatedBy: 'alpha@example.com',
+      expires: '2018-10-18T23:18:39Z',
+      query: 'config=gles\u0026model=iPhone7\u0026name=glyph_pos_h_s_this_is_a_super_long_test_name_or_key_value',
+      note: 'skia:7204',
+      countAll: 2,
+      exclusiveCountAll: 0,
+      count: 2,
+      exclusiveCount: 0,
+    },
+    {
+      id: '7589748926390545630',
+      name: 'alpha@example.com',
+      updatedBy: 'alpha@example.com',
+      expires: '2018-11-15T00:51:14Z',
+      query: 'config=angle_d3d9_es2\u0026cpu_or_gpu_value=RadeonR9M470X\u0026name=alpha_image\u0026name=colorfilterimagefilter_layer\u0026name=gradients_no_texture\u0026name=lighting\u0026name=multipicturedraw_sierpinski_simple\u0026name=radial_gradient\u0026name=shadermaskfilter_gradient\u0026name=shadermaskfilter_image\u0026name=srgb_colorfilter\u0026name=xfermodes3',
+      note: 'skia:7245',
+      countAll: 18,
+      exclusiveCountAll: 18,
+      count: 15,
+      exclusiveCount: 15,
+    },
+    {
+      id: '7589748925671328782',
+      name: 'alpha@example.com',
+      updatedBy: 'alpha@example.com',
+      expires: '2019-04-03T20:35:12Z',
+      query: 'config=glmsaa8\u0026cpu_or_gpu_value=GTX660\u0026cpu_or_gpu_value=GTX960\u0026cpu_or_gpu_value=QuadroP400\u0026name=car.svg',
+      note: 'skia:6545',
+      countAll: 10,
+      exclusiveCountAll: 6,
+      count: 4,
+      exclusiveCount: 0,
+    },
+    {
+      id: '7589748925255618990',
+      name: 'alpha@example.com',
+      updatedBy: 'beta@example.com',
+      expires: '2019-12-29T17:54:13Z',
+      query: 'config=enarrow\u0026config=esrgb\u0026config=f16\u0026config=glenarrow\u0026config=glessrgb\u0026config=glsrgb\u0026config=narrow',
+      note: '',
+      countAll: 135459,
+      exclusiveCountAll: 133449,
+      count: 62701,
+      exclusiveCount: 61895,
+    },
+    {
+      id: '7589748924822791934',
+      name: 'alpha@example.com',
+      updatedBy: 'alpha@example.com',
+      expires: '2019-12-30T08:56:24Z',
+      query: 'config=angle_gl_es2_msaa8\u0026config=angle_gl_es3_msaa8\u0026config=glmsaa8\u0026cpu_or_gpu_value=GTX660\u0026cpu_or_gpu_value=GTX960\u0026name=car.svg\u0026name=draw_image_set\u0026name=filltypespersp\u0026name=gallardo.svg\u0026name=rg1024_green_grapes.svg\u0026os=Win10',
+      note: 'skia:6813',
+      countAll: 60,
+      exclusiveCountAll: 40,
+      count: 36,
+      exclusiveCount: 28,
+    },
+    {
+      id: '7589748924681439406',
+      name: 'alpha@example.com',
+      updatedBy: 'alpha@example.com',
+      expires: '2020-01-01T02:15:23Z',
+      query: 'config=gles\u0026config=glesdft\u0026cpu_or_gpu=GPU\u0026cpu_or_gpu_value=Tegra3\u0026name=bleed_alpha_bmp\u0026name=bleed_alpha_bmp_shader\u0026name=bleed_alpha_image\u0026name=bleed_alpha_image_shader',
+      note: 'skia:5013',
+      countAll: 16,
+      exclusiveCountAll: 16,
+      count: 16,
+      exclusiveCount: 16,
+    },
+    {
+      id: '7589748924269153262',
+      name: 'alpha@example.com',
+      updatedBy: 'alpha@example.com',
+      expires: '2020-01-04T03:10:51Z',
+      query: 'config=glmsaa8\u0026cpu_or_gpu_value=GTX660\u0026cpu_or_gpu_value=GTX960\u0026name=dstreadshuffle\u0026name=rg1024_green_grapes.svg\u0026name=tiger-8.svg',
+      note: 'skia:6545',
+      countAll: 12,
+      exclusiveCountAll: 8,
+      count: 10,
+      exclusiveCount: 6,
+    },
+    {
+      id: '7589748924016733918',
+      name: 'alpha@example.com',
+      updatedBy: 'alpha@example.com',
+      expires: '2020-01-12T17:07:57Z',
+      query: 'config=angle_d3d11_es3\u0026model=LenovoYogaC630\u0026name=car.svg\u0026name=gallardo.svg',
+      note: 'skia:8976',
+      countAll: 2,
+      exclusiveCountAll: 2,
+      count: 2,
+      exclusiveCount: 2,
+    },
+    {
+      id: '7589748923887311614',
+      name: 'alpha@example.com',
+      updatedBy: 'gamma@example.com',
+      expires: '2020-01-17T16:54:11Z',
+      query: 'config=angle_d3d11_es2_msaa8\u0026config=angle_d3d11_es3_msaa8\u0026cpu_or_gpu_value=QuadroP400\u0026name=Chalkboard.svg\u0026name=bezier_quad_effects\u0026name=bleed\u0026name=bleed_alpha_bmp\u0026name=bleed_alpha_bmp_shader\u0026name=bleed_alpha_image\u0026name=bleed_alpha_image_shader\u0026name=bleed_image\u0026name=car.svg\u0026name=circular_arcs_fill\u0026name=circular_arcs_stroke_and_fill_butt\u0026name=circular_arcs_stroke_and_fill_round\u0026name=circular_arcs_stroke_and_fill_square\u0026name=circular_arcs_stroke_butt\u0026name=circular_arcs_stroke_round\u0026name=circular_arcs_stroke_square\u0026name=filltypespersp\u0026name=gallardo.svg\u0026name=glyph_pos_n_b\u0026name=glyph_pos_n_s\u0026name=hittestpath\u0026name=image_scale_aligned\u0026name=pictureimagegenerator\u0026name=repeated_bitmap\u0026name=rg1024_green_grapes.svg\u0026name=shadow_utils_gray\u0026name=shadow_utils_occl\u0026name=verylarge_picture_image\u0026name=verylargebitmap\u0026os=Win10',
+      note: 'skia:6813',
+      countAll: 116,
+      exclusiveCountAll: 92,
+      count: 100,
+      exclusiveCount: 76,
+    },
+    {
+      id: '7589748923749696270',
+      name: 'alpha@example.com',
+      updatedBy: 'alpha@example.com',
+      expires: '2020-01-25T17:15:25Z',
+      query: 'config=glmsaa4\u0026cpu_or_gpu_value=TegraX1\u0026name=rg1024_green_grapes.svg',
+      note: 'skia:6545',
+      countAll: 4,
+      exclusiveCountAll: 4,
+      count: 4,
+      exclusiveCount: 4,
+    },
+  ],
+};
diff --git a/golden/modules/rpc_types.ts b/golden/modules/rpc_types.ts
index a29da04..2ed23e0 100644
--- a/golden/modules/rpc_types.ts
+++ b/golden/modules/rpc_types.ts
@@ -176,6 +176,29 @@
 	total: number;
 }
 
+export interface IgnoreRuleBody {
+	duration: string;
+	filter: string;
+	note: string;
+}
+
+export interface IgnoreRule {
+	id: string;
+	name: string;
+	updatedBy: string;
+	expires: string;
+	query: string;
+	note: string;
+	countAll: number;
+	exclusiveCountAll: number;
+	count: number;
+	exclusiveCount: number;
+}
+
+export interface IgnoresResponse {
+	rules: IgnoreRule[] | null;
+}
+
 export type ParamSet = { [key: string]: string[] };
 
 export type ParamSetResponse = { [key: string]: string[] | null };