This is an automated email from the ASF dual-hosted git repository.

jamesnetherton pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git


The following commit(s) were added to refs/heads/main by this push:
     new 906c84cdf8 Add Camel Dev UI errors page
906c84cdf8 is described below

commit 906c84cdf849ec8391787f5ec80dd25fdba8d720
Author: James Netherton <[email protected]>
AuthorDate: Mon Oct 26 07:42:32 2026 +0000

    Add Camel Dev UI errors page
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../devui/CamelCoreDevConsoleProcessor.java        |   7 +
 .../main/resources/dev-ui/qwc-camel-core-errors.js | 348 +++++++++++++++++++++
 .../devui/CamelQuarkusCoreDevUIErrorsTest.java     |  78 +++++
 3 files changed, 433 insertions(+)

diff --git 
a/extensions-core/core/deployment/src/main/java/org/apache/camel/quarkus/core/deployment/devui/CamelCoreDevConsoleProcessor.java
 
b/extensions-core/core/deployment/src/main/java/org/apache/camel/quarkus/core/deployment/devui/CamelCoreDevConsoleProcessor.java
index 78f415fc3b..76d778e708 100644
--- 
a/extensions-core/core/deployment/src/main/java/org/apache/camel/quarkus/core/deployment/devui/CamelCoreDevConsoleProcessor.java
+++ 
b/extensions-core/core/deployment/src/main/java/org/apache/camel/quarkus/core/deployment/devui/CamelCoreDevConsoleProcessor.java
@@ -66,6 +66,13 @@ public class CamelCoreDevConsoleProcessor {
                 .componentLink("qwc-camel-core-diagram.js")
                 .metadata(CamelDevUIConstants.CONSOLE_ID_METADATA_KEY, 
CamelDevUIConstants.CONSOLE_ID_NONE));
 
