Skip to content

Dev UI v2

Phillip Krüger edited this page Feb 6, 2023 · 20 revisions

Quarkus 3 provides the base for a new Dev UI. This new Dev UI uses Web Components (Lit and Vaadin components) and Json RPC over Web Sockets, to provide a more interactive and modern experience.

This page contains a list of how to to help extend this new dev UI.

Layout

devui1

  • Menu: Main sections of Dev UI.
  • Page: Where sections and extensions can display their content.
  • Card: Extensions can add links to their cards that will navigate to their page (above) displaying their content.
  • Footer: Bottom drawer that allows log related content.

devui2

When a extension linked is clicked, the extension has got full control of the page, and if the extension has more than one link on their card, those links will form the sub-menu in the header.

Extensions can take part in the Dev UI in 3 ways:

  • Link(s) on their card.
  • Menu item(s) in the menu.
  • Tab(s) in the footer.

In most cases only links on cards will be used.

Cards

Cards are automatically added for all extensions in the running project. By default information on the extension will be displayed (from src/main/resources/META-INF/quarkus-extension.yaml)

devui3

You can add links to that card by creating a processor with a build step producing an io.quarkus.devui.spi.page.PageBuildItem:

public class SmallRyeHealthDevUiProcessor {

    @BuildStep(onlyIf = IsDevelopment.class) // <1> Only for dev.
    CardPageBuildItem create() {
        CardPageBuildItem cardPageBuildItem = new CardPageBuildItem("Smallrye Health"); // <2> This name must be the extension name

        // Add simple links
        cardPageBuildItem.addPage(Page.externalPageBuilder("Health") // <3> The link label
                .icon("font-awesome-solid:stethoscope") // <4> The link icon
                .url("/q/health")  // <5> The link target
                .isJsonContent());  // <6> The type of content

        cardPageBuildItem.addPage(Page.externalPageBuilder("Health UI")
                .icon("font-awesome-solid:stethoscope")
                .url("/q/health-ui")
                .isHtmlContent());

        return pageBuildItem;
    }

}

There are multiple Page Builder that helps with the creating of these pages:

  • externalPageBuilder - Build a link to a page that is outside of the scope of Dev UI. By default it will be embedded in the page view of the extension. To not embed it (but rather navigate out to the external link) use .doNotEmbed() on the builder.
  • webComponentPageBuilder - Build a link to a page that is rendered with a Web Component.
  • TODO: add other builders

Use web components

One key difference with the previous dev UI is the usage of Web Components. So, a card link can navigate to a web component full page.

The web component interacts with the backend using JSON RPC (over a web socket). It allows bi-directional interactions, including streams.

To create a card linking a web component, create a processor with the following build steps:

public class CacheDevUiConsoleProcessor {

    @BuildStep(onlyIf = IsDevelopment.class)
    CardPageBuildItem create(CurateOutcomeBuildItem bi) {
        CardPageBuildItem cardPageBuildItem = new CardPageBuildItem("Cache"); // Must be the extension name
        cardPageBuildItem.addPage(Page.webComponentPageBuilder()
                .title("Caches")
                .componentLink("cache-component.js")
                .icon("font-awesome-solid:database"));

        return pageBuildItem;
    }

    @BuildStep(onlyIf = IsDevelopment.class)
    JsonRPCProvidersBuildItem createJsonRPCServiceForCache() {
        return new JsonRPCProvidersBuildItem("Caches", CacheJsonRPCService.class);
    }
}

Let's focus on the create method. Unlike the one from SmallRyeHealthDevUiProcessor, this one creates a WebComponentPage. It defines the title of that page, the link to the web component JS file, and an optional icon.

The createJsonRPCServiceForCache defines a JsonRPCProvidersBuildItem, which will be the backend component interacting with the web component.

Build time data

You can make data created at build time, available in your web component by using the addBuildTimeData method of the CardPageBuildItem:

CardPageBuildItem cardPageBuildItem = new CardPageBuildItem("Cache");
cardPageBuildItem.addBuildTimeData("initialCaches", initialCache);

initialCaches can be any JsonObject or JsonArray or (if Jackson Databind is in your dependencies) any POJO.

That will be available in your web component to import:

import { initialCaches } from 'cache-data';

Note the const being imported match the key you used in addBuildTimeData and the from is your extension name + -data;

The value is then usable in your component as a normal Json object.

The JSON RPC backend

The Dev UI uses JSON RPC over a web socket. However, to simplify the code, the extension produces a JsonRPCProvidersBuildItem. This build item sets the (JavaScript) name of the API (Caches in our example) and the class handling the interaction (CacheJsonRPCService). This class must be in the runtime module of the extension and is a CDI bean:

@ApplicationScoped
public class CacheJsonRPCService {

    @Inject
    CacheManager manager;

    // Called using jsonRpc.Caches.getAll()
    public JsonArray getAll() {
        Collection<String> names = manager.getCacheNames();
        List<CaffeineCache> allCaches = new ArrayList<>(names.size());
        for (String name : names) {
            Optional<Cache> cache = manager.getCache(name);
            if (cache.isPresent() && cache.get() instanceof CaffeineCache) {
                allCaches.add((CaffeineCache) cache.get());
            }
        }
        allCaches.sort(Comparator.comparing(CaffeineCache::getName));

        var array = new JsonArray();
        for (CaffeineCache cc : allCaches) {
            array.add(getJsonRepresentationForCache(cc));
        }
        return array;
    }

