Add point-links-sk to display links related to specific data points.

- Starting with displaying commit range links for configured keys. We can extend this to include other links as well.

Review suggestions:
 - Most of the files in this change are due to a added config.
 - The main code is in point-links-sk.ts.
 - This is getting used in explore-simple-sk.ts

Bug: b/323510837
Change-Id: I0814190a72bd6b186c9ac57dd5562d1f1be8805a
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/885310
Reviewed-by: Eduardo Yap <eduardoyap@google.com>
Commit-Queue: Ashwin Verleker <ashwinpv@google.com>
diff --git a/perf/configs/chrome-perf-non-public.json b/perf/configs/chrome-perf-non-public.json
index 064c160..1df80d3 100644
--- a/perf/configs/chrome-perf-non-public.json
+++ b/perf/configs/chrome-perf-non-public.json
@@ -83,6 +83,9 @@
             "cache_expiration_minutes": 300
         }
     },
+    "data_point_config": {
+        "keys_for_commit_range": ["V8 Git Hash", "WebRTC Git Hash"]
+    },
     "temporal_config": {
         "host_port": "temporal.temporal:7233",
         "namespace": "perf-grouping",
diff --git a/perf/go/config/config.go b/perf/go/config/config.go
index 8f3d0bf..58f8d19 100644
--- a/perf/go/config/config.go
+++ b/perf/go/config/config.go
@@ -818,6 +818,12 @@
 	TaskQueue string `json:"task_queue,omitempty"`
 }
 
+// DataPointConfig contains config properties to customize how data for individual points is displayed.
+type DataPointConfig struct {
+	// The link keys to use for commit range urls.
+	KeysForCommitRange []string `json:"keys_for_commit_range,omitempty"`
+}
+
 // QueryConfig contains query customization info for the instance.
 type QueryConfig struct {
 	// IncludedParams defines the params that should be displayed in the query dialog.
@@ -927,6 +933,7 @@
 	AnomalyConfig       AnomalyConfig       `json:"anomaly_config,omitempty"`
 	QueryConfig         QueryConfig         `json:"query_config,omitempty"`
 	TemporalConfig      TemporalConfig      `json:"temporal_config,omitempty"`
+	DataPointConfig     DataPointConfig     `json:"data_point_config,omitempty"`
 
 	EnableSheriffConfig bool `json:"enable_sheriff_config,omitempty"`
 
diff --git a/perf/go/config/validate/instanceConfigSchema.json b/perf/go/config/validate/instanceConfigSchema.json
index 4176c77..767e393 100644
--- a/perf/go/config/validate/instanceConfigSchema.json
+++ b/perf/go/config/validate/instanceConfigSchema.json
@@ -73,6 +73,18 @@
         "notifications"
       ]
     },
