# 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`](./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](https://godoc.org/k8s.io/test-infra/prow/spyglass/lenses#Lens). An instance of the struct implementing the `lenses.Lens` interface must then be registered with spyglass, by calling [`lenses.RegisterLens`](https://godoc.org/k8s.io/test-infra/prow/spyglass/lenses#RegisterLens). A minimal example of a lens called `samplelens`, located at `lenses/samplelens`, might look like this: ```go package samplelens import ( "encoding/json" "k8s.io/test-infra/prow/config" "k8s.io/test-infra/prow/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 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 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 `import`ing it from [`prow/cmd/deck/main.go`](../cmd/deck/main.go), alongside the other lenses: ```go import ( // ... _ "k8s.io/test-infra/prow/spyglass/lenses/samplelens" ) ``` Finally, you will need to run `./hack/update-bazel.sh` from the test-infra root to update the bazel files so your lens is built. You can then test it by running `./prow/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 `` element). Relative references into your directory will work: spyglass adds a `` tag that references the expected output directory. Spyglass lenses have access to a `spyglass` global that [provides a number of APIs](#lens-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 specify them using Bazel. 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`, you might add the following to your `BUILD.bazel` (which should already have been generated by `./hack/update-bazel.sh` when writing your backend): ```python # Note that the important parts are the `name` arguments, which you should not change unless # you know what you're doing. You can change the filenames in `srcs` freely, as long as they # match the ways you reference them in code. load("//def:ts.bzl", "rollup_bundle", "ts_library") ts_library( name = "script", srcs = ["sample.ts"], deps = [ "//prow/spyglass/lenses:lens_api", ], ) rollup_bundle( name = "script_bundle", entry_point = ":sample.ts", deps = [ ":script", ], ) filegroup( name = "template", srcs = ["template.html"], visibility = ["//visibility:public"], ) filegroup( name = "resources", srcs = [ "style.css", "magic.png", ":script_bundle.min", ], visibility = ["//visibility:public"], ) ``` With this setup, you would reference your script in your HTML as `script_bundle.min.js`, like so: ```html ``` You also need to update the [spyglass `BUILD.bazel`](./BUILD.bazel) to have references to yours, in particular adding your lens to the `templates` and `resources` filegroups. ### Lens APIs Many Spyglass APIs are asynchronous, and so return a [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises). We recommend using `async`/`await` to use them, like this: ``` async function doStuff(): Promise { 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` `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` `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 ``. 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` `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` `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. #### Links We set a default `` 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. #### Fragments / Anchor links 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 (``) would usually not work well in conjunction with the `` 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.