    // Called using jsonRpc.Caches.clear(name: name)
    public JsonObject clear(String name) {
        Optional<Cache> cache = manager.getCache(name);
        if (cache.isPresent()) {
            cache.get().invalidateAll().subscribe().asCompletionStage();
            return getJsonRepresentationForCache(cache.get());
        } else {
            return new JsonObject().put("name", name).put("size", -1);
        }
    }

    // Called using jsonRpc.Caches.clear(name: name)
    public JsonObject refresh(String name) {
        System.out.println("refresh called for " + name);
        Optional<Cache> cache = manager.getCache(name);
        if (cache.isPresent()) {
            return getJsonRepresentationForCache(cache.get());
        } else {
            return new JsonObject().put("name", name).put("size", -1);
        }
    }

    private static JsonObject getJsonRepresentationForCache(Cache cc) {
        return new JsonObject().put("name", cc.getName())
            .put("size", ((CaffeineCacheImpl) cc).getSize());
    }

}

Each of the public method from the bean is exposed with JSON RPC. It means the web component can invoke them and get the results. In our example, we have three methods:

  • getAll - retrieving all the caches
  • clear - invalidating all the items from a cache
  • refresh - gets up-to-date representation of a cache

Note that these methods return JSON object / JSON array. Other types are supported too, but JSON eases the integration with the frontend. When you have Jackson Databind in your dependencies you can also return a POJO. By default Jackson Databind is not included, so only use this when your extension is adding that to the dependencies anyway.

The Web component

The web component is implemented in JavaScript. The file is located in the deployment module, in the src/main/resources/dev-ui/<extension name>/ directory. In our case, it's the src/main/resources/dev-ui/cache/cache-component.js file.

The content is a web component using Lit and Vaadin. A Web Component is JavaScript class extending LitElement.

The Web Component JavaScript file:

  • imports the web component it relies on.
  • is declared as a class with: export class MyComponent extend ListElement.
  • can contain its own style using static styles= css\...``.
  • can access the JSON RPC bridge using jsonRpc = new JsonRpc("the name set in the JsonRPCProvidersBuildItem");.
  • defines its state properties in static properties = {}.
  • can override component interface methods like connectedCallback, disconnectedCallback, render...
  • define how it's presented on the screen using the render method.

The following snippet is an example of a Web Component:

import { LitElement, html, css} from 'lit';
import { JsonRpc } from 'jsonrpc';
import '@vaadin/icon';
import '@vaadin/button';
import { until } from 'lit/directives/until.js';
import '@vaadin/grid';
import { columnBodyRenderer } from '@vaadin/grid/lit.js';
import '@vaadin/grid/vaadin-grid-sort-column.js';

export class CacheComponent extends LitElement {

    jsonRpc = new JsonRpc("Caches");

    // Component style
    static styles = css`
        .button {
            background-color: transparent;
            cursor: pointer;
        }
        .clearIcon {
            color: orange;
        }
        `;

    // Component properties
    static properties = {
        "_caches": {state: true}
    }

    // Components callbacks

    /**
     * Called when displayed
     */
    connectedCallback() {
        super.connectedCallback();
        this.jsonRpc.getAll().then(jsonRpcResponse => {
            this._caches = new Map();
            jsonRpcResponse.result.forEach(c => {
                this._caches.set(c.name, c);
            });
        });
    }

    /**
     * Called when it needs to render the components
     * @returns {*}
     */
    render() {
        return html`${until(this._renderCacheTable(), html`<span>Loading caches...</span>`)}`;
    }

    // View / Templates

    _renderCacheTable() {
        if (this._caches) {
            let caches = [...this._caches.values()];
            return html`
                <vaadin-grid .items="${caches}" class="datatable" theme="no-border">
                    <vaadin-grid-column auto-width
                                        header="Name"
                                        ${columnBodyRenderer(this._nameRenderer, [])}>
                    </vaadin-grid-column>

                    <vaadin-grid-column auto-width
                                        header="Size"
                                        path="size">
                    </vaadin-grid-column>

                    <vaadin-grid-column auto-width
                                        header=""
                                        ${columnBodyRenderer(this._actionRenderer, [])}
                                        resizable>
                    </vaadin-grid-column>
                </vaadin-grid>`;
        }
    }

    _actionRenderer(cache) {
        return html`
            <vaadin-button theme="small" @click=${() => this._clear(cache.name)} class="button">
                <vaadin-icon class="clearIcon" icon="font-awesome-solid:broom"></vaadin-icon> Clear
            </vaadin-button>`;
    }

    _nameRenderer(cache) {
        return html`
            <vaadin-button theme="small" @click=${() => this._refresh(cache.name)} class="button">
                <vaadin-icon icon="font-awesome-solid:rotate"></vaadin-icon>
            </vaadin-button>
            ${cache.name}`;
    }