+    "DataPointConfig": {
+      "properties": {
+        "keys_for_commit_range": {
+          "items": {
+            "type": "string"
+          },
+          "type": "array"
+        }
+      },
+      "additionalProperties": false,
+      "type": "object"
+    },
     "DataStoreConfig": {
       "properties": {
         "datastore_type": {
@@ -305,6 +317,9 @@
         "temporal_config": {
           "$ref": "#/$defs/TemporalConfig"
         },
+        "data_point_config": {
+          "$ref": "#/$defs/DataPointConfig"
+        },
         "enable_sheriff_config": {
           "type": "boolean"
         },
diff --git a/perf/go/frontend/frontend.go b/perf/go/frontend/frontend.go
index 251de1a..5578792 100644
--- a/perf/go/frontend/frontend.go
+++ b/perf/go/frontend/frontend.go
@@ -257,6 +257,7 @@
 	NeedAlertAction            bool               `json:"need_alert_action"`               // Action to take for the alert.
 	BugHostURL                 string             `json:"bug_host_url"`                    // The URL for the bug host for the instance.
 	GitRepoUrl                 string             `json:"git_repo_url"`                    // The URL for the associated git repo.
+	KeysForCommitRange         []string           `json:"keys_for_commit_range"`           // The link keys for commit range url display of individual points.
 }
 
 // getPageContext returns the value of `window.perf` serialized as JSON.
@@ -284,6 +285,7 @@
 		NeedAlertAction:            config.Config.NeedAlertAction,
 		BugHostURL:                 config.Config.BugHostUrl,
 		GitRepoUrl:                 config.Config.GitRepoConfig.URL,
+		KeysForCommitRange:         config.Config.DataPointConfig.KeysForCommitRange,
 	}
 	b, err := json.MarshalIndent(pc, "", "  ")
 	if err != nil {
diff --git a/perf/modules/anomaly-sk/anomaly-sk-demo.ts b/perf/modules/anomaly-sk/anomaly-sk-demo.ts
index 1777f2d..40a4272 100644
--- a/perf/modules/anomaly-sk/anomaly-sk-demo.ts
+++ b/perf/modules/anomaly-sk/anomaly-sk-demo.ts
@@ -23,6 +23,7 @@
   need_alert_action: false,
   bug_host_url: '',
   git_repo_url: '',
+  keys_for_commit_range: [],
 };
 
 const dummyAnomaly = (bugId: number): Anomaly => ({
diff --git a/perf/modules/anomaly-sk/anomaly-sk_test.ts b/perf/modules/anomaly-sk/anomaly-sk_test.ts
index ea6581f..075ca30 100644
--- a/perf/modules/anomaly-sk/anomaly-sk_test.ts
+++ b/perf/modules/anomaly-sk/anomaly-sk_test.ts
@@ -192,6 +192,7 @@
       need_alert_action: false,
       bug_host_url: '',
       git_repo_url: '',
+      keys_for_commit_range: [],
     };
   });
 
diff --git a/perf/modules/chart-tooltip-sk/chart-tooltip-sk-demo.ts b/perf/modules/chart-tooltip-sk/chart-tooltip-sk-demo.ts
index 763f28c..87c3bb0 100644
--- a/perf/modules/chart-tooltip-sk/chart-tooltip-sk-demo.ts
+++ b/perf/modules/chart-tooltip-sk/chart-tooltip-sk-demo.ts
@@ -25,6 +25,7 @@
   need_alert_action: false,
   bug_host_url: '',
   git_repo_url: '',
+  keys_for_commit_range: [],
 };
 
 const dummyAnomaly = (bugId: number): Anomaly => ({
diff --git a/perf/modules/cluster-page-sk/cluster-page-sk-demo.ts b/perf/modules/cluster-page-sk/cluster-page-sk-demo.ts
index ad6cf38..3b28822 100644
--- a/perf/modules/cluster-page-sk/cluster-page-sk-demo.ts
+++ b/perf/modules/cluster-page-sk/cluster-page-sk-demo.ts
@@ -129,4 +129,5 @@
   need_alert_action: false,
   bug_host_url: '',
   git_repo_url: '',
+  keys_for_commit_range: [],
 };
diff --git a/perf/modules/commit-range-sk/commit-range-sk-demo.ts b/perf/modules/commit-range-sk/commit-range-sk-demo.ts
index 87d49fa..06ec9c3 100644
--- a/perf/modules/commit-range-sk/commit-range-sk-demo.ts
+++ b/perf/modules/commit-range-sk/commit-range-sk-demo.ts
@@ -25,6 +25,7 @@
   need_alert_action: false,
   bug_host_url: '',
   git_repo_url: '',
+  keys_for_commit_range: [],
 };
 
 // The response to a POST of [64809, 64811] to /_/cid/.
diff --git a/perf/modules/commit-range-sk/commit-range-sk_test.ts b/perf/modules/commit-range-sk/commit-range-sk_test.ts
index 231ff38..6459a58 100644
--- a/perf/modules/commit-range-sk/commit-range-sk_test.ts
+++ b/perf/modules/commit-range-sk/commit-range-sk_test.ts
@@ -29,6 +29,7 @@
       need_alert_action: false,
       bug_host_url: '',
       git_repo_url: '',
