Skip to content

Commit

Permalink
WebSockets Next: add basic Dev UI
Browse files Browse the repository at this point in the history
- list WebSocket endpoints with info about open connections
- resolves quarkusio#39464
  • Loading branch information
mkouba committed Mar 15, 2024
1 parent 6fe55e0 commit a4bf483
Show file tree
Hide file tree
Showing 9 changed files with 449 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@
import io.quarkus.builder.item.MultiBuildItem;

/**
* A generated {@link io.quarkus.websockets.next.runtime.WebSocketEndpoint}.
* A generated representation of a {@link io.quarkus.websockets.next.runtime.WebSocketEndpoint}.
*/
final class GeneratedEndpointBuildItem extends MultiBuildItem {
public final class GeneratedEndpointBuildItem extends MultiBuildItem {

final String className;
final String path;
public final String endpointClassName;
public final String generatedClassName;
public final String path;

GeneratedEndpointBuildItem(String className, String path) {
this.className = className;
GeneratedEndpointBuildItem(String endpointClassName, String generatedClassName, String path) {
this.endpointClassName = endpointClassName;
this.generatedClassName = generatedClassName;
this.path = path;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ public String apply(String name) {
// and delegates callback invocations to the endpoint bean
String generatedName = generateEndpoint(endpoint, classOutput);
reflectiveClasses.produce(ReflectiveClassBuildItem.builder(generatedName).constructors().build());
generatedEndpoints.produce(new GeneratedEndpointBuildItem(generatedName, endpoint.path));
generatedEndpoints.produce(new GeneratedEndpointBuildItem(endpoint.bean.getImplClazz().name().toString(),
generatedName, endpoint.path));
}
}

Expand All @@ -189,7 +190,7 @@ public void registerRoutes(WebSocketServerRecorder recorder, HttpRootPathBuildIt
RouteBuildItem.Builder builder = RouteBuildItem.builder()
.route(httpRootPath.relativePath(endpoint.path))
.handlerType(HandlerType.NORMAL)
.handler(recorder.createEndpointHandler(endpoint.className));
.handler(recorder.createEndpointHandler(endpoint.generatedClassName));
routes.produce(builder.build());
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package io.quarkus.websockets.next.deployment.devui;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import io.quarkus.deployment.IsDevelopment;
import io.quarkus.deployment.annotations.BuildProducer;
import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.devui.spi.JsonRPCProvidersBuildItem;
import io.quarkus.devui.spi.page.CardPageBuildItem;
import io.quarkus.devui.spi.page.Page;
import io.quarkus.websockets.next.deployment.GeneratedEndpointBuildItem;
import io.quarkus.websockets.next.deployment.WebSocketEndpointBuildItem;
import io.quarkus.websockets.next.runtime.devui.WebSocketNextJsonRPCService;

public class WebSocketServerDevUIProcessor {

@BuildStep(onlyIf = IsDevelopment.class)
public void pages(List<WebSocketEndpointBuildItem> endpoints, List<GeneratedEndpointBuildItem> generatedEndpoints,
BuildProducer<CardPageBuildItem> cardPages) {

CardPageBuildItem pageBuildItem = new CardPageBuildItem();

pageBuildItem.addBuildTimeData("endpoints", createEndpointsJson(endpoints, generatedEndpoints));

pageBuildItem.addPage(Page.webComponentPageBuilder()
.title("Endpoints")
.icon("font-awesome-solid:plug")
.componentLink("qwc-wsn-endpoints.js")
.staticLabel(String.valueOf(endpoints.size())));

cardPages.produce(pageBuildItem);
}

@BuildStep(onlyIf = IsDevelopment.class)
JsonRPCProvidersBuildItem rpcProvider() {
return new JsonRPCProvidersBuildItem(WebSocketNextJsonRPCService.class);
}

private List<Map<String, Object>> createEndpointsJson(List<WebSocketEndpointBuildItem> endpoints,
List<GeneratedEndpointBuildItem> generatedEndpoints) {
List<Map<String, Object>> json = new ArrayList<>();
for (WebSocketEndpointBuildItem endpoint : endpoints) {
Map<String, Object> endpointJson = new HashMap<>();
String clazz = endpoint.bean.getImplClazz().name().toString();
endpointJson.put("clazz", clazz);
endpointJson.put("generatedClazz",
generatedEndpoints.stream().filter(ge -> ge.endpointClassName.equals(clazz)).findFirst()
.orElseThrow().generatedClassName);
endpointJson.put("path", getOriginalPath(endpoint.path));
endpointJson.put("executionMode", endpoint.executionMode.toString());
List<Map<String, Object>> callbacks = new ArrayList<>();
addCallback(endpoint.onOpen, callbacks);
addCallback(endpoint.onBinaryMessage, callbacks);
addCallback(endpoint.onTextMessage, callbacks);
addCallback(endpoint.onPongMessage, callbacks);
addCallback(endpoint.onClose, callbacks);
endpointJson.put("callbacks", callbacks);
json.add(endpointJson);
}
return json;
}

private void addCallback(WebSocketEndpointBuildItem.Callback callback, List<Map<String, Object>> callbacks) {
if (callback != null) {
callbacks.add(Map.of("annotation", callback.annotation.toString(), "method", callback.method.toString()));
}
}

private static final Pattern PATH_PARAM_PATTERN = Pattern.compile(":[a-zA-Z0-9_]+");

static String getOriginalPath(String path) {
StringBuilder sb = new StringBuilder();
Matcher m = PATH_PARAM_PATTERN.matcher(path);
while (m.find()) {
// Replace :foo with {foo}
String match = m.group();
m.appendReplacement(sb, "{" + match.subSequence(1, match.length()) + "}");
}
m.appendTail(sb);
return sb.toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@

import { LitElement, html, css} from 'lit';
import { columnBodyRenderer } from '@vaadin/grid/lit.js';
import '@vaadin/grid';
import '@vaadin/text-field';
import { JsonRpc } from 'jsonrpc';
import { endpoints } from 'build-time-data';


export class QwcWebSocketNextEndpoints extends LitElement {

jsonRpc = new JsonRpc(this);

static styles = css`
:host {
display: flex;
flex-direction: column;
gap: 10px;
}
.endpoints-table {
padding-bottom: 10px;
}
.annotation {
color: var(--lumo-contrast-50pct);
}
.connections-icon {
font-size: small;
color: var(--lumo-contrast-50pct);
cursor: pointer;
}
.top-bar {
display: flex;
align-items: baseline;
gap: 20px;
padding-left: 20px;
padding-right: 20px;
}
.top-bar h4 {
color: var(--lumo-contrast-60pct);
}
`;


static properties = {
_selectedEndpoint: {state: true},
_endpointsAndConnections: {state: true}
};

constructor() {
super();
this._selectedEndpoint = null;
}

connectedCallback() {
super.connectedCallback();
const generatedEndpoints = endpoints.map(e => e.generatedClazz);
this.jsonRpc.getConnections({"endpoints": generatedEndpoints})
.then(jsonResponse => {
this._endpointsAndConnections = endpoints.map(e => {
e.connections = jsonResponse.result[e.generatedClazz];
return e;
});
})
.then(() => {
this._conntectionStatusStream = this.jsonRpc.connectionStatus().onNext(jsonResponse => {
const endpoint = this._endpointsAndConnections.find(e => e.generatedClazz == jsonResponse.result.endpoint);
if (endpoint) {
if (jsonResponse.result.removed) {
const connectionId = jsonResponse.result.id;
endpoint.connections = endpoint.connections.filter(c => c.id != connectionId);
} else {
endpoint.connections = [
...endpoint.connections,
jsonResponse.result
];
}
// TODO this is inefficient but I did not find a way to update the endpoint list
// when a connection is added/removed
// https://lit.dev/docs/components/properties/#mutating-properties
this._endpointsAndConnections = this._endpointsAndConnections.map(e => e);
}
}
);
});
}

disconnectedCallback() {
super.disconnectedCallback();
this._conntectionStatusStream.cancel();
}

render() {
if (this._endpointsAndConnections){
if (this._selectedEndpoint){
return this._renderConnections();
} else{
return this._renderEndpoints();
}
} else {
return html`<span>Loading endpoints...</span>`;
}
}

// TODO I'm not really sure this info is interesting enough
// <vaadin-grid-column auto-width
// header="Execution mode"
// ${columnBodyRenderer(this._renderExecutionMode, [])}
// resizable>
// </vaadin-grid-column>
_renderEndpoints(){
return html`
<vaadin-grid .items="${this._endpointsAndConnections}" class="endpoints-table" theme="no-border" all-rows-visible>
<vaadin-grid-column auto-width
header="Endpoint"
${columnBodyRenderer(this._renderClazz, [])}
resizable>
</vaadin-grid-column>
<vaadin-grid-column auto-width
header="Path"
${columnBodyRenderer(this._renderPath, [])}
resizable>
</vaadin-grid-column>
<vaadin-grid-column auto-width
header="Callbacks"
${columnBodyRenderer(this._renderCallbacks, [])}
resizable>
</vaadin-grid-column>
<vaadin-grid-column auto-width
header="Connections"
${columnBodyRenderer(this._renderConnectionsButton, [])}
resizable>
</vaadin-grid-column>
</vaadin-grid>
`;
}

_renderConnections(){
return html`
${this._renderTopBar()}
<vaadin-grid .items="${this._selectedEndpoint.connections}" class="connections-table" theme="no-border" all-rows-visible>
<vaadin-grid-column auto-width
header="Id"
${columnBodyRenderer(this._renderId, [])}
resizable>
</vaadin-grid-column>
<vaadin-grid-column auto-width
header="Handshake Path"
${columnBodyRenderer(this._renderHandshakePath, [])}
resizable>
</vaadin-grid-column>
<vaadin-grid-column auto-width
header="Opened"
${columnBodyRenderer(this._renderOpenedAt, [])}
resizable>
</vaadin-grid-column>
</vaadin-grid>
`;
}

_renderTopBar(){
return html`
<div class="top-bar">
<vaadin-button @click="${this._showEndpoints}">
<vaadin-icon icon="font-awesome-solid:caret-left" slot="prefix"></vaadin-icon>
Back
</vaadin-button>
<h4>${this._selectedEndpoint.clazz} · Open Connections</h4>
</div>`;
}

_renderPath(endpoint) {
return html`
<code>${endpoint.path}</code>
`;
}

_renderClazz(endpoint) {
return html`
<strong>${endpoint.clazz}</strong>
`;
}

_renderExecutionMode(endpoint) {
return html`
${endpoint.executionMode}
`;
}

_renderCallbacks(endpoint) {
return endpoint.callbacks ? html`<ul>
${ endpoint.callbacks.map(callback =>
html`<li><div class="annotation"><code>${callback.annotation}</code></div><div><code>${callback.method}</code></div></li>`
)}</ul>`: html``;
}

_renderConnectionsButton(endpoint) {
let ret = html`
<vaadin-icon class="connections-icon" icon="font-awesome-solid:plug" @click=${() => this._showConnections(endpoint)}></vaadin-icon>
`;
ret = html`${ret} <span>${endpoint.connections.length}</span>`;
return ret;
}

_renderId(connection) {
return html`
${connection.id}
`;
}

_renderHandshakePath(connection) {
return html`
<code>${connection.handshakePath}</code>
`;
}

_renderOpenedAt(connection) {
return html`
${connection.creationTime}
`;
}

_showConnections(endpoint){
this._selectedEndpoint = endpoint;
}

_showEndpoints(){
this._selectedEndpoint = null;
}

}
customElements.define('qwc-wsn-endpoints', QwcWebSocketNextEndpoints);
1 change: 1 addition & 0 deletions extensions/websockets-next/server/runtime/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

<artifactId>quarkus-websockets-next</artifactId>
<name>Quarkus - WebSockets Next - Runtime</name>
<description>Implementation of the WebSocket API with enhanced efficiency and usability</description>

<dependencies>
<dependency>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.quarkus.websockets.next;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -90,6 +91,12 @@ default void closeAndAwait() {
*/
HandshakeRequest handshakeRequest();

/**
*
* @return the time when this connection was created
*/
Instant creationTime();

/**
* Makes it possible to send messages to all clients connected to the same WebSocket endpoint.
*
Expand Down
Loading

0 comments on commit a4bf483

Please sign in to comment.