diff --git a/docs/src/main/asciidoc/dev-ui.adoc b/docs/src/main/asciidoc/dev-ui.adoc index ec327daaa2c15..ed1c767df0b35 100644 --- a/docs/src/main/asciidoc/dev-ui.adoc +++ b/docs/src/main/asciidoc/dev-ui.adoc @@ -47,7 +47,7 @@ Extensions can: - <> - <> -- <> +- <> - <> - <> @@ -822,7 +822,7 @@ https://github.com/quarkusio/quarkus/blob/main/extensions/vertx-http/dev-ui-reso ====== Log The log controller is used to add control buttons to a (footer) log. -See <>. +See <>. image::dev-ui-log-control-v2.png[alt=Dev UI Log control,role="center"] @@ -1145,9 +1145,9 @@ See the https://github.com/quarkusio/quarkus/tree/main/extensions/vertx-http/dev The state in Dev UI uses https://github.com/gitaarik/lit-state[LitState]. You can read more about it in their https://gitaarik.github.io/lit-state/build/[documentation]. -== Add a log file +== Add a footer tab -Apart from adding a card and a page, extensions can add a log to the footer. This is useful for logging things that are happening continuously. A page will lose connection to the backend when navigating away from that page, and a log in the footer will be permanently connected. +Apart from adding a card and a page, extensions can add a tab to the footer. This is useful for things that are happening continuously. A page will lose connection to the backend when navigating away from that page, and a log in the footer will be permanently connected. Adding something to the footer works exactly like adding a Card, except you use a `FooterPageBuildItem` rather than a `CardPageBuildItem`. @@ -1179,6 +1179,59 @@ export class QwcJokesLog extends LitElement { https://github.com/phillip-kruger/quarkus-jokes/blob/main/deployment/src/main/resources/dev-ui/qwc-jokes-log.js[Example code] +=== Add a log to the footer + +There is an easy way to add a log stream to the footer, without having to create the above mentioned footer. +If you just want to stream a log to a tab you can just produce a `FooterLogBuildItem`. This way you only provide a name and a `Flow.Publisher` for your log. + +Here is an example from the Dev Services deployment module: + +[source,java] +---- +@BuildStep(onlyIf = { IsDevelopment.class }) +public List config( + // ... + BuildProducer footerLogProducer){ + + // ... + + // Dev UI Log stream + for (DevServiceDescriptionBuildItem service : serviceDescriptions) { + if (service.getContainerInfo() != null) { + footerLogProducer.produce(new FooterLogBuildItem(service.getName(), () -> { + return createLogPublisher(service.getContainerInfo().getId()); + })); + } + } +} + +// ... + +private Flow.Publisher createLogPublisher(String containerId) { + try (FrameConsumerResultCallback resultCallback = new FrameConsumerResultCallback()) { + SubmissionPublisher publisher = new SubmissionPublisher<>(); + resultCallback.addConsumer(OutputFrame.OutputType.STDERR, + frame -> publisher.submit(frame.getUtf8String())); + resultCallback.addConsumer(OutputFrame.OutputType.STDOUT, + frame -> publisher.submit(frame.getUtf8String())); + LogContainerCmd logCmd = DockerClientFactory.lazyClient() + .logContainerCmd(containerId) + .withFollowStream(true) + .withTailAll() + .withStdErr(true) + .withStdOut(true); + logCmd.exec(resultCallback); + + return publisher; + } catch (Exception e) { + throw new RuntimeException(e); + } +} + +---- + + + == Add a section menu This allows an extension to link a page directly in the section Menu. diff --git a/extensions/devservices/deployment/src/main/java/io/quarkus/devservices/deployment/DevServicesProcessor.java b/extensions/devservices/deployment/src/main/java/io/quarkus/devservices/deployment/DevServicesProcessor.java index c1af246c71515..da9de6071daba 100644 --- a/extensions/devservices/deployment/src/main/java/io/quarkus/devservices/deployment/DevServicesProcessor.java +++ b/extensions/devservices/deployment/src/main/java/io/quarkus/devservices/deployment/DevServicesProcessor.java @@ -21,11 +21,16 @@ import java.util.Optional; import java.util.Set; import java.util.TreeMap; +import java.util.concurrent.Flow; +import java.util.concurrent.SubmissionPublisher; import java.util.function.Function; import java.util.stream.Collectors; import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.output.FrameConsumerResultCallback; +import org.testcontainers.containers.output.OutputFrame; +import com.github.dockerjava.api.command.LogContainerCmd; import com.github.dockerjava.api.model.Container; import com.github.dockerjava.api.model.ContainerNetwork; import com.github.dockerjava.api.model.ContainerNetworkSettings; @@ -45,6 +50,7 @@ import io.quarkus.deployment.util.ContainerRuntimeUtil; import io.quarkus.deployment.util.ContainerRuntimeUtil.ContainerRuntime; import io.quarkus.dev.spi.DevModeType; +import io.quarkus.devui.spi.buildtime.FooterLogBuildItem; public class DevServicesProcessor { @@ -58,6 +64,7 @@ public class DevServicesProcessor { public List config( DockerStatusBuildItem dockerStatusBuildItem, BuildProducer commandBuildItemBuildProducer, + BuildProducer footerLogProducer, LaunchModeBuildItem launchModeBuildItem, Optional devServicesLauncherConfig, List devServicesResults) { @@ -80,6 +87,15 @@ public List config( commandBuildItemBuildProducer.produce( new ConsoleCommandBuildItem(new DevServicesCommand(serviceDescriptions))); + // Dev UI Log stream + for (DevServiceDescriptionBuildItem service : serviceDescriptions) { + if (service.getContainerInfo() != null) { + footerLogProducer.produce(new FooterLogBuildItem(service.getName(), () -> { + return createLogPublisher(service.getContainerInfo().getId()); + })); + } + } + if (context == null) { context = ConsoleStateManager.INSTANCE.createContext("Dev Services"); } @@ -104,6 +120,27 @@ public List config( return serviceDescriptions; } + private Flow.Publisher createLogPublisher(String containerId) { + try (FrameConsumerResultCallback resultCallback = new FrameConsumerResultCallback()) { + SubmissionPublisher publisher = new SubmissionPublisher<>(); + resultCallback.addConsumer(OutputFrame.OutputType.STDERR, + frame -> publisher.submit(frame.getUtf8String())); + resultCallback.addConsumer(OutputFrame.OutputType.STDOUT, + frame -> publisher.submit(frame.getUtf8String())); + LogContainerCmd logCmd = DockerClientFactory.lazyClient() + .logContainerCmd(containerId) + .withFollowStream(true) + .withTailAll() + .withStdErr(true) + .withStdOut(true); + logCmd.exec(resultCallback); + + return publisher; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + private List buildServiceDescriptions( DockerStatusBuildItem dockerStatusBuildItem, List devServicesResults, diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/BuildTimeContentProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/BuildTimeContentProcessor.java index ff3954246bce9..2864840626569 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/BuildTimeContentProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/BuildTimeContentProcessor.java @@ -107,6 +107,7 @@ InternalImportMapBuildItem createKnownInternalImportMap(NonApplicationRootPathBu internalImportMapBuildItem.add("qwc-hot-reload-element", contextRoot + "qwc/qwc-hot-reload-element.js"); internalImportMapBuildItem.add("qwc-abstract-log-element", contextRoot + "qwc/qwc-abstract-log-element.js"); internalImportMapBuildItem.add("qwc-server-log", contextRoot + "qwc/qwc-server-log.js"); + internalImportMapBuildItem.add("qwc-footer-log", contextRoot + "qwc/qwc-footer-log.js"); internalImportMapBuildItem.add("qwc-extension-link", contextRoot + "qwc/qwc-extension-link.js"); // Quarkus UI internalImportMapBuildItem.add("qui-ide-link", contextRoot + "qui/qui-ide-link.js"); diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/DevUIProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/DevUIProcessor.java index d94b45e1ae03a..b0e952b333f06 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/DevUIProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/devui/deployment/DevUIProcessor.java @@ -56,6 +56,8 @@ import io.quarkus.devui.runtime.jsonrpc.json.JsonMapper; import io.quarkus.devui.spi.DevUIContent; import io.quarkus.devui.spi.JsonRPCProvidersBuildItem; +import io.quarkus.devui.spi.buildtime.BuildTimeActionBuildItem; +import io.quarkus.devui.spi.buildtime.FooterLogBuildItem; import io.quarkus.devui.spi.buildtime.StaticContentBuildItem; import io.quarkus.devui.spi.page.CardPageBuildItem; import io.quarkus.devui.spi.page.FooterPageBuildItem; @@ -63,6 +65,7 @@ import io.quarkus.devui.spi.page.Page; import io.quarkus.devui.spi.page.PageBuilder; import io.quarkus.devui.spi.page.QuteDataPageBuilder; +import io.quarkus.devui.spi.page.WebComponentPageBuilder; import io.quarkus.maven.dependency.GACT; import io.quarkus.maven.dependency.GACTV; import io.quarkus.qute.Qute; @@ -87,7 +90,7 @@ * This also find all jsonrpc methods and make them available in the jsonRPC Router */ public class DevUIProcessor { - + private static final String FOOTER_LOG_NAMESPACE = "devui-footer-log"; private static final String DEVUI = "dev-ui"; private static final String SLASH = "/"; private static final String DOT = "."; @@ -418,6 +421,54 @@ void createJsonRpcRouter(DevUIRecorder recorder, } } + /** + * This build all the pages for dev ui, based on the extension included + */ + @BuildStep(onlyIf = IsDevelopment.class) + void processFooterLogs(BuildProducer buildTimeActionProducer, + BuildProducer footerPageProducer, + List footerLogBuildItems) { + + List devServiceLogs = new ArrayList<>(); + List footers = new ArrayList<>(); + + for (FooterLogBuildItem footerLogBuildItem : footerLogBuildItems) { + // Create the Json-RPC service that will stream the log + String name = footerLogBuildItem.getName().replaceAll(" ", ""); + + BuildTimeActionBuildItem devServiceLogActions = new BuildTimeActionBuildItem(FOOTER_LOG_NAMESPACE); + devServiceLogActions.addSubscription(name + "Log", ignored -> { + try { + return footerLogBuildItem.getPublisher(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + devServiceLogs.add(devServiceLogActions); + + // Create the Footer in the Dev UI + WebComponentPageBuilder log = Page.webComponentPageBuilder().internal() + .namespace(FOOTER_LOG_NAMESPACE) + .icon("font-awesome-regular:file-lines") + .title(capitalizeFirstLetter(footerLogBuildItem.getName())) + .metadata("jsonRpcMethodName", footerLogBuildItem.getName() + "Log") + .componentLink("qwc-footer-log.js"); + + FooterPageBuildItem footerPageBuildItem = new FooterPageBuildItem(FOOTER_LOG_NAMESPACE, log); + footers.add(footerPageBuildItem); + } + + buildTimeActionProducer.produce(devServiceLogs); + footerPageProducer.produce(footers); + } + + private String capitalizeFirstLetter(String input) { + if (input == null || input.isEmpty()) { + return input; + } + return input.substring(0, 1).toUpperCase() + input.substring(1); + } + /** * This build all the pages for dev ui, based on the extension included */ @@ -449,7 +500,7 @@ void getAllExtensions(List cardPageBuildItems, // Now go through all extensions and check them for active components Map cardPagesMap = getCardPagesMap(curateOutcomeBuildItem, cardPageBuildItems); Map menuPagesMap = getMenuPagesMap(curateOutcomeBuildItem, menuPageBuildItems); - Map footerPagesMap = getFooterPagesMap(curateOutcomeBuildItem, footerPageBuildItems); + Map> footerPagesMap = getFooterPagesMap(curateOutcomeBuildItem, footerPageBuildItems); try { final Yaml yaml = new Yaml(); List activeExtensions = new ArrayList<>(); @@ -559,18 +610,21 @@ void getAllExtensions(List cardPageBuildItems, // Tabs in the footer if (footerPagesMap.containsKey(namespace)) { - FooterPageBuildItem footerPageBuildItem = footerPagesMap.get(namespace); - List footerPageBuilders = footerPageBuildItem.getPages(); - Map buildTimeData = footerPageBuildItem.getBuildTimeData(); - for (PageBuilder pageBuilder : footerPageBuilders) { - Page page = buildFinalPage(pageBuilder, extension, buildTimeData); - extension.addFooterPage(page); + List fbis = footerPagesMap.get(namespace); + for (FooterPageBuildItem footerPageBuildItem : fbis) { + List footerPageBuilders = footerPageBuildItem.getPages(); + + Map buildTimeData = footerPageBuildItem.getBuildTimeData(); + for (PageBuilder pageBuilder : footerPageBuilders) { + Page page = buildFinalPage(pageBuilder, extension, buildTimeData); + extension.addFooterPage(page); + } + // Also make sure the static resources for that static resource is available + produceResources(artifactId, webJarBuildProducer, + devUIWebJarProducer); + footerTabExtensions.add(extension); } - // Also make sure the static resources for that static resource is available - produceResources(artifactId, webJarBuildProducer, - devUIWebJarProducer); - footerTabExtensions.add(extension); } } @@ -582,6 +636,33 @@ void getAllExtensions(List cardPageBuildItems, log.error("Failed to process extension descriptor " + p.toUri(), e); } }); + + // Also add footers for extensions that might not have a runtime + if (!footerPagesMap.isEmpty()) { + for (Map.Entry> footer : footerPagesMap.entrySet()) { + List fbis = footer.getValue(); + for (FooterPageBuildItem footerPageBuildItem : fbis) { + if (footerPageBuildItem.isInternal()) { + Extension deploymentOnlyExtension = new Extension(); + deploymentOnlyExtension.setName(footer.getKey()); + deploymentOnlyExtension.setNamespace(FOOTER_LOG_NAMESPACE); + + List footerPageBuilders = footerPageBuildItem.getPages(); + + for (PageBuilder pageBuilder : footerPageBuilders) { + pageBuilder.namespace(deploymentOnlyExtension.getNamespace()); + pageBuilder.extension(deploymentOnlyExtension.getName()); + pageBuilder.internal(); + Page page = pageBuilder.build(); + deploymentOnlyExtension.addFooterPage(page); + } + + footerTabExtensions.add(deploymentOnlyExtension); + } + } + } + } + extensionsProducer.produce( new ExtensionsBuildItem(activeExtensions, inactiveExtensions, sectionMenuExtensions, footerTabExtensions)); } catch (IOException ex) { @@ -754,11 +835,19 @@ private Map getMenuPagesMap(CurateOutcomeBuildItem cu return m; } - private Map getFooterPagesMap(CurateOutcomeBuildItem curateOutcomeBuildItem, + private Map> getFooterPagesMap(CurateOutcomeBuildItem curateOutcomeBuildItem, List pages) { - Map m = new HashMap<>(); + Map> m = new HashMap<>(); for (FooterPageBuildItem pageBuildItem : pages) { - m.put(pageBuildItem.getExtensionPathName(curateOutcomeBuildItem), pageBuildItem); + + String key = pageBuildItem.getExtensionPathName(curateOutcomeBuildItem); + if (m.containsKey(key)) { + m.get(key).add(pageBuildItem); + } else { + List fbi = new ArrayList<>(); + fbi.add(pageBuildItem); + m.put(key, fbi); + } } return m; } diff --git a/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-footer-log.js b/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-footer-log.js new file mode 100644 index 0000000000000..b51d63d27546e --- /dev/null +++ b/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-footer-log.js @@ -0,0 +1,192 @@ +import { QwcAbstractLogElement, html, css} from 'qwc-abstract-log-element'; +import { repeat } from 'lit/directives/repeat.js'; +import { LogController } from 'log-controller'; +import { JsonRpc } from 'jsonrpc'; + +/** + * This component represent Log file in the footer. + */ +export class QwcFooterLog extends QwcAbstractLogElement { + logControl = new LogController(this); + jsonRpc = new JsonRpc(this,false); + + static styles = css` + .log { + width: 100%; + height: 100%; + max-height: 100%; + display: flex; + flex-direction:column; + } + + .text-warn { + color: var(--lumo-warning-color-50pct); + } + .text-error{ + color: var(--lumo-error-text-color); + } + + .message { + } + `; + + static properties = { + jsonRPCMethodName:{type: String}, + _messages: {state:true}, + _zoom: {state:true}, + _increment: {state: false}, + _followLog: {state: false}, + _observer: {state:false} + }; + + constructor() { + super(); + + this.logControl + .addToggle("On/off switch", true, (e) => { + this._toggleOnOffClicked(e); + }).addItem("Zoom out", "font-awesome-solid:magnifying-glass-minus", "var(--lumo-tertiary-text-color)", (e) => { + this._zoomOut(); + }).addItem("Zoom in", "font-awesome-solid:magnifying-glass-plus", "var(--lumo-tertiary-text-color)", (e) => { + this._zoomIn(); + }).addItem("Clear", "font-awesome-solid:trash-can", "var(--lumo-error-color)", (e) => { + this._clearLog(); + }).addFollow("Follow log", true , (e) => { + this._toggleFollowLog(e); + }).done(); + + this._messages = []; + this._zoom = parseFloat(1.0); + this._increment = parseFloat(0.05); + this._followLog = true; + this.jsonRPCMethodName = null; + } + + connectedCallback() { + super.connectedCallback(); + this._toggleOnOff(true); + } + + disconnectedCallback() { + this._toggleOnOff(false); + + super.disconnectedCallback(); + } + + render() { + if(this.jsonRPCMethodName){ + return html` + ${repeat( + this._messages, + (message) => message.sequenceNumber, + (message, index) => html` +
+ ${this._renderLogEntry(message)} +
+ ` + )} +
`; + } + } + + _renderLogEntry(message){ + + if(message.length>0){ + let c="message"; + if (message.includes(' ERROR ')) { + c = "text-error"; + } else if (message.includes(' WARN ')) { + c = "text-warn"; + } + + return html`${message}`; + }else{ + return html`
`; + } + } + + _handleKeyPress(event) { + if (event.key === 'Enter') { + this._addLogEntry(""); + } + } + + _handleZoomIn(event){ + this._zoomIn(); + } + + _handleZoomOut(event){ + this._zoomOut(); + } + + _toggleOnOffClicked(e){ + this._toggleOnOff(e); + // Add line on stop + if(!e){ + this._addLogEntry("----------------------------------------------------------------------"); + } + } + + hotReload(){ + this._clearLog(); + if(this._observer != null){ + this._toggleOnOff(true); + } + } + + _toggleOnOff(e){ + if(e){ + this._observer = this.jsonRpc[this.jsonRPCMethodName]().onNext(jsonRpcResponse => { + this._addLogEntry(jsonRpcResponse.result); + }); + }else{ + this._observer.cancel(); + this._observer = null; + } + } + + _addLogEntry(entry){ + this._messages = [ + ...this._messages, + entry + ]; + + this._scrollToBottom(); + } + + async _scrollToBottom(){ + if(this._followLog){ + await this.updateComplete; + + const last = Array.from( + this.shadowRoot.querySelectorAll('.logEntry') + ).pop(); + + if(last){ + last.scrollIntoView({ + behavior: "smooth", + block: "end" + }); + } + } + } + + _zoomOut(){ + this._zoom = parseFloat(parseFloat(this._zoom) - parseFloat(this._increment)).toFixed(2); + } + + _zoomIn(){ + this._zoom = parseFloat(parseFloat(this._zoom) + parseFloat(this._increment)).toFixed(2); + } + + _clearLog(){ + this._messages = []; + } + + _toggleFollowLog(e){ + this._followLog = e; + this._scrollToBottom(); + } + +} +customElements.define('qwc-footer-log', QwcFooterLog); \ No newline at end of file diff --git a/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-footer.js b/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-footer.js index 7ac8c1b3b4305..8c5ee9cc8c4af 100644 --- a/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-footer.js +++ b/extensions/vertx-http/dev-ui-resources/src/main/resources/dev-ui/qwc/qwc-footer.js @@ -295,8 +295,14 @@ export class QwcFooter extends observeState(LitElement) { } _renderTabBody(footerTab){ - let dynamicFooter = `<${footerTab.componentName} namespace="${footerTab.namespace}">`; - return html`${unsafeHTML(dynamicFooter)}`; + if(footerTab.componentName === "qwc-footer-log"){ // Reusable footer log. + let jsonRpcMethodName = footerTab.metadata.jsonRpcMethodName; + let dynamicFooter = `<${footerTab.componentName} title="${footerTab.title}" namespace="${footerTab.namespace}" jsonRpcMethodName="${jsonRpcMethodName}">`; + return html`${unsafeHTML(dynamicFooter)}`; + }else{ + let dynamicFooter = `<${footerTab.componentName} title="${footerTab.title}" namespace="${footerTab.namespace}">`; + return html`${unsafeHTML(dynamicFooter)}`; + } } _tabSelected(index){ diff --git a/extensions/vertx-http/dev-ui-spi/src/main/java/io/quarkus/devui/spi/buildtime/FooterLogBuildItem.java b/extensions/vertx-http/dev-ui-spi/src/main/java/io/quarkus/devui/spi/buildtime/FooterLogBuildItem.java new file mode 100644 index 0000000000000..231c175a4425f --- /dev/null +++ b/extensions/vertx-http/dev-ui-spi/src/main/java/io/quarkus/devui/spi/buildtime/FooterLogBuildItem.java @@ -0,0 +1,29 @@ +package io.quarkus.devui.spi.buildtime; + +import java.util.concurrent.Flow; +import java.util.function.Supplier; + +import io.quarkus.devui.spi.AbstractDevUIBuildItem; + +/** + * Add a log to the footer of dev ui + */ +public final class FooterLogBuildItem extends AbstractDevUIBuildItem { + + private final String name; + private final Supplier> publisherSupplier; + + public FooterLogBuildItem(String name, Supplier> publisherSupplier) { + super(DEV_UI); + this.name = name; + this.publisherSupplier = publisherSupplier; + } + + public String getName() { + return name; + } + + public Flow.Publisher getPublisher() { + return publisherSupplier.get(); + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java index e1aee4cfa5657..afbde7e136bff 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/QuarkusErrorHandler.java @@ -246,7 +246,7 @@ private void textResponse(RoutingContext event, String details, String stack, Th try (StringWriter sw = new StringWriter()) { sw.write(NL + HEADING + NL); - sw.write("------------------------" + NL); + sw.write("---------------------------" + NL); sw.write(NL); sw.write("Details:"); sw.write(NL);