+      keys_for_commit_range: [],
     };
 
     element = newInstance((el: CommitRangeSk) => {
diff --git a/perf/modules/explore-multi-sk/explore-multi-sk-demo.ts b/perf/modules/explore-multi-sk/explore-multi-sk-demo.ts
index b08fea6..2ad6f28 100644
--- a/perf/modules/explore-multi-sk/explore-multi-sk-demo.ts
+++ b/perf/modules/explore-multi-sk/explore-multi-sk-demo.ts
@@ -23,6 +23,7 @@
   need_alert_action: false,
   bug_host_url: '',
   git_repo_url: '',
+  keys_for_commit_range: [],
 };
 
 customElements.whenDefined('explore-multi-sk').then(() => {
diff --git a/perf/modules/explore-simple-sk/BUILD.bazel b/perf/modules/explore-simple-sk/BUILD.bazel
index 7d98e2a..ed9b2f0 100644
--- a/perf/modules/explore-simple-sk/BUILD.bazel
+++ b/perf/modules/explore-simple-sk/BUILD.bazel
@@ -33,6 +33,7 @@
         "//perf/modules/plot-summary-sk",
         "//perf/modules/picker-field-sk",
         "//perf/modules/chart-tooltip-sk",
+        "//perf/modules/point-links-sk",
     ],
     ts_deps = [
         "//infra-sk/modules/ElementSk:index_ts_lib",
diff --git a/perf/modules/explore-simple-sk/explore-simple-sk.ts b/perf/modules/explore-simple-sk/explore-simple-sk.ts
index 7d66699..cf01607 100644
--- a/perf/modules/explore-simple-sk/explore-simple-sk.ts
+++ b/perf/modules/explore-simple-sk/explore-simple-sk.ts
@@ -42,6 +42,7 @@
 import '../pivot-table-sk';
 import '../plot-simple-sk';
 import '../plot-summary-sk';
+import '../point-links-sk';
 import '../query-count-sk';
 import '../window/window';
 
@@ -137,6 +138,7 @@
   Commit as ChartCommit,
 } from '../chart-tooltip-sk/chart-tooltip-sk';
 import { $$ } from '../../../infra-sk/modules/dom';
+import { PointLinksSk } from '../point-links-sk/point-links-sk';
 
 /** The type of trace we are adding to a plot. */
 type addPlotType = 'query' | 'formula' | 'pivot';
@@ -466,6 +468,8 @@
 
   private ingestFileLinks: IngestFileLinksSk | null = null;
 
+  private pointLinks: PointLinksSk | null = null;
+
   private logEntry: HTMLPreElement | null = null;
 
   private paramset: ParamSetSk | null = null;
@@ -942,6 +946,7 @@
             </div>
             <div>
               <commit-range-sk id="commit-range-link"></commit-range-sk>
+              <point-links-sk id="point-links"></point-links-sk>
               <commit-detail-panel-sk id=commits selectable .hide=${
                 window.perf.hide_list_of_commits_on_explore
               }></commit-detail-panel-sk>
@@ -977,6 +982,7 @@
     this.formula = this.querySelector('#formula');
     this.jsonsource = this.querySelector('#jsonsource');
     this.ingestFileLinks = this.querySelector('#ingest-file-links');
+    this.pointLinks = this.querySelector<PointLinksSk>('#point-links');
     this.logEntry = this.querySelector('#logEntry');
     this.paramset = this.querySelector('#paramset');
     this.percent = this.querySelector('#percent');
@@ -1753,6 +1759,13 @@
           this.jsonsource!.cid = cid;
           this.jsonsource!.traceid = traceid;
           this.ingestFileLinks!.load(cid, traceid);
+          // Populate the point links element.
+          this.pointLinks!.load(
+            commit,
+            prevCommit,
+            e.detail.name,
+            window.perf.keys_for_commit_range!
+          );
         }
 
         // when the commit details are loaded, add those info to
diff --git a/perf/modules/explore-simple-sk/explore-simple-sk_test.ts b/perf/modules/explore-simple-sk/explore-simple-sk_test.ts
index c5b0be6..b17fc7f 100644
--- a/perf/modules/explore-simple-sk/explore-simple-sk_test.ts
+++ b/perf/modules/explore-simple-sk/explore-simple-sk_test.ts
@@ -136,6 +136,7 @@
     need_alert_action: false,
     bug_host_url: '',
     git_repo_url: '',
+    keys_for_commit_range: [],
   };
 
   // Create a common element-sk to be used by all the tests.
diff --git a/perf/modules/explore-sk/explore-sk-demo.ts b/perf/modules/explore-sk/explore-sk-demo.ts
index 6701eea..b73e141 100644
--- a/perf/modules/explore-sk/explore-sk-demo.ts
+++ b/perf/modules/explore-sk/explore-sk-demo.ts
@@ -30,6 +30,7 @@
   need_alert_action: false,
   bug_host_url: '',
   git_repo_url: '',
+  keys_for_commit_range: [],
 };
 
 customElements.whenDefined('explore-sk').then(() => {
diff --git a/perf/modules/json/index.ts b/perf/modules/json/index.ts
index f040250..506488c 100644
--- a/perf/modules/json/index.ts
+++ b/perf/modules/json/index.ts
@@ -307,6 +307,7 @@
 	need_alert_action: boolean;
 	bug_host_url: string;
 	git_repo_url: string;
+	keys_for_commit_range: string[] | null;
 }
 
 export interface TriageRequest {
diff --git a/perf/modules/perf-scaffold-sk/perf-scaffold-sk-demo.ts b/perf/modules/perf-scaffold-sk/perf-scaffold-sk-demo.ts
index 70a6ebe..ee9f2ce 100644
--- a/perf/modules/perf-scaffold-sk/perf-scaffold-sk-demo.ts
+++ b/perf/modules/perf-scaffold-sk/perf-scaffold-sk-demo.ts
@@ -20,6 +20,7 @@
   need_alert_action: false,
   bug_host_url: '',
   git_repo_url: '',
+  keys_for_commit_range: [],
 };
 
 document.querySelector('.component-goes-here')!.innerHTML = `
diff --git a/perf/modules/point-links-sk/BUILD.bazel b/perf/modules/point-links-sk/BUILD.bazel
new file mode 100644
index 0000000..75a32ea
--- /dev/null
+++ b/perf/modules/point-links-sk/BUILD.bazel
@@ -0,0 +1,60 @@
+load("//infra-sk:index.bzl", "karma_test", "sk_demo_page_server", "sk_element", "sk_element_puppeteer_test", "sk_page")
+
+sk_demo_page_server(
+    name = "demo_page_server",
+    sk_page = ":point-links-sk-demo",
+)
+
+sk_element(
+    name = "point-links-sk",
+    sass_srcs = ["point-links-sk.scss"],
+    ts_deps = [
+        "//:node_modules/lit-html",
+        "//infra-sk/modules/ElementSk:index_ts_lib",
+        "//elements-sk/modules:define_ts_lib",
+        "//infra-sk/modules:jsonorthrow_ts_lib",
+        "//perf/modules/errorMessage:index_ts_lib",
+        "//perf/modules/json:index_ts_lib",
+    ],
+    ts_srcs = [
+        "point-links-sk.ts",
+        "index.ts",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+sk_page(
+    name = "point-links-sk-demo",
+    html_file = "point-links-sk-demo.html",
+    scss_entry_point = "point-links-sk-demo.scss",
+    sk_element_deps = [":point-links-sk"],
+    ts_deps = [
+        "//:node_modules/fetch-mock",
+        "//perf/modules/json:index_ts_lib",
+    ],
+    ts_entry_point = "point-links-sk-demo.ts",
+)
+
+sk_element_puppeteer_test(
+    name = "point-links-sk_puppeteer_test",
+    src = "point-links-sk_puppeteer_test.ts",
+    sk_demo_page_server = ":demo_page_server",
+    deps = [
+        "//:node_modules/@types/chai",
+        "//:node_modules/chai",
+        "//puppeteer-tests:util_ts_lib",
+    ],
+)
+
+karma_test(
+    name = "point-links-sk_test",
+    src = "point-links-sk_test.ts",
+    deps = [
+        ":point-links-sk",
+        "//:node_modules/@types/chai",
+        "//:node_modules/chai",
+        "//:node_modules/fetch-mock",
+        "//infra-sk/modules:test_util_ts_lib",
+        "//perf/modules/json:index_ts_lib",
+    ],
+)
diff --git a/perf/modules/point-links-sk/index.ts b/perf/modules/point-links-sk/index.ts
new file mode 100644
index 0000000..d4fbe17
--- /dev/null
+++ b/perf/modules/point-links-sk/index.ts
@@ -0,0 +1 @@
+import './point-links-sk';
diff --git a/perf/modules/point-links-sk/point-links-sk-demo.html b/perf/modules/point-links-sk/point-links-sk-demo.html
new file mode 100644
index 0000000..83d9e32
--- /dev/null
+++ b/perf/modules/point-links-sk/point-links-sk-demo.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>point-links-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">
+</head>
+<body class="body-sk">
+  <h1>point-links-sk</h1>
+  <point-links-sk></point-links-sk>
+
+  <h2>Events</h2>
+  <pre id=events></pre>
+</body>
+</html>
diff --git a/perf/modules/point-links-sk/point-links-sk-demo.scss b/perf/modules/point-links-sk/point-links-sk-demo.scss
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/perf/modules/point-links-sk/point-links-sk-demo.scss
diff --git a/perf/modules/point-links-sk/point-links-sk-demo.ts b/perf/modules/point-links-sk/point-links-sk-demo.ts
new file mode 100644
index 0000000..270d547
--- /dev/null
+++ b/perf/modules/point-links-sk/point-links-sk-demo.ts
@@ -0,0 +1,35 @@
+import fetchMock from 'fetch-mock';
+import { CommitNumber } from '../json';
+import './index';
+import { PointLinksSk } from './point-links-sk';
+
+fetchMock.post('/_/details/?results=false', (url, request) => {
+  const requestObj = JSON.parse(request.body!.toString());
+  switch (requestObj.cid) {
+    case 12:
+      return {
+        version: 1,
+        links: {
+          'V8 Git Hash':
+            'https://chromium.googlesource.com/v8/v8/+/47f420e89ec1b33dacc048d93e0317ab7fec43dd',
+        },
+      };
+    case 11:
+      return {
+        version: 1,
+        links: {
+          'V8 Git Hash':
+            'https://chromium.googlesource.com/v8/v8/+/f052b8c4db1f08d1f8275351c047854e6ff1805f',
+        },
+      };
+    default:
+      return {};
+  }
+});
+
+window.customElements.whenDefined('point-links-sk').then(() => {
+  const sources = document.querySelectorAll<PointLinksSk>('point-links-sk')!;
+  sources.forEach((source) => {
+    source.load(CommitNumber(12), CommitNumber(11), 'foo', ['V8 Git Hash']);
+  });
+});
diff --git a/perf/modules/point-links-sk/point-links-sk.scss b/perf/modules/point-links-sk/point-links-sk.scss
new file mode 100644
index 0000000..6656b66
--- /dev/null
+++ b/perf/modules/point-links-sk/point-links-sk.scss
@@ -0,0 +1,2 @@
+point-links-sk {
+}
diff --git a/perf/modules/point-links-sk/point-links-sk.ts b/perf/modules/point-links-sk/point-links-sk.ts
new file mode 100644
index 0000000..9ace535
--- /dev/null
+++ b/perf/modules/point-links-sk/point-links-sk.ts
@@ -0,0 +1,171 @@
+/**
+ * @module modules/point-links-sk
+ * @description <h2><code>point-links-sk</code></h2>
+ * This module provides the ability to display links which are specific to data points.
+ * The original data source for the links come from the ingestion file and the caller
+ * provides a list of keys to extract from the links and format those as anchor elements
+ * to be displayed.
+ *
+ * This module also generates commit range links for incoming links that are commits. This
+ * is done by getting the links for the current commit (the point that is selected) and the
+ * previous commit, and then generating a git log url to show the list of commits between
+ * both of these.
+ *
+ * @example
+ *
+ * Link in ingestion file (commit n): {'V8 Git Hash': 'https://chromium.googlesource.com/v8/v8/+/47f420e89ec1b33dacc048d93e0317ab7fec43dd'}
+ * Link in ingestion file (commit n-1): {'V8 Git Hash': 'https://chromium.googlesource.com/v8/v8/+/f052b8c4db1f08d1f8275351c047854e6ff1805f'}
+ *
+ * Since both the commit links are different, this module will generate a new link like below.
+ *
+ * <a href='https://chromium.googlesource.com/v8/v8/+log/f052b8c4..47f420e>V8 Git Hash Range</a>
+ */
+import { TemplateResult, html } from 'lit-html';
+import { define } from '../../../elements-sk/modules/define';
+import { ElementSk } from '../../../infra-sk/modules/ElementSk';
+import { CommitDetailsRequest, CommitNumber, ingest } from '../json';
+import { jsonOrThrow } from '../../../infra-sk/modules/jsonOrThrow';
+import { errorMessage } from '../errorMessage';
+
+export class PointLinksSk extends ElementSk {
+  constructor() {
+    super(PointLinksSk.template);
+  }
+
+  // Contains the urls to be displayed.
+  displayUrls: { [key: string]: string } = {};
+
+  // Contains the texts for the urls to be displayed.
+  displayTexts: { [key: string]: string } = {};
+
+  private static template = (ele: PointLinksSk) =>
+    html` <div ?hidden=${Object.keys(ele.displayUrls || {}).length === 0}>
+      <ul>
+        ${ele.renderLinks()}
+      </ul>
+    </div>`;
+
+  connectedCallback(): void {
+    super.connectedCallback();
+    this._render();
+  }
+
+  renderLinks(): TemplateResult[] {
+    const keys = Object.keys(this.displayUrls);
+    const getHtml = (key: string): TemplateResult => {
+      const link = this.displayUrls![key];
+      const linkText = this.displayTexts[key];
+      return html`<li>${key}: <a href="${link}"> ${linkText}</a></li>`;
+    };
+    return keys.map(getHtml);
+  }
+
+  // load and display the links for the given commit and trace.
+  public async load(
+    cid: CommitNumber,
+    prev_cid: CommitNumber,
+    traceid: string,
+    keysForCommitRange: string[]
+  ): Promise<void> {
+    // Clear any existing links first.
+    this.displayUrls = {};
+    this.displayTexts = {};
+    const currentLinks = await this.getLinksForPoint(cid, traceid);
+    const prevLinks = await this.getLinksForPoint(prev_cid, traceid);
+    keysForCommitRange.forEach((key) => {
+      const currentCommitUrl = currentLinks[key];
+      if (
+        currentCommitUrl !== undefined &&
+        currentCommitUrl !== null &&
+        currentCommitUrl !== ''
+      ) {
+        const prevCommitUrl = prevLinks[key];
+        const currentCommitId = this.getCommitIdFromCommitUrl(
+          currentCommitUrl
+        ).substring(0, 7);
+        const prevCommitId = this.getCommitIdFromCommitUrl(
+          prevCommitUrl
+        ).substring(0, 7);
+        if (currentCommitId === prevCommitId) {
+          this.displayUrls[key] = currentCommitUrl;
+          this.displayTexts[key] = currentCommitId;
+        } else {
+          const repoUrl = this.getRepoUrlFromCommitUrl(currentCommitUrl);
+          const commitRangeUrl = `${repoUrl}+log/${prevCommitId}..${currentCommitId}`;
+          const displayKey = `${key} Range`;
+          this.displayUrls[displayKey] = commitRangeUrl;
+          this.displayTexts[displayKey] = this.getFormattedCommitRangeText(
+            prevCommitId,
+            currentCommitId
+          );
+        }
+      }
+    });
+    this._render();
+  }
+
+  /**
+   * Get the commit range text.
+   * @param start Start Commit.
+   * @param end End Commit.
+   * @returns Formatted text.
+   */
+  private getFormattedCommitRangeText(start: string, end: string): string {
+    return `${start} - ${end}`;
+  }
+
+  /**
+   * Get the repository name from the given commit url.
+   * @param commitUrl Full commit url.
+   * @returns Repository name.
+   */
+  private getRepoUrlFromCommitUrl(commitUrl: string): string {
+    const idx = commitUrl.indexOf('+');
+    return commitUrl.substring(0, idx);
+  }
+
+  /**
+   * Get the commit id from the given commit url.
+   * @param commitUrl Full commit url.
+   * @returns Commit id.
+   */
+  private getCommitIdFromCommitUrl(commitUrl: string): string {
+    const idx = commitUrl.lastIndexOf('/');
+    return commitUrl.substring(idx + 1);
+  }
+
+  /**
+   * Get the links for the given commit.
+   * @param cid Commit id.
+   * @param traceId Trace id.
+   * @returns Links relevant to the commit id and trace id.
+   */
+  private async getLinksForPoint(
+    cid: CommitNumber,
+    traceId: string
+  ): Promise<{ [key: string]: string }> {
+    const body: CommitDetailsRequest = {
+      cid: cid,
+      traceid: traceId,
+    };
+    const url = '/_/details/?results=false';
+    try {
+      const resp = await fetch(url, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify(body),
+      });
+      const json = await jsonOrThrow(resp);
+      const format = json as ingest.Format;
+      return format.links!;
+    } catch (error) {
+      await errorMessage(error as string);
+    }
+
+    return {};
+  }
+}
+
+define('point-links-sk', PointLinksSk);
diff --git a/perf/modules/point-links-sk/point-links-sk_puppeteer_test.ts b/perf/modules/point-links-sk/point-links-sk_puppeteer_test.ts
new file mode 100644
index 0000000..ae7ed9d
--- /dev/null
+++ b/perf/modules/point-links-sk/point-links-sk_puppeteer_test.ts
@@ -0,0 +1,28 @@
+import { expect } from 'chai';
+import {
+  loadCachedTestBed,
+  takeScreenshot,
+  TestBed,
+} from '../../../puppeteer-tests/util';
+
+describe('point-links-sk', () => {
+  let testBed: TestBed;
+  before(async () => {
+    testBed = await loadCachedTestBed();
+  });
+
+  beforeEach(async () => {
+    await testBed.page.goto(testBed.baseUrl);
+    await testBed.page.setViewport({ width: 400, height: 550 });
+  });
+
+  it('should render the demo page (smoke test)', async () => {
+    expect(await testBed.page.$$('point-links-sk')).to.have.length(1);
+  });
+
+  describe('screenshots', () => {
+    it('shows the default view', async () => {
+      await takeScreenshot(testBed.page, 'perf', 'point-links-sk');
+    });
+  });
+});
diff --git a/perf/modules/point-links-sk/point-links-sk_test.ts b/perf/modules/point-links-sk/point-links-sk_test.ts
new file mode 100644
index 0000000..d8d6a43
--- /dev/null
+++ b/perf/modules/point-links-sk/point-links-sk_test.ts
@@ -0,0 +1,142 @@
+import './index';
+import { assert } from 'chai';
+import fetchMock from 'fetch-mock';
+import { PointLinksSk } from './point-links-sk';
+
+import { setUpElementUnderTest } from '../../../infra-sk/modules/test_util';
+import { CommitNumber } from '../json';
+
+describe('point-links-sk', () => {
+  const newInstance = setUpElementUnderTest<PointLinksSk>('point-links-sk');
+
+  let element: PointLinksSk;
+  beforeEach(() => {});
+
+  describe('Load links for a commit.', () => {
+    beforeEach(() => {
+      element = newInstance();
+      fetchMock.reset();
+    });
+
+    it('With no eligible links.', () => {
+      const currentCommitId = CommitNumber(4);
+      const prevCommitId = CommitNumber(3);
+      const keysForCommitRange: string[] = [];
+      element.load(
+        currentCommitId,
+        prevCommitId,
+        'my trace',
+        keysForCommitRange
+      );
+      assert.isEmpty(element.displayUrls, 'No display urls expected.');
+      assert.isEmpty(element.displayTexts, 'No display texts expected.');
+    });
+
+    it('With all eligible links but no range.', async () => {
+      const keysForCommitRange = ['key1', 'key2'];
+      const expectedLinks = {
+        key1: 'https://commit/link1',
+        key2: 'https://commit/link2',
+      };
+      fetchMock.post('/_/details/?results=false', {
+        version: 1,
+        links: expectedLinks,
+      });
+
+      const currentCommitId = CommitNumber(4);
+      const prevCommitId = CommitNumber(3);
+
+      await element.load(
+        currentCommitId,
+        prevCommitId,
+        'my trace',
+        keysForCommitRange
+      );
+      assert.deepEqual(expectedLinks, element.displayUrls);
+    });
+
+    it('With all eligible links and only ranges.', async () => {
+      const keysForCommitRange = ['key1', 'key2'];
+      const currentCommitId = CommitNumber(4);
+      const prevCommitId = CommitNumber(3);
+
+      fetchMock.post('/_/details/?results=false', (url, request) => {
+        const requestObj = JSON.parse(request.body!.toString());
+        switch (requestObj.cid) {
+          case currentCommitId:
+            return {
+              version: 1,
+              links: {
+                key1: 'https://repoHost/repo1/+/curLink',
+                key2: 'https://repoHost/repo2/+/curLink',
+              },
+            };
+          case prevCommitId:
+            return {
+              version: 1,
+              links: {
+                key1: 'https://repoHost/repo1/+/preLink',
+                key2: 'https://repoHost/repo2/+/preLink',
+              },
+            };
+          default:
+            return {};
+        }
+      });
+
+      await element.load(
+        currentCommitId,
+        prevCommitId,
+        'my trace',
+        keysForCommitRange
+      );
+      const expectedLinks = {
+        'key1 Range': 'https://repoHost/repo1/+log/preLink..curLink',
+        'key2 Range': 'https://repoHost/repo2/+log/preLink..curLink',
+      };
+      assert.deepEqual(expectedLinks, element.displayUrls);
+    });
+
+    it('With all eligible links and mixed links and ranges.', async () => {
+      const keysForCommitRange = ['key1', 'key2'];
+      const currentCommitId = CommitNumber(4);
+      const prevCommitId = CommitNumber(3);
+
+      fetchMock.post('/_/details/?results=false', (url, request) => {
+        const requestObj = JSON.parse(request.body!.toString());
+        switch (requestObj.cid) {
+          case currentCommitId:
+            return {
+              version: 1,
+              links: {
+                key1: 'https://repoHost/repo1/+/curLink',
+                key2: 'https://repoHost/repo2/+/curLink',
+              },
+            };
+          case prevCommitId:
+            return {
+              version: 1,
+              links: {
+                key1: 'https://repoHost/repo1/+/curLink',
+                key2: 'https://repoHost/repo2/+/preLink',
+              },
+            };
+          default:
+            return {};
+        }
+      });
+
+      await element.load(
+        currentCommitId,
+        prevCommitId,
+        'my trace',
+        keysForCommitRange
+      );
+      const expectedLinks = {
+        key1: 'https://repoHost/repo1/+/curLink',
+        'key2 Range': 'https://repoHost/repo2/+log/preLink..curLink',
+      };
+      assert.deepEqual(expectedLinks, element.displayUrls);
+    });
+  });
+});
diff --git a/perf/modules/trybot-page-sk/trybot-page-sk-demo.ts b/perf/modules/trybot-page-sk/trybot-page-sk-demo.ts
index b62dad6..f9587df 100644
--- a/perf/modules/trybot-page-sk/trybot-page-sk-demo.ts
+++ b/perf/modules/trybot-page-sk/trybot-page-sk-demo.ts
@@ -26,6 +26,7 @@
   need_alert_action: false,
   bug_host_url: '',
   git_repo_url: '',
+  keys_for_commit_range: [],
 };
 
 Date.now = () => Date.parse('2020-03-22T00:00:00.000Z');