    _clear(name) {
        this.jsonRpc.clear({name: name}).then(jsonRpcResponse => {
            this._updateCache(jsonRpcResponse.result)
        });
    }

    _refresh(name) {
        this.jsonRpc.refresh({name: name}).then(jsonRpcResponse => {
            this._updateCache(jsonRpcResponse.result)
        });
    }

    _updateCache(cache){
        if (this._caches.has(cache.name)  && cache.size !== -1) {
            this._caches.set(cache.name, cache);
            this.requestUpdate(); // Required because we use a Map, so we do not re-assign the state variable.
        }
    }

}
customElements.define('cache-component', CacheComponent);

The last line is essential, as it registers the web component.

Let's look into that code. After the list of imports, it does the following:

export class CacheComponent extends LitElement {

This is the beginning of the web component. Because our web component interacts with the backend, we instantiate the JSON RPC bridge:

jsonRpc = new JsonRpc("Caches");

This object will let us invoke the getAll, clear and refresh methods. Caches is the name passed in the JsonRPCProvidersBuildItem.

A web component can define its style:

// Component style
    static styles = css`
        .button {
            background-color: transparent;
            cursor: pointer;
        }
        .clearIcon {
            color: orange;
        }
        `;

This style is not leaked outside of the web component.

A web component has properties, including state variables:

// Component properties
    static properties = {
        "_caches": {state: true}
    }

The state:true is important as it triggers the rendering of the component if this variable is modified.

Accessing the properties is done using this._caches. Note that using _ is a convention to denote internal attributes and methods (like private in Java).

A web component can implement lifecycle hooks such as:

  • connectedCallback - called when the web component is inserted in the DOM
  • disconnectedCallback - called when the web component is removed from the DOM
  • render - called when the UI needs to be updated.

In our example, the connectedCallback retrieves the caches from the backend:

connectedCallback() {
        super.connectedCallback();
        this.jsonRpc.getAll().then(jsonRpcResponse => {
            this._caches = new Map();
            jsonRpcResponse.result.forEach(c => {
                this._caches.set(c.name, c);
            });
        });
    }

this.jsonRpc.getAll() sends a JSON RPC message to the backend (to invoke the getAll() method), and when the response is received invokes the then callback. We initiate the _cache state variable in that callback.

The render method is the central piece of our web component. It defines how the component is rendered on screen:

/**
     * Called when it needs to render the components
     * @returns {*}
     */
    render() {
        return html`${until(this._renderCacheTable(), html`<span>Loading caches...</span>`)}`;
    }

    // View / Templates

    _renderCacheTable() {
        if (this._caches) {
            let caches = [...this._caches.values()];
            return html`
                <vaadin-grid .items="${caches}" class="datatable" theme="no-border">
                    <vaadin-grid-column auto-width
                                        header="Name"
                                        ${columnBodyRenderer(this._nameRenderer, [])}>
                    </vaadin-grid-column>

                    <vaadin-grid-column auto-width
                                        header="Size"
                                        path="size">
                    </vaadin-grid-column>

                    <vaadin-grid-column auto-width
                                        header=""
                                        ${columnBodyRenderer(this._actionRenderer, [])}
                                        resizable>
                    </vaadin-grid-column>
                </vaadin-grid>`;
        }
    }

     _actionRenderer(cache) {
        return html`
            <vaadin-button theme="small" @click=${() => this._clear(cache.name)} class="button">
                <vaadin-icon class="clearIcon" icon="font-awesome-solid:broom"></vaadin-icon> Clear
            </vaadin-button>`;
    }

    _nameRenderer(cache) {
        return html`
            <vaadin-button theme="small" @click=${() => this._refresh(cache.name)} class="button">
                <vaadin-icon icon="font-awesome-solid:rotate"></vaadin-icon>
            </vaadin-button>
            ${cache.name}`;
    }

In our example, we use Vaadin components to display a table with three columns: the cache name, size, and a "clear" action. The name is rendered using a custom renderer to also contain an "update" action.

The rest of the methods are implementing various actions.

Inject HTML content

When your backend returns HTML content, you need to use the unsafeHTML function:

import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';

// ...

return html`<vaadin-icon icon="font-awesome-solid:syringe" title="channel"></vaadin-icon> ${unsafeHTML(component.description)}`

Display an icon

import '@vaadin/icon';

//...

html`<vaadin-icon icon="font-awesome-solid:syringe"></vaadin-icon>

Find the name of the icon at https://fontawesome.com/icons.

Theme

TODO: Show how to use css theme vars

Menu

You can also add a link directly on the Menu, that points to your page:

WebComponentPageBuilder menuItemPageBuilder = Page.webComponentPageBuilder()
                .icon("font-awesome-regular:face-grin-tongue-wink")
                .componentLink("qwc-jokes-menu.js");

MenuPageBuildItem menuPageBuildItem = new MenuPageBuildItem("Jokes", menuItemPageBuilder);

devui4

Footer

TODO: Show how to participate in the footer

Current Version

Migration Guide 3.16

Next Version

Migration Guide 3.17

Clone this wiki locally