Skip to content

Dev UI v2

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

Dev UI v2

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 page
  • Card: Extensions can add links to their cards that will navigate to their (full) page.
  • 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 in the menu
  • Tab on the footer

In most cases only linked on cards will be used.

Adding link(s) on a card for an extension

Cards are automatically added for all extensions in the running project. By default information on the extension will be displayed (from

To add a card for an extension, create 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.
    PageBuildItem create() {
        PageBuildItem pageBuildItem = new PageBuildItem("Smallrye Health"); // <2> This name must be the extension name

        // Add simple links
        pageBuildItem.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

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

        return pageBuildItem;
    }

}

Use web components

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

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)
    PageBuildItem create(CurateOutcomeBuildItem bi) {
        PageBuildItem pageBuildItem = new PageBuildItem("Cache"); // Must be the extension name
        pageBuildItem.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.

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.

The 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.

Clone this wiki locally