+        cardPageBuildItem.addPage(Page.webComponentPageBuilder()
+                .title("Errors")
+                .icon("font-awesome-solid:bug")
+                .componentLink("qwc-camel-core-errors.js")
+                .metadata(CamelDevUIConstants.CONSOLE_ID_METADATA_KEY, 
"errors")
+                .metadata(CamelDevUIConstants.ALLOWED_OPTIONS_METADATA_KEY, 
"stackTrace=true|false"));
+
         cardPageBuildItem.addPage(Page.webComponentPageBuilder()
                 .title("Events")
                 .icon("font-awesome-solid:bolt-lightning")
diff --git 
a/extensions-core/core/deployment/src/main/resources/dev-ui/qwc-camel-core-errors.js
 
b/extensions-core/core/deployment/src/main/resources/dev-ui/qwc-camel-core-errors.js
new file mode 100644
index 0000000000..8b14dd819d
--- /dev/null
+++ 
b/extensions-core/core/deployment/src/main/resources/dev-ui/qwc-camel-core-errors.js
@@ -0,0 +1,348 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {css, html} from 'qwc-hot-reload-element';
+import {QwcCamelCore} from "./qwc-camel-core.js";
+import {columnBodyRenderer, gridRowDetailsRenderer} from '@vaadin/grid/lit.js';
+import '@vaadin/grid';
+import '@vaadin/grid/vaadin-grid-sort-column.js';
+import '@vaadin/vertical-layout';
+import '@qomponent/qui-badge';
+import 'qui-ide-link';
+
+export class QwcCamelCoreErrors extends QwcCamelCore {
+
+    static styles = [QwcCamelCore.styles, css`
+        .details {
+            display: flex;
+            flex-direction: column;
+            gap: 10px;
+            padding: 5px 10px 15px 10px;
+        }
+
+        .details h4 {
+            margin: 0;
+        }
+
+        .details pre {
+            margin: 0;
+            font-size: 85%;
+            white-space: pre-wrap;
+            word-break: break-all;
+        }
+
+        .details table {
+            border-collapse: collapse;
+            font-size: 85%;
+        }
+
+        .details td {
+            padding: 2px 15px 2px 0;
+            vertical-align: top;
+        }
+    `];
+
+    _handledFilterData = [
+        {'label': 'Unhandled', 'value': 'false'},
+        {'label': 'Handled', 'value': 'true'},
+    ];
+
+    static properties = {
+        filterText: {state: true},
+        handledFilter: {state: true},
+        _openedKinds: {state: true},
+    }
+
+    constructor() {
+        super('errors', {'stackTrace': 'true'});
+        this.filterText = '';
+        this.handledFilter = '';
+        this._openedKinds = new Set();
+    }
+
+    filteredData() {
+        const errors = this.consoleData()['errors'] ?? [];
+        const text = this.filterText.toLowerCase();
+        return this._groupByKind(errors
+            .filter(item => !this.handledFilter || String(item.handled) === 
this.handledFilter)
+            .filter(item => !text
+                || (item.routeId ?? '').toLowerCase().includes(text)
+                || (item.nodeId ?? '').toLowerCase().includes(text)
+                || (item.exception?.type ?? '').toLowerCase().includes(text)));
+    }
+
+    // Camel keeps only the newest few exchanges of the same kind of error 
(route, node and exception type)
+    // and counts the rest, so show one row per kind
+    _groupByKind(errors) {
+        const groups = new Map();
+        [...errors].sort((a, b) => b.timestamp - a.timestamp).forEach(entry => 
{
+            const kind = 
`${entry.routeId}|${entry.nodeId}|${entry.exception?.type}`;
+            let group = groups.get(kind);
+            if (!group) {
+                group = {
+                    kind: kind,
+                    latest: entry,
+                    entries: [],
+                    routeId: entry.routeId,
+                    nodeId: entry.nodeId,
+                    exceptionType: entry.exception?.type,
+                    handled: entry.handled,
+                    occurrences: 0,
+                    firstTimestamp: entry.timestamp,
+                    lastTimestamp: entry.timestamp,
+                };
+                groups.set(kind, group);
+            }
+            group.entries.push(entry);
+            group.occurrences = Math.max(group.occurrences, entry.repeatCount 
?? 1, group.entries.length);
+            group.firstTimestamp = Math.min(group.firstTimestamp, 
entry.repeatFirstTimestamp ?? entry.timestamp);
+        });
+        return [...groups.values()];
+    }
+
+    render() {
+        return html`
+            <vaadin-horizontal-layout theme="spacing padding">
+                <vaadin-text-field
+                        style="width: 80%"
+                        placeholder="Filter by route, node or exception type"
+                        @input=${e => this.filterText = e.target.value}
+                        clear-button-visible>
+                    <vaadin-icon slot="prefix" 
icon="font-awesome-solid:magnifying-glass"></vaadin-icon>
+                </vaadin-text-field>
+                <vaadin-combo-box
+                        style="width: 20%"
+                        placeholder="Filter by handled status"
+                        item-label-path="label"
+                        item-value-path="value"
+                        .items="${this._handledFilterData}"
+                        @change="${e => this.handledFilter = e.target.value}"
+                        clear-button-visible>
+                </vaadin-combo-box>
+            </vaadin-horizontal-layout>
+            ${this._renderContent()}
+        `;
+    }
+
+    _renderContent() {
+        const data = this.consoleData();
+        if (data['enabled'] === undefined) {
+            return super.redenderNoDataAvailableMessage();
+        }
+        if (!data['enabled']) {
+            return html`
+                <vaadin-horizontal-layout class="empty-container">
+                    The Camel error registry is disabled. Set 
camel.errorRegistry.enabled=true to capture routing errors.
+                </vaadin-horizontal-layout>`;
+        }
+        return this._renderErrors();
+    }
+
+    _renderErrors() {
+        const groups = this.filteredData();
+        return html`
+            <vaadin-grid .items="${groups}"
+                         .detailsOpenedItems="${groups.filter(group => 
this._openedKinds.has(group.kind))}"
+                         item-id-path="kind"
+                         class="consoleData"
+                         theme="no-border row-stripes"
+                         @click="${e => this._toggleDetails(e)}"
+                         ${gridRowDetailsRenderer((group) => 
this._renderDetails(group), [])}>
+                <vaadin-grid-sort-column
+                        path="lastTimestamp"
+                        auto-width
+                        header="Last Seen"
+                        ${columnBodyRenderer((group) => 
super.codeStyleRenderer(super.toDate(group.lastTimestamp)), [])}
+                        resizable>
+                </vaadin-grid-sort-column>
+                <vaadin-grid-sort-column
+                        path="routeId"
+                        auto-width
+                        header="Route ID"
+                        ${columnBodyRenderer((group) => 
super.codeStyleRenderer(group.routeId), [])}
+                        resizable>
+                </vaadin-grid-sort-column>
+                <vaadin-grid-sort-column
+                        path="nodeId"
+                        auto-width
+                        header="Node ID"
+                        ${columnBodyRenderer((group) => 
super.codeStyleRenderer(group.nodeId), [])}
+                        resizable>
+                </vaadin-grid-sort-column>
+                <vaadin-grid-sort-column
+                        path="exceptionType"
+                        auto-width
+                        header="Exception"
+                        ${columnBodyRenderer((group) => html`<code 
title="${group.exceptionType ?? 
''}">${this._simpleName(group.exceptionType)}</code>`, [])}
+                        resizable>
+                </vaadin-grid-sort-column>
+                <vaadin-grid-column
+                        header="Last Message"
+                        ${columnBodyRenderer((group) => 
super.codeStyleRenderer(group.latest.exception?.message), [])}
+                        resizable>
+                </vaadin-grid-column>
+                <vaadin-grid-column
+                        auto-width
+                        header="Source"
+                        ${columnBodyRenderer((group) => 
super.codeStyleRenderer(group.latest.location), [])}
+                        resizable>
+                </vaadin-grid-column>
+                <vaadin-grid-sort-column
+                        path="occurrences"
+                        auto-width
+                        header="Occurrences"
+                        ${columnBodyRenderer((group) => 
super.numericPillStyleRenderer(group.occurrences), [])}
+                        resizable>
+                </vaadin-grid-sort-column>
+                <vaadin-grid-sort-column
+                        path="handled"
+                        auto-width
+                        header="Handled"
+                        ${columnBodyRenderer((group) => 
this._handledRenderer(group.handled), [])}
+                        resizable>
+                </vaadin-grid-sort-column>
+                <span slot="empty-state">No errors captured</span>
+            </vaadin-grid>
+        `;
+    }
+
+    _toggleDetails(e) {
+        const context = e.currentTarget.getEventContext(e);
+        // clicks inside the expanded details (e.g. selecting text or an IDE 
link) keep it open
+        if (!context.item || context.section === 'details') {
+            return;
+        }
+        const opened = new Set(this._openedKinds);
+        if (!opened.delete(context.item.kind)) {
+            opened.add(context.item.kind);
+        }
+        this._openedKinds = opened;
+    }
+
+    _renderDetails(group) {
+        const item = group.latest;
+        const message = item.message ?? {};
+        return html`
+            <div class="details">
+                <table>
+                    ${this._detailRow('First seen', 
super.toDate(group.firstTimestamp))}
+                    ${this._detailRow('Last seen', 
super.toDate(group.lastTimestamp))}
+                    ${this._detailRow('Occurrences', group.occurrences)}
+                </table>
+                <h4>Latest captured exchanges (${group.entries.length} of 
${group.occurrences})</h4>
+                <table>
+                    ${group.entries.map(entry => html`
+                        <tr>
+                            
<td><code>${super.toDate(entry.timestamp)}</code></td>
+                            <td><code>${entry.exchangeId}</code></td>
+                            <td>${this._handledRenderer(entry.handled)}</td>
+                            <td><code>${entry.exception?.message ?? 
''}</code></td>
+                        </tr>`)}
+                </table>
+                <h4>Latest exchange</h4>
+                <table>
+                    ${this._detailRow('Exchange ID', item.exchangeId)}
+                    ${this._detailRow('From', item.fromEndpointUri)}
+                    ${this._detailRow('Endpoint', item.endpointUri)}
+                    ${this._detailRow('Step ID', item.stepId)}
+                    ${this._detailRow('Elapsed', item.elapsed !== undefined ? 
item.elapsed + ' ms' : undefined)}
+                    ${this._detailRow('Thread', item.threadName)}
+                </table>
+                ${this._renderList('Message history', item.messageHistory)}
+                ${this._renderStackTrace(item.exception)}
+                ${this._renderKeyValues('Headers', message.headers)}
+                ${message.body ? html`
+                    <h4>Body <code>${message.body.type ?? ''}</code></h4>
+                    ${message.body.value !== undefined ? 
html`<pre>${this._unescape(message.body.value)}</pre>` : ''}` : ''}
+                ${this._renderKeyValues('Exchange properties', 
item.exchangeProperties)}
+                ${this._renderKeyValues('Exchange variables', 
item.exchangeVariables)}
+            </div>
+        `;
+    }
+
+    _detailRow(label, value) {
+        if (value === undefined || value === null || value === '') {
+            return '';
+        }
+        return html`<tr><td>${label}</td><td><code>${value}</code></td></tr>`;
+    }
+
+    _renderList(title, lines) {
+        if (!lines || lines.length === 0) {
+            return '';
+        }
+        return html`
+            <h4>${title}</h4>
+            <pre>${lines.join('\n')}</pre>`;
+    }
+
+    _renderStackTrace(exception) {
+        if (!exception?.stackTrace) {
+            return '';
+        }
+        const lines = 
this._unescape(exception.stackTrace).split(/\r?\n/).filter(line => line.trim() 
!== '');
+        return html`
+            <h4>Stack trace</h4>
+            <pre>${lines.map(line => html`<qui-ide-link 
stackTraceLine="${line.trim()}">${line}</qui-ide-link>\n`)}</pre>`;
+    }
+
+    _renderKeyValues(title, entries) {
+        if (!entries || entries.length === 0) {
+            return '';
+        }
+        return html`
+            <h4>${title}</h4>
+            <table>
+                ${entries.map(entry => html`
+                    <tr>
+                        <td><code>${entry.key}</code></td>
+                        <td><code>${entry.type ?? ''}</code></td>
+                        <td><code>${entry.value ?? ''}</code></td>
+                    </tr>`)}
+            </table>`;
+    }
+
+    _handledRenderer(handled) {
+        if (handled) {
+            return html`<qui-badge small 
level="success"><span>Handled</span></qui-badge>`;
+        }
+        return html`<qui-badge small 
level="error"><span>Unhandled</span></qui-badge>`;
+    }
+
+    _simpleName(type) {
+        if (!type) {
+            return '';
+        }
+        return type.substring(type.lastIndexOf('.') + 1);
+    }
+
+    // Camel escapes stack traces and message bodies for JSON before the 
console JSON is serialized
+    _unescape(value) {
+        if (typeof value !== 'string') {
+            return value ?? '';
+        }
+        return value.replace(/\\(u[0-9a-fA-F]{4}|.)/g, (match, escaped) => {
+            if (escaped.length === 5) {
+                return String.fromCharCode(parseInt(escaped.substring(1), 16));
+            }
+            return this._escapes[escaped] ?? escaped;
+        });
+    }
+
+    _escapes = {'n': '\n', 'r': '\r', 't': '\t', 'b': '\b', 'f': '\f'};
+}
+
+customElements.define('qwc-camel-core-errors', QwcCamelCoreErrors);
diff --git 
a/extensions-core/core/deployment/src/test/java/org/apache/camel/quarkus/core/deployment/devui/CamelQuarkusCoreDevUIErrorsTest.java
 
b/extensions-core/core/deployment/src/test/java/org/apache/camel/quarkus/core/deployment/devui/CamelQuarkusCoreDevUIErrorsTest.java
new file mode 100644
index 0000000000..97d1cc2d4f
--- /dev/null
+++ 
b/extensions-core/core/deployment/src/test/java/org/apache/camel/quarkus/core/deployment/devui/CamelQuarkusCoreDevUIErrorsTest.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.quarkus.core.deployment.devui;
+
+import java.time.Duration;
+import java.util.Map;
+
+import io.quarkus.devui.tests.DevUIJsonRPCTest;
+import io.quarkus.test.QuarkusDevModeTest;
+import io.vertx.core.json.Json;
+import io.vertx.core.json.JsonArray;
+import io.vertx.core.json.JsonObject;
+import org.apache.camel.builder.RouteBuilder;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class CamelQuarkusCoreDevUIErrorsTest extends DevUIJsonRPCTest {
+    @RegisterExtension
+    static final QuarkusDevModeTest CONFIG = new QuarkusDevModeTest()
+            .withApplicationRoot(jar -> jar.addClass(FailingRoute.class));
+
+    public CamelQuarkusCoreDevUIErrorsTest() {
+        super("org.apache.camel.quarkus.camel-quarkus-core");
+    }
+
+    @Test
+    void errorsConsoleCapturesRoutingErrors() {
+        JsonObject failure = await().atMost(Duration.ofSeconds(10)).until(() 
-> {
+            JsonArray errors = getErrors();
+            return errors.isEmpty() ? null : errors.getJsonObject(0);
+        }, error -> error != null);
+
+        assertEquals("failing", failure.getString("routeId"));
+        assertEquals("boom", failure.getString("nodeId"));
+        assertFalse(failure.getBoolean("handled"));
+
+        JsonObject exception = failure.getJsonObject("exception");
+        assertEquals(IllegalStateException.class.getName(), 
exception.getString("type"));
+        assertEquals("Forced failure", exception.getString("message"));
+        
assertTrue(exception.getString("stackTrace").contains("IllegalStateException"));
+    }
+
+    private JsonArray getErrors() throws Exception {
+        String result = super.executeJsonRPCMethod(String.class, 
"getConsoleJSON",
+                Map.of("id", "errors", "options", Map.of("stackTrace", 
"true")));
+        JsonObject json = (JsonObject) Json.decodeValue(result);
+        assertTrue(json.getBoolean("enabled"));
+        return json.getJsonArray("errors");
+    }
+
+    public static class FailingRoute extends RouteBuilder {
+        @Override
+        public void configure() {
+            from("timer:fail?repeatCount=1")
+                    .routeId("failing")
+                    .throwException(new IllegalStateException("Forced 
failure")).id("boom");
+        }
+    }
+}

Reply via email to