-
Notifications
You must be signed in to change notification settings - Fork 2.7k
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.
- 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.
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 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;
}
}
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 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 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.
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)}`
import '@vaadin/icon';
//...
html`<vaadin-icon icon="font-awesome-solid:syringe"></vaadin-icon>
Find the name of the icon at https://fontawesome.com/icons.