-
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 src/main/resources/META-INF/quarkus-extension.yaml
)
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
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.
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 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 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.
TODO: Show how to use css theme vars
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);
TODO: Show how to participate in the footer