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

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


The following commit(s) were added to refs/heads/main by this push:
     new f64de14b7099 camel-tui - Add Kafka tab to More menu
f64de14b7099 is described below

commit f64de14b70994a12c199d4b5614ece9983f7df75
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Jul 14 14:55:14 2026 +0200

    camel-tui - Add Kafka tab to More menu
    
    Add a dedicated Kafka tab under the More submenu that displays live
    Kafka consumer status from the Kafka dev console. Shows route, worker
    state, group ID, topic, partition, offset, and errors in a sortable
    table with MCP and F1 help support.
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../jbang/core/commands/tui/IntegrationInfo.java   |   1 +
 .../jbang/core/commands/tui/KafkaConsumerInfo.java |  32 +++
 .../dsl/jbang/core/commands/tui/KafkaTab.java      | 237 +++++++++++++++++++++
 .../dsl/jbang/core/commands/tui/StatusParser.java  |  39 ++++
 .../dsl/jbang/core/commands/tui/TabRegistry.java   |   5 +-
 .../dsl/jbang/core/commands/tui/TuiIcons.java      |   1 +
 .../jbang/core/commands/tui/TabRegistryTest.java   |   8 +-
 7 files changed, 318 insertions(+), 5 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
index b0201105a1c7..6598567a4198 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
@@ -83,6 +83,7 @@ class IntegrationInfo {
     final List<HealthCheckInfo> healthChecks = new ArrayList<>();
     final List<EndpointInfo> endpoints = new ArrayList<>();
     final List<CircuitBreakerInfo> circuitBreakers = new ArrayList<>();
+    final List<KafkaConsumerInfo> kafkaConsumers = new ArrayList<>();
     int errorCount;
     volatile List<ErrorInfo> errors = new ArrayList<>();
     volatile List<ActivityEntry> activity = List.of();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/KafkaConsumerInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/KafkaConsumerInfo.java
new file mode 100644
index 000000000000..ade2907e5a86
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/KafkaConsumerInfo.java
@@ -0,0 +1,32 @@
+/*
+ * 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.dsl.jbang.core.commands.tui;
+
+class KafkaConsumerInfo {
+    String routeId;
+    String uri;
+    String threadId;
+    String state;
+    String lastError;
+    String groupId;
+    String groupInstanceId;
+    String memberId;
+    int generationId;
+    String lastTopic;
+    int lastPartition;
+    long lastOffset;
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/KafkaTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/KafkaTab.java
new file mode 100644
index 000000000000..945ab7f1b289
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/KafkaTab.java
@@ -0,0 +1,237 @@
+/*
+ * 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.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Span;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.table.Cell;
+import dev.tamboui.widgets.table.Row;
+import dev.tamboui.widgets.table.Table;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.*;
+
+class KafkaTab extends AbstractTableTab {
+
+    KafkaTab(MonitorContext ctx) {
+        super(ctx, "route", "state", "group", "topic");
+    }
+
+    @Override
+    protected int getRowCount() {
+        IntegrationInfo info = ctx.findSelectedIntegration();
+        return info != null ? info.kafkaConsumers.size() : 0;
+    }
+
+    @Override
+    public void onTabSelected() {
+        IntegrationInfo info = ctx.findSelectedIntegration();
+        if (info != null && !info.kafkaConsumers.isEmpty() && 
tableState.selected() == null) {
+            tableState.select(0);
+        }
+    }
+
+    @Override
+    protected void renderContent(Frame frame, Rect area, IntegrationInfo info) 
{
+        List<KafkaConsumerInfo> sorted = new ArrayList<>(info.kafkaConsumers);
+        sorted.sort(this::sortKafka);
+
+        List<Row> rows = new ArrayList<>();
+        for (KafkaConsumerInfo ki : sorted) {
+            String state = ki.state != null ? ki.state : "";
+            Style stateStyle = switch (state.toLowerCase(Locale.ROOT)) {
+                case "running" -> Theme.success();
+                case "paused", "resume_requested" -> Theme.warning();
+                default -> state.isEmpty() ? Style.EMPTY : Theme.error();
+            };
+            String displayState = capitalize(state);
+            String topic = ki.lastTopic != null ? ki.lastTopic : "";
+            String partition = ki.lastTopic != null ? 
String.valueOf(ki.lastPartition) : "";
+            String offset = ki.lastTopic != null ? 
String.valueOf(ki.lastOffset) : "";
+            String error = ki.lastError != null ? ki.lastError : "";
+            String uri = ki.uri != null ? ki.uri : "";
+
+            rows.add(Row.from(
+                    Cell.from(Span.styled(ki.routeId != null ? ki.routeId : 
"", Style.EMPTY.fg(Theme.accent()))),
+                    Cell.from(Span.styled(displayState, stateStyle)),
+                    Cell.from(ki.groupId != null ? ki.groupId : ""),
+                    Cell.from(topic),
+                    rightCell(partition, 9),
+                    rightCell(offset, 10),
+                    Cell.from(Span.styled(error, error.isEmpty() ? Style.EMPTY 
: Theme.error())),
+                    Cell.from(Span.styled(uri, Style.EMPTY.dim()))));
+        }
+
+        if (rows.isEmpty()) {
+            rows.add(emptyRow("No Kafka consumers", 8));
+        }
+
+        Table table = Table.builder()
+                .rows(rows)
+                .header(Row.from(
+                        Cell.from(Span.styled(sortLabel("ROUTE", "route"), 
sortStyle("route"))),
+                        Cell.from(Span.styled(sortLabel("STATUS", "state"), 
sortStyle("state"))),
+                        Cell.from(Span.styled(sortLabel("GROUP-ID", "group"), 
sortStyle("group"))),
+                        Cell.from(Span.styled(sortLabel("TOPIC", "topic"), 
sortStyle("topic"))),
+                        rightCell("PARTITION", 9, Style.EMPTY.bold()),
+                        rightCell("OFFSET", 10, Style.EMPTY.bold()),
+                        Cell.from(Span.styled("ERROR", Style.EMPTY.bold())),
+                        Cell.from(Span.styled("ENDPOINT", 
Style.EMPTY.bold()))))
+                .widths(
+                        Constraint.length(20),
+                        Constraint.length(12),
+                        Constraint.length(20),
+                        Constraint.length(20),
+                        Constraint.length(9),
+                        Constraint.length(10),
+                        Constraint.length(30),
+                        Constraint.fill())
+                .highlightStyle(Theme.selectionBg())
+                .highlightSpacing(Table.HighlightSpacing.ALWAYS)
+                
.block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL)
+                        .title(" Kafka ").build())
+                .build();
+
+        lastTableArea = area;
+        frame.renderStatefulWidget(table, area, tableState);
+        renderScrollbar(frame, sorted.size());
+    }
+
+    private int sortKafka(KafkaConsumerInfo a, KafkaConsumerInfo b) {
+        int result = switch (sort) {
+            case "state" -> compareStr(a.state, b.state);
+            case "group" -> compareStr(a.groupId, b.groupId);
+            case "topic" -> compareStr(a.lastTopic, b.lastTopic);
+            default -> compareStr(a.routeId, b.routeId);
+        };
+        return sortReversed ? -result : result;
+    }
+
+    private static String capitalize(String s) {
+        if (s == null || s.isEmpty()) {
+            return "";
+        }
+        String lower = s.toLowerCase(Locale.ROOT);
+        return Character.toUpperCase(lower.charAt(0)) + lower.substring(1);
+    }
+
+    @Override
+    public SelectionContext getSelectionContext() {
+        IntegrationInfo info = ctx.findSelectedIntegration();
+        if (info == null || info.kafkaConsumers.isEmpty()) {
+            return null;
+        }
+        List<KafkaConsumerInfo> sorted = new ArrayList<>(info.kafkaConsumers);
+        sorted.sort(this::sortKafka);
+        List<String> items = sorted.stream()
+                .map(ki -> (ki.routeId != null ? ki.routeId : "") + " " + 
(ki.groupId != null ? ki.groupId : ""))
+                .toList();
+        Integer sel = tableState.selected();
+        return new SelectionContext("table", items, sel != null ? sel : -1, 
items.size(), "Kafka");
+    }
+
+    @Override
+    public String description() {
+        return "Apache Kafka consumer status (group, topic, partition, 
offset)";
+    }
+
+    @Override
+    public String getHelpText() {
+        return """
+                # Kafka
+
+                Displays live status for all Kafka consumers in the selected 
integration.
+                Data comes from the Kafka dev console built into 
`camel-kafka`, which
+                tracks each consumer worker thread's group membership, current 
position,
+                and health state.
+
+                ## Table Columns
+
+                - **ROUTE** — Route ID that owns this Kafka consumer
+                - **STATUS** — Worker state: `Running` (green, actively 
polling),
+                  `Paused` (yellow, consumer paused), or error states (red)
+                - **GROUP-ID** — Kafka consumer group ID this consumer belongs 
to
+                - **TOPIC** — Topic of the last consumed record
+                - **PARTITION** — Partition number of the last consumed record
+                - **OFFSET** — Offset of the last consumed record
+                - **ERROR** — Last error message if the consumer is unhealthy
+                - **ENDPOINT** — Full Camel endpoint URI for this consumer
+
+                ## What to Look For
+
+                - **All consumers Running**: Normal operation. Offsets should 
be
+                  advancing steadily if messages are flowing.
+                - **Consumer Paused**: The consumer has been programmatically 
paused
+                  (e.g., by a route policy or manual suspension).
+                - **Error column populated**: The consumer worker is unhealthy.
+                  Check the error message for connection issues, authentication
+                  failures, or deserialization errors.
+                - **Offset not advancing**: If messages are being produced but 
the
+                  offset stays the same, the consumer may be stuck or the topic
+                  may have no new messages on that partition.
+
+                ## Keys
+
+                - `Up/Down` — select consumer
+                - `s` — cycle sort column
+                - `S` — reverse sort order
+                """;
+    }
+
+    @Override
+    public JsonObject getTableDataAsJson() {
+        IntegrationInfo info = ctx.findSelectedIntegration();
+        if (info == null) {
+            return null;
+        }
+        JsonObject result = new JsonObject();
+        result.put("tab", "Kafka");
+        JsonArray rows = new JsonArray();
+        List<KafkaConsumerInfo> sorted = new ArrayList<>(info.kafkaConsumers);
+        sorted.sort(this::sortKafka);
+        for (KafkaConsumerInfo ki : sorted) {
+            JsonObject row = new JsonObject();
+            row.put("routeId", ki.routeId);
+            row.put("state", ki.state);
+            row.put("groupId", ki.groupId);
+            row.put("lastTopic", ki.lastTopic);
+            row.put("lastPartition", ki.lastPartition);
+            row.put("lastOffset", ki.lastOffset);
+            if (ki.lastError != null) {
+                row.put("lastError", ki.lastError);
+            }
+            row.put("uri", ki.uri);
+            rows.add(row);
+        }
+        result.put("rows", rows);
+        result.put("totalRows", info.kafkaConsumers.size());
+        Integer sel = tableState.selected();
+        result.put("selectedIndex", sel != null ? sel : -1);
+        return result;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
index 85482474c610..f0a3be50cd7a 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
@@ -382,6 +382,9 @@ final class StatusParser {
             }
         }
 
+        // Parse Kafka consumers
+        parseKafkaSection(root, info);
+
         // Parse error count from error registry
         JsonObject errorsObj = (JsonObject) root.get("errors");
         if (errorsObj != null) {
@@ -997,6 +1000,42 @@ final class StatusParser {
         }
     }
 
+    private static void parseKafkaSection(JsonObject root, IntegrationInfo 
info) {
+        JsonObject kafkaObj = (JsonObject) root.get("kafka");
+        if (kafkaObj == null) {
+            return;
+        }
+        JsonArray consumers = (JsonArray) kafkaObj.get("kafkaConsumers");
+        if (consumers == null) {
+            return;
+        }
+        for (Object c : consumers) {
+            JsonObject cj = (JsonObject) c;
+            String routeId = cj.getString("routeId");
+            String uri = cj.getString("uri");
+            JsonArray workers = (JsonArray) cj.get("workers");
+            if (workers != null) {
+                for (Object w : workers) {
+                    JsonObject wo = (JsonObject) w;
+                    KafkaConsumerInfo ki = new KafkaConsumerInfo();
+                    ki.routeId = routeId;
+                    ki.uri = uri;
+                    ki.threadId = wo.getString("threadId");
+                    ki.state = wo.getString("state");
+                    ki.lastError = wo.getString("lastError");
+                    ki.groupId = wo.getString("groupId");
+                    ki.groupInstanceId = wo.getString("groupInstanceId");
+                    ki.memberId = wo.getString("memberId");
+                    ki.generationId = wo.getIntegerOrDefault("generationId", 
0);
+                    ki.lastTopic = wo.getString("lastTopic");
+                    ki.lastPartition = wo.getIntegerOrDefault("lastPartition", 
0);
+                    ki.lastOffset = wo.getLongOrDefault("lastOffset", 0);
+                    info.kafkaConsumers.add(ki);
+                }
+            }
+        }
+    }
+
     @SuppressWarnings("unchecked")
     private static void parseMicrometerMeters(
             JsonObject micrometerObj, String section, String type, 
IntegrationInfo info) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java
index ff8ba0099fdf..06c9d3ffdeff 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java
@@ -93,6 +93,7 @@ class TabRegistry {
     private SpansTab spansTab;
     private ProcessTab processTab;
     private OverviewTab overviewTab;
+    private KafkaTab kafkaTab;
     private DataSourceTab dataSourceTab;
     private SqlQueryTab sqlQueryTab;
     private SqlTraceTab sqlTraceTab;
@@ -113,6 +114,7 @@ class TabRegistry {
         diagramTab = new DiagramTab(ctx);
         routesTab = new RoutesTab(ctx);
         consumersTab = new ConsumersTab(ctx);
+        kafkaTab = new KafkaTab(ctx);
         dataSourceTab = new DataSourceTab(ctx);
         heapHistogramTab = new HeapHistogramTab(ctx);
         memoryLeakTab = new MemoryLeakTab(ctx);
@@ -161,9 +163,10 @@ class TabRegistry {
                 new MoreTab(TuiIcons.TAB_HEAP, "Heap Histogram", "&Heap 
Histogram", heapHistogramTab),
                 new MoreTab(TuiIcons.TAB_INFLIGHT, "Inflight", "In&flight", 
inflightTab),
                 new MoreTab(TuiIcons.TAB_DATASOURCE, "JDBC DataSource", "&JDBC 
DataSource", dataSourceTab),
+                new MoreTab(TuiIcons.TAB_KAFKA, "Kafka", "&Kafka", kafkaTab),
                 new MoreTab(TuiIcons.TAB_MAVEN_DEPENDENCIES, "Maven 
Dependencies", "Maven &Dependencies", mavenDependenciesTab),
                 new MoreTab(TuiIcons.TAB_MEMORY, "Memory", "&Memory", 
memoryTab),
-                new MoreTab(TuiIcons.TAB_MEMORY_LEAK, "Memory Leak", "Memory 
Lea&k", memoryLeakTab),
+                new MoreTab(TuiIcons.TAB_MEMORY_LEAK, "Memory Leak", "Memor&y 
Leak", memoryLeakTab),
                 new MoreTab(TuiIcons.TAB_METRICS, "Metrics", "Metr&ics", 
metricsTab),
                 new MoreTab(TuiIcons.TAB_SQL_QUERY, "SQL Query", "S&QL Query", 
sqlQueryTab),
                 new MoreTab(TuiIcons.TAB_SQL_TRACE, "SQL Trace", "SQL T&race", 
sqlTraceTab),
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
index c80d022373ca..95237e6e990e 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
@@ -134,6 +134,7 @@ final class TuiIcons {
     static final String TAB_DATASOURCE = "💿";
     static final String TAB_HEAP = MEMORY;
     static final String TAB_INFLIGHT = RESET;
+    static final String TAB_KAFKA = "📨";
     static final String TAB_MAVEN_DEPENDENCIES = "📜";
     static final String TAB_MEMORY = MEMORY;
     static final String TAB_MEMORY_LEAK = "💧";
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistryTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistryTest.java
index 2560954442bc..db15e29f4996 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistryTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistryTest.java
@@ -82,8 +82,8 @@ class TabRegistryTest {
     }
 
     @Test
-    void moreTabsHasTwentyOneEntries() {
-        assertEquals(21, registry.moreTabs().size());
+    void moreTabsHasTwentyTwoEntries() {
+        assertEquals(22, registry.moreTabs().size());
     }
 
     @Test
@@ -109,8 +109,8 @@ class TabRegistryTest {
         // MORE_SHORTCUTS array carried before the MoreTab refactor. A label 
edit that repoints a key must fail here.
         List<Character> shortcuts = 
registry.moreTabs().stream().map(TabRegistry.MoreTab::shortcut).toList();
         assertEquals(
-                List.of('B', 'W', 'C', 'A', 'G', 'N', 'V', 'E', 'H', 'F', 'J', 
'D', 'M', 'K', 'I', 'Q', 'R', 'O', 'P', 'S',
-                        'T'),
+                List.of('B', 'W', 'C', 'A', 'G', 'N', 'V', 'E', 'H', 'F', 'J', 
'K', 'D', 'M', 'Y', 'I', 'Q', 'R', 'O', 'P',
+                        'S', 'T'),
                 shortcuts, "More tab shortcut letters must match the 
historical sequence");
     }
 

Reply via email to