Build a Spyglass Lens

Spyglass lenses consist of two components: a frontend (which may be trivial) and a backend.

Lens backend

Today, a lens backend must be linked in to the deck binary. As such, lenses must live under prow/spyglass/lenses. Additionally lenses must be in a folder that matches the name of the lens. The content of this folder will be served by deck, enabling you to reference static content such as images, stylesheets, or scripts.

Inside your template you must implement the lenses.Lens interface.

An instance of the struct implementing the lenses.Lens interface must then be registered with spyglass, by calling lenses.RegisterLens.

A minimal example of a lens called samplelens, located at lenses/samplelens, might look like this:

package samplelens
import (
	"encoding/json"

	"sigs.k8s.io/prow/pkg/config"
	"sigs.k8s.io/prow/pkg/spyglass/lenses"
)

type Lens struct{}

func init() {
	lenses.RegisterLens(Lens{})
}

// Config returns the lens's configuration.
func (lens Lens) Config() lenses.LensConfig {
	return lenses.LensConfig{
		Title:     "Human Readable Lens",
		Name:      "samplelens", // remember: this *must* match the location of the lens (and thus package name)
		Priority:  0,
	}
}

// Header returns the content of <head>
func (lens Lens) Header(artifacts []lenses.Artifact, resourceDir string, config json.RawMessage, spyglassConfig config.Spyglass) string {
	return ""
}

func (lens Lens) Callback(artifacts []lenses.Artifact, resourceDir string, data string, config json.RawMessage, spyglassConfig config.Spyglass) string {
	return ""
}

// Body returns the displayed HTML for the <body>
func (lens Lens) Body(artifacts []lenses.Artifact, resourceDir string, data string, config json.RawMessage, spyglassConfig config.Spyglass) string {
	return "Hi! I'm a lens!"
}

If you want to read resources included in your lens (such as templates), you can find them in the provided resourceDir.

Finally, you will need to import your lens from deck in order to actually link it in. You can do this by importing it from prow/cmd/deck/main.go, alongside the other lenses:

import (
	// ...
	_ "sigs.k8s.io/prow/pkg/spyglass/lenses/samplelens"
)

Finally, you can then test it by running ./cmd/deck/runlocal and loading a spyglass page.

Lens frontend

The HTML generated by a lens can reference static assets that will be served by Deck on behalf of your lens. Scripts and stylesheets can be referenced in the output of the Header() function (which is inserted into the <head> element). Relative references into your directory will work: spyglass adds a <base> tag that references the expected output directory.

Spyglass lenses have access to a spyglass global that provides a number of APIs to interact with your lens backend and the rest of the world. Your lens is rendered in a sandboxed iframe, so you generally cannot interact without using these APIs.

We recommend writing lenses using TypeScript, and provide TypeScript declarations for the spyglass APIs.

In order to build frontend resources in, you will need to notify the build system. Assuming you had a template called template.html, a typescript file called sample.ts, a stylesheet called style.css, and an image called magic.png. The changes are:

  1. Add a new file called tsconfig.json:
{
  "extends": "../../../../tsconfig.json",
  "include": [
    "sample.ts",
  ],
}
  1. Add a line in prow/cmd/deck/.ts-packages:
prow/spyglass/lenses/sample/sample.ts->script_bundle.min.js

With this setup, you would reference your script in your HTML as script_bundle.min.js, like so:

<script type="text/javascript" src="script_bundle.min.js"></script>

Lens APIs

Many Spyglass APIs are asynchronous, and so return a Promise. We recommend using async/await to use them, like this:

async function doStuff(): Promise<void> {
  const someStuff = await spyglass.request("");
}

We provide the following methods under spyglass in all lenses:

spyglass.contentUpdated(): void

contentUpdated should be called whenever you make changes to the content of the page. It signals to the Spyglass host page that it needs to recalculate how your lens is displayed. It is not necessary to call it on initial page load.

spyglass.request(data: string): Promise<string>

request is used to call back to your lens’s backend. Whatever data you provide will be provided unmodified to your lens backend’s Callback() method. request returns a Promise, which will eventually be resolved with the string returned from Callback() (unless an error occurs, in which case it will fail). We recommend, but do not require, that both strings be JSON-encoded.

spyglass.updatePage(data: string): Promise<void>

updatePage calls your lens backend’s Body() method again, passing in whatever data you provide and shows a loading spinner. Once the call completes, the lens is re-displayed using the newly-provided <body>. Note that this does not reload the lens, and so your script will keep running. The returned promise resolves once the new content is ready.

spyglass.requestPage(data: string): Promise<string>

requestPage calls your lens backend’s Body() method again, passing in whatever data you provide. Unlike updatePage, it does not show a spinner, and does not change the page. Instead, the returned promise will resolve with the newly-generated HTML.

spyglass.makeFragmentLink(fragment: string): string

makeFragmentLink returns a link to the top-level page that will cause your lens to receive the specified fragment in location.hash, and no other lens on the page to receive any fragment. This is useful when generating links for the user to copy to your content, but should not be used to perform direct navigation - instead, just update location.hash, and propagation will be handled transparently.

If the provided fragment does not have a leading # one will be added, for consistency with the behaviour of location.hash.

spyglass.scrollTo(x: number, y: number): Promise<void>

scrollTo scrolls the parent Spyglass page such that the provided (x, y) document-relative coordinate of your lens is visible. Note that we keep lenses at slightly under 100% page width, so only y is currently meaningful.

Special considerations

Sandboxing

Lenses are contained in sandboxed iframes in the parent page. The most notably restricted activity is making XHR requests to Deck, which would be considered prohibited CORS requests. Lenses also cannot directly interact with their parent window, outside of the provided APIs.

We set a default <base> with href set pointing in to your resource directory, and target set to _top. This means that links will by default replace the entire spyglass page, which is usually the intended effect. It also means that src or href HTML attributes are based in those directories, which is usually what you want in this context.

Fragment URLs (the part after the #) are supported fairly transparently, despite being in an iframe. The parent page muxes all the lens’s fragments and ensures that if the page is loaded, each lens receives the fragment it expects. Changing your fragment will automatically update the parent page’s fragment. If the fragment matches the ID or name of an element, the page will scroll such that that element is visible.

Anchor links (<a href="#something">) would usually not work well in conjunction with the <base> tag. To resolve this, we rewrite all links of this form to behave as expected both on page load and on DOM modification. In most cases, this should be transparent. If you want users to copy links via right click -> copy link, however, this will not work nicely. Instead, consider setting the href attribute to something from spyglass.makeFragmentLink, but handling clicks by manually setting location.hash to the desired fragment.