This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch feature/CAMEL-24259-tui-tabs-and-inline-docs in repository https://gitbox.apache.org/repos/asf/camel.git
commit d5f82f5ae252f7c117ccc25b621e3b338d269334 Author: Claus Ibsen <[email protected]> AuthorDate: Sun Jul 26 10:01:34 2026 +0200 CAMEL-24259: camel-jbang - TUI add Producers, Events, and Route Controller tabs Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../camel/cli/connector/LocalCliConnector.java | 8 + .../dsl/jbang/core/commands/tui/EventInfo.java | 25 ++ .../dsl/jbang/core/commands/tui/EventTab.java | 234 ++++++++++++++++ .../jbang/core/commands/tui/IntegrationInfo.java | 7 + .../dsl/jbang/core/commands/tui/ProducerInfo.java | 27 ++ .../dsl/jbang/core/commands/tui/ProducersTab.java | 234 ++++++++++++++++ .../core/commands/tui/RouteControllerInfo.java | 29 ++ .../core/commands/tui/RouteControllerTab.java | 293 +++++++++++++++++++++ .../dsl/jbang/core/commands/tui/StatusParser.java | 70 +++++ .../dsl/jbang/core/commands/tui/TabRegistry.java | 11 + .../dsl/jbang/core/commands/tui/TuiIcons.java | 3 + 11 files changed, 941 insertions(+) diff --git a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java index 290f2ec1c903..ad33fe6a3e93 100644 --- a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java +++ b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java @@ -1529,6 +1529,14 @@ public class LocalCliConnector extends ServiceSupport implements CliConnector, C root.put("producers", json); } } + DevConsole dc14c = dcr.resolveById("route-controller"); + if (dc14c != null) { + JsonObject json + = (JsonObject) dc14c.call(DevConsole.MediaType.JSON, Map.of("stacktrace", "false")); + if (json != null && !json.isEmpty()) { + root.put("routeController", json); + } + } DevConsole dc15 = dcr.resolveById("variables"); if (dc15 != null) { JsonObject json = (JsonObject) dc15.call(DevConsole.MediaType.JSON); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EventInfo.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EventInfo.java new file mode 100644 index 000000000000..ed3893d11b79 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EventInfo.java @@ -0,0 +1,25 @@ +/* + * 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 EventInfo { + String type; + String category; + long timestamp; + String exchangeId; + String message; +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EventTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EventTab.java new file mode 100644 index 000000000000..0a9ad6be45cc --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EventTab.java @@ -0,0 +1,234 @@ +/* + * 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 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.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.MouseEvent; +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.TimeUtils; +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 EventTab extends AbstractTableTab { + + EventTab(MonitorContext ctx) { + super(ctx, "timestamp", "category", "type", "message"); + this.sort = "timestamp"; + } + + @Override + public void navigateUp() { + } + + @Override + public void navigateDown() { + } + + @Override + public boolean handleKeyEvent(KeyEvent ke) { + if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP) + || ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN) + || ke.isHome() || ke.isEnd()) { + return false; + } + return super.handleKeyEvent(ke); + } + + @Override + public boolean handleMouseEvent(MouseEvent me, Rect area) { + return false; + } + + @Override + protected int getRowCount() { + IntegrationInfo info = ctx.findSelectedIntegration(); + return info != null ? info.events.size() : 0; + } + + @Override + protected void renderContent(Frame frame, Rect area, IntegrationInfo info) { + List<EventInfo> sorted = new ArrayList<>(info.events); + sorted.sort(this::sortEvent); + + List<Row> rows = new ArrayList<>(); + for (EventInfo ei : sorted) { + String ts = ei.timestamp > 0 ? TimeUtils.printSince(ei.timestamp) : ""; + Style categoryStyle = categoryStyle(ei.category); + + rows.add(Row.from( + rightCell(ts, 12), + Cell.from(Span.styled(ei.category != null ? ei.category : "", categoryStyle)), + Cell.from(Span.styled(ei.type != null ? ei.type : "", Style.EMPTY.fg(Theme.accent()))), + Cell.from(ei.message != null ? ei.message : ""))); + } + + if (rows.isEmpty()) { + rows.add(emptyRow("No events", 4)); + } + + Table table = Table.builder() + .rows(rows) + .header(Row.from( + rightCell(sortLabel("AGO", "timestamp"), 12, sortStyle("timestamp")), + Cell.from(Span.styled(sortLabel("CATEGORY", "category"), sortStyle("category"))), + Cell.from(Span.styled(sortLabel("TYPE", "type"), sortStyle("type"))), + Cell.from(Span.styled(sortLabel("MESSAGE", "message"), sortStyle("message"))))) + .widths( + Constraint.length(12), + Constraint.length(12), + Constraint.length(30), + Constraint.fill()) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" Events ").build()) + .build(); + + lastTableArea = area; + frame.renderStatefulWidget(table, area, tableState); + renderScrollbar(frame, sorted.size()); + } + + private int sortEvent(EventInfo a, EventInfo b) { + int result = switch (sort) { + case "category" -> { + String ca = a.category != null ? a.category : ""; + String cb = b.category != null ? b.category : ""; + yield ca.compareToIgnoreCase(cb); + } + case "type" -> { + String ta = a.type != null ? a.type : ""; + String tb = b.type != null ? b.type : ""; + yield ta.compareToIgnoreCase(tb); + } + case "message" -> { + String ma = a.message != null ? a.message : ""; + String mb = b.message != null ? b.message : ""; + yield ma.compareToIgnoreCase(mb); + } + default -> // "timestamp" — newest first by default + Long.compare(b.timestamp, a.timestamp); + }; + return sortReversed ? -result : result; + } + + private static Style categoryStyle(String category) { + if (category == null) { + return Style.EMPTY; + } + return switch (category) { + case "route" -> Style.EMPTY.fg(Theme.accent()); + case "exchange" -> Theme.success(); + default -> Style.EMPTY; + }; + } + + @Override + public SelectionContext getSelectionContext() { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null || info.events.isEmpty()) { + return null; + } + List<EventInfo> sorted = new ArrayList<>(info.events); + sorted.sort(this::sortEvent); + List<String> items = sorted.stream().map(e -> e.type != null ? e.type : "").toList(); + Integer sel = tableState.selected(); + return new SelectionContext("table", items, sel != null ? sel : -1, items.size(), "Events"); + } + + @Override + public String description() { + return "Camel lifecycle events (context, route, and exchange events)"; + } + + @Override + public String getHelpText() { + return """ + # Events + + Shows Camel lifecycle events captured by the event notifier. Events are + grouped into three categories: + + - **general** — Context-level events: CamelContext starting/started/stopping, + service add/remove, component add/remove + - **route** — Route lifecycle events: route added/removed/started/stopped/reloaded + - **exchange** — Exchange events: exchange created/completed/failed/sending/sent + + Events are stored in a circular buffer (default capacity 25 per category). + Only the most recent events are shown. + + ## Table Columns + + - **AGO** — How long ago the event occurred (e.g., `2s`, `1m30s`) + - **CATEGORY** — Event category: `general`, `route`, or `exchange` + - **TYPE** — The specific event type (e.g., `CamelContextStartedEvent`, + `RouteStartedEvent`, `ExchangeCompletedEvent`) + - **MESSAGE** — Human-readable event description + + ## Keys + + - `Up/Down` — select event + - `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", "Events"); + JsonArray rows = new JsonArray(); + List<EventInfo> sorted = new ArrayList<>(info.events); + sorted.sort(this::sortEvent); + for (EventInfo ei : sorted) { + JsonObject row = new JsonObject(); + row.put("type", ei.type); + row.put("category", ei.category); + if (ei.timestamp > 0) { + row.put("timestamp", ei.timestamp); + } + if (ei.exchangeId != null) { + row.put("exchangeId", ei.exchangeId); + } + row.put("message", ei.message); + rows.add(row); + } + result.put("rows", rows); + result.put("totalRows", info.events.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/IntegrationInfo.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java index 6598567a4198..88a2b654a942 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 @@ -80,6 +80,13 @@ class IntegrationInfo { long vanishStart; final List<RouteInfo> routes = new ArrayList<>(); final List<ConsumerInfo> consumers = new ArrayList<>(); + final List<ProducerInfo> producers = new ArrayList<>(); + final List<EventInfo> events = new ArrayList<>(); + String routeControllerType; + boolean routeControllerUnhealthy; + int routeControllerRestartingRoutes; + int routeControllerExhaustedRoutes; + final List<RouteControllerInfo> routeControllerRoutes = new ArrayList<>(); final List<HealthCheckInfo> healthChecks = new ArrayList<>(); final List<EndpointInfo> endpoints = new ArrayList<>(); final List<CircuitBreakerInfo> circuitBreakers = new ArrayList<>(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProducerInfo.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProducerInfo.java new file mode 100644 index 000000000000..e1e697163eac --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProducerInfo.java @@ -0,0 +1,27 @@ +/* + * 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 ProducerInfo { + String uri; + String state; + String className; + String routeId; + String stepId; + boolean remote; + boolean singleton; +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProducersTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProducersTab.java new file mode 100644 index 000000000000..38c63698c796 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ProducersTab.java @@ -0,0 +1,234 @@ +/* + * 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 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.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.MouseEvent; +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 ProducersTab extends AbstractTableTab { + + ProducersTab(MonitorContext ctx) { + super(ctx, "route", "status", "type", "uri"); + } + + @Override + public void navigateUp() { + } + + @Override + public void navigateDown() { + } + + @Override + public boolean handleKeyEvent(KeyEvent ke) { + if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP) + || ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN) + || ke.isHome() || ke.isEnd()) { + return false; + } + return super.handleKeyEvent(ke); + } + + @Override + public boolean handleMouseEvent(MouseEvent me, Rect area) { + return false; + } + + @Override + protected int getRowCount() { + IntegrationInfo info = ctx.findSelectedIntegration(); + return info != null ? info.producers.size() : 0; + } + + @Override + protected void renderContent(Frame frame, Rect area, IntegrationInfo info) { + List<ProducerInfo> sorted = new ArrayList<>(info.producers); + sorted.sort(this::sortProducer); + + List<Row> rows = new ArrayList<>(); + for (ProducerInfo pi : sorted) { + String type = producerType(pi); + Style statusStyle = "Started".equals(pi.state) ? Theme.success() : Theme.error(); + + rows.add(Row.from( + Cell.from(Span.styled(" " + (pi.routeId != null ? pi.routeId : ""), Style.EMPTY.fg(Theme.accent()))), + Cell.from(Span.styled(pi.state != null ? pi.state : "", statusStyle)), + Cell.from(type), + Cell.from(pi.remote ? "Yes" : "No"), + Cell.from(pi.uri != null ? pi.uri : ""))); + } + + if (rows.isEmpty()) { + rows.add(emptyRow("No producers", 5)); + } + + Table table = Table.builder() + .rows(rows) + .header(Row.from( + Cell.from(Span.styled(" " + sortLabel("ROUTE", "route"), sortStyle("route"))), + Cell.from(Span.styled(sortLabel("STATUS", "status"), sortStyle("status"))), + Cell.from(Span.styled(sortLabel("TYPE", "type"), sortStyle("type"))), + Cell.from(Span.styled("REMOTE", Style.EMPTY.bold())), + Cell.from(Span.styled(sortLabel("URI", "uri"), sortStyle("uri"))))) + .widths( + Constraint.length(20), + Constraint.length(10), + Constraint.length(20), + Constraint.length(8), + Constraint.fill()) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" Producers ").build()) + .build(); + + lastTableArea = area; + frame.renderStatefulWidget(table, area, tableState); + renderScrollbar(frame, sorted.size()); + } + + private int sortProducer(ProducerInfo a, ProducerInfo b) { + int result = switch (sort) { + case "status" -> { + String sa = a.state != null ? a.state : ""; + String sb = b.state != null ? b.state : ""; + yield sa.compareToIgnoreCase(sb); + } + case "type" -> { + String ta = producerType(a); + String tb = producerType(b); + yield ta.compareToIgnoreCase(tb); + } + case "uri" -> { + String ua = a.uri != null ? a.uri : ""; + String ub = b.uri != null ? b.uri : ""; + yield ua.compareToIgnoreCase(ub); + } + default -> { // "route" + String ra = a.routeId != null ? a.routeId : ""; + String rb = b.routeId != null ? b.routeId : ""; + yield ra.compareToIgnoreCase(rb); + } + }; + return sortReversed ? -result : result; + } + + private static String producerType(ProducerInfo pi) { + if (pi.className == null) { + return ""; + } + String s = pi.className; + if (s.endsWith("Producer")) { + s = s.substring(0, s.length() - 8); + } + int dot = s.lastIndexOf('.'); + return dot >= 0 ? s.substring(dot + 1) : s; + } + + @Override + public SelectionContext getSelectionContext() { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null || info.producers.isEmpty()) { + return null; + } + List<ProducerInfo> sorted = new ArrayList<>(info.producers); + sorted.sort(this::sortProducer); + List<String> items = sorted.stream().map(p -> p.routeId != null ? p.routeId : "").toList(); + Integer sel = tableState.selected(); + return new SelectionContext("table", items, sel != null ? sel : -1, items.size(), "Producers"); + } + + @Override + public String description() { + return "Producer statistics (output endpoints sending data to external systems)"; + } + + @Override + public String getHelpText() { + return """ + # Producers + + Producers are the **output** side of a Camel route. They send data to + external systems (message brokers, HTTP endpoints, databases, files, etc.). + + Unlike consumers (one per route), a route can have multiple producers + — each `.to()` or `.toD()` call in the route creates a producer. + + ## Table Columns + + - **ROUTE** — The route this producer belongs to + - **STATUS** — Producer state: `Started` (running normally) or `Stopped` + - **TYPE** — The Camel component type (e.g., `Kafka`, `Http`, `Log`, `Seda`) + - **REMOTE** — Whether this producer sends to a remote system (`Yes`) or is in-process (`No`) + - **URI** — The full endpoint URI (e.g., `kafka://my-topic`, `log://mylogger`) + + ## Keys + + - `Up/Down` — select producer + - `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", "Producers"); + JsonArray rows = new JsonArray(); + List<ProducerInfo> sorted = new ArrayList<>(info.producers); + sorted.sort(this::sortProducer); + for (ProducerInfo pi : sorted) { + JsonObject row = new JsonObject(); + row.put("routeId", pi.routeId); + row.put("uri", pi.uri); + row.put("state", pi.state); + row.put("className", pi.className); + row.put("remote", pi.remote); + row.put("singleton", pi.singleton); + if (pi.stepId != null) { + row.put("stepId", pi.stepId); + } + rows.add(row); + } + result.put("rows", rows); + result.put("totalRows", info.producers.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/RouteControllerInfo.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteControllerInfo.java new file mode 100644 index 000000000000..50d9c0296234 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteControllerInfo.java @@ -0,0 +1,29 @@ +/* + * 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 RouteControllerInfo { + String routeId; + String status; + String uri; + String supervising; + long attempts; + long lastAttempt; + long nextAttempt; + long elapsed; + String error; +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteControllerTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteControllerTab.java new file mode 100644 index 000000000000..492cfdb34ccf --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteControllerTab.java @@ -0,0 +1,293 @@ +/* + * 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 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.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.MouseEvent; +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.TimeUtils; +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 RouteControllerTab extends AbstractTableTab { + + RouteControllerTab(MonitorContext ctx) { + super(ctx, "route", "status", "supervising", "attempts", "uri"); + } + + @Override + public void navigateUp() { + } + + @Override + public void navigateDown() { + } + + @Override + public boolean handleKeyEvent(KeyEvent ke) { + if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP) + || ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN) + || ke.isHome() || ke.isEnd()) { + return false; + } + return super.handleKeyEvent(ke); + } + + @Override + public boolean handleMouseEvent(MouseEvent me, Rect area) { + return false; + } + + @Override + protected int getRowCount() { + IntegrationInfo info = ctx.findSelectedIntegration(); + return info != null ? info.routeControllerRoutes.size() : 0; + } + + @Override + protected void renderContent(Frame frame, Rect area, IntegrationInfo info) { + // Handle non-supervised controller + if ("DefaultRouteController".equals(info.routeControllerType)) { + renderEmptyState(frame, area, "Route controller: Default (not supervised)", " Route Controller "); + return; + } + + List<RouteControllerInfo> sorted = new ArrayList<>(info.routeControllerRoutes); + sorted.sort(this::sortRouteController); + + // Handle supervised controller with all routes started successfully + if ("SupervisingRouteController".equals(info.routeControllerType) + && !info.routeControllerUnhealthy + && info.routeControllerRestartingRoutes == 0 + && info.routeControllerExhaustedRoutes == 0 + && sorted.stream().allMatch(r -> "Started".equals(r.status))) { + renderEmptyState(frame, area, "All routes started successfully", " Route Controller (Supervised) "); + return; + } + + List<Row> rows = new ArrayList<>(); + for (RouteControllerInfo rc : sorted) { + Style statusStyle = "Started".equals(rc.status) ? Theme.success() : Theme.error(); + String supervising = rc.supervising != null ? rc.supervising : ""; + String attempts = rc.attempts > 0 ? String.valueOf(rc.attempts) : ""; + String lastAttempt = rc.lastAttempt > 0 ? TimeUtils.printSince(rc.lastAttempt) : ""; + String nextAttempt = rc.nextAttempt > 0 ? TimeUtils.printSince(rc.nextAttempt) : ""; + String uriOrError = rc.error != null ? rc.error : (rc.uri != null ? rc.uri : ""); + Style uriStyle = rc.error != null ? Theme.error() : Style.EMPTY; + + rows.add(Row.from( + Cell.from(Span.styled(" " + (rc.routeId != null ? rc.routeId : ""), Style.EMPTY.fg(Theme.accent()))), + Cell.from(Span.styled(rc.status != null ? rc.status : "", statusStyle)), + Cell.from(supervising), + rightCell(attempts, 10), + rightCell(lastAttempt, 12), + rightCell(nextAttempt, 12), + Cell.from(Span.styled(uriOrError, uriStyle)))); + } + + if (rows.isEmpty()) { + rows.add(emptyRow("No routes under supervision", 7)); + } + + String title = "SupervisingRouteController".equals(info.routeControllerType) + ? " Route Controller (Supervised) " + : " Route Controller "; + + Table table = Table.builder() + .rows(rows) + .header(Row.from( + Cell.from(Span.styled(" " + sortLabel("ROUTE", "route"), sortStyle("route"))), + Cell.from(Span.styled(sortLabel("STATUS", "status"), sortStyle("status"))), + Cell.from(Span.styled(sortLabel("SUPERVISING", "supervising"), sortStyle("supervising"))), + rightCell(sortLabel("ATTEMPTS", "attempts"), 10, sortStyle("attempts")), + rightCell("LAST", 12, Style.EMPTY.bold()), + rightCell("NEXT", 12, Style.EMPTY.bold()), + Cell.from(Span.styled(sortLabel("URI", "uri"), sortStyle("uri"))))) + .widths( + Constraint.length(20), + Constraint.length(10), + Constraint.length(14), + Constraint.length(10), + Constraint.length(12), + Constraint.length(12), + Constraint.fill()) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(title).build()) + .build(); + + lastTableArea = area; + frame.renderStatefulWidget(table, area, tableState); + renderScrollbar(frame, sorted.size()); + } + + private void renderEmptyState(Frame frame, Rect area, String message, String title) { + List<Row> rows = new ArrayList<>(); + rows.add(Row.from(Cell.from(Span.styled(" " + message, Theme.muted())))); + + Table table = Table.builder() + .rows(rows) + .header(Row.from(Cell.from(Span.styled(" INFO", Style.EMPTY.bold())))) + .widths(Constraint.fill()) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(title).build()) + .build(); + + lastTableArea = area; + frame.renderStatefulWidget(table, area, tableState); + } + + private int sortRouteController(RouteControllerInfo a, RouteControllerInfo b) { + int result = switch (sort) { + case "status" -> { + String sa = a.status != null ? a.status : ""; + String sb = b.status != null ? b.status : ""; + yield sa.compareToIgnoreCase(sb); + } + case "supervising" -> { + String sa = a.supervising != null ? a.supervising : ""; + String sb = b.supervising != null ? b.supervising : ""; + yield sa.compareToIgnoreCase(sb); + } + case "attempts" -> Long.compare(b.attempts, a.attempts); + case "uri" -> { + String ua = a.uri != null ? a.uri : ""; + String ub = b.uri != null ? b.uri : ""; + yield ua.compareToIgnoreCase(ub); + } + default -> { // "route" + String ra = a.routeId != null ? a.routeId : ""; + String rb = b.routeId != null ? b.routeId : ""; + yield ra.compareToIgnoreCase(rb); + } + }; + return sortReversed ? -result : result; + } + + @Override + public SelectionContext getSelectionContext() { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null || info.routeControllerRoutes.isEmpty()) { + return null; + } + List<RouteControllerInfo> sorted = new ArrayList<>(info.routeControllerRoutes); + sorted.sort(this::sortRouteController); + List<String> items = sorted.stream().map(r -> r.routeId != null ? r.routeId : "").toList(); + Integer sel = tableState.selected(); + return new SelectionContext("table", items, sel != null ? sel : -1, items.size(), "Route Controller"); + } + + @Override + public String description() { + return "Route controller status (supervised route startup and restart attempts)"; + } + + @Override + public String getHelpText() { + return """ + # Route Controller + + Shows the status of the Camel route controller. The route controller manages + route startup and can automatically restart routes that fail to start. + + There are two types of route controllers: + + - **Default** — Routes are started in order and a failure stops the context. + This tab shows "Route controller: Default (not supervised)" in this case. + - **Supervising** — Routes that fail to start are retried with exponential + backoff. The controller tracks attempts and manages restart scheduling. + + When using the supervised controller and all routes start successfully, + the tab shows "All routes started successfully". + + ## Table Columns + + - **ROUTE** — The route ID + - **STATUS** — Route state: `Started`, `Stopped`, or other lifecycle states + - **SUPERVISING** — Supervision status (e.g., `Active` when the route is being + restarted by the controller) + - **ATTEMPTS** — Number of restart attempts so far + - **LAST** — Time since the last restart attempt + - **NEXT** — Time until the next scheduled restart attempt + - **URI** — The route's consumer endpoint URI. If the route has a startup error, + the error message is shown here instead (in red) + + ## Keys + + - `Up/Down` — select route + - `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", "Route Controller"); + result.put("controllerType", info.routeControllerType); + result.put("unhealthy", info.routeControllerUnhealthy); + result.put("restartingRoutes", info.routeControllerRestartingRoutes); + result.put("exhaustedRoutes", info.routeControllerExhaustedRoutes); + JsonArray rows = new JsonArray(); + List<RouteControllerInfo> sorted = new ArrayList<>(info.routeControllerRoutes); + sorted.sort(this::sortRouteController); + for (RouteControllerInfo rc : sorted) { + JsonObject row = new JsonObject(); + row.put("routeId", rc.routeId); + row.put("status", rc.status); + row.put("uri", rc.uri); + if (rc.supervising != null) { + row.put("supervising", rc.supervising); + } + row.put("attempts", rc.attempts); + if (rc.lastAttempt > 0) { + row.put("lastAttempt", rc.lastAttempt); + } + if (rc.nextAttempt > 0) { + row.put("nextAttempt", rc.nextAttempt); + } + if (rc.error != null) { + row.put("error", rc.error); + } + rows.add(row); + } + result.put("rows", rows); + result.put("totalRows", info.routeControllerRoutes.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 f0a3be50cd7a..db04860676c5 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 @@ -326,6 +326,60 @@ final class StatusParser { } } + // Parse producers + JsonObject producersObj = (JsonObject) root.get("producers"); + if (producersObj != null) { + JsonArray producerList = (JsonArray) producersObj.get("producers"); + if (producerList != null) { + for (Object p : producerList) { + JsonObject pj = (JsonObject) p; + ProducerInfo pi = new ProducerInfo(); + pi.uri = pj.getString("uri"); + pi.state = pj.getString("state"); + pi.className = pj.getString("class"); + pi.routeId = pj.getString("routeId"); + pi.stepId = pj.getString("stepId"); + pi.remote = Boolean.TRUE.equals(pj.get("remote")); + pi.singleton = Boolean.TRUE.equals(pj.get("singleton")); + info.producers.add(pi); + } + } + } + + // Parse events + JsonObject eventsObj = (JsonObject) root.get("events"); + if (eventsObj != null) { + parseEventArray(eventsObj, "events", "general", info); + parseEventArray(eventsObj, "routeEvents", "route", info); + parseEventArray(eventsObj, "exchangeEvents", "exchange", info); + } + + // Parse route controller + JsonObject rcObj = (JsonObject) root.get("routeController"); + if (rcObj != null) { + info.routeControllerType = rcObj.getString("controller"); + info.routeControllerUnhealthy = Boolean.TRUE.equals(rcObj.get("unhealthyRoutes")); + info.routeControllerRestartingRoutes = rcObj.getIntegerOrDefault("restartingRoutes", 0); + info.routeControllerExhaustedRoutes = rcObj.getIntegerOrDefault("exhaustedRoutes", 0); + JsonArray rcRoutes = (JsonArray) rcObj.get("routes"); + if (rcRoutes != null) { + for (Object r : rcRoutes) { + JsonObject rj = (JsonObject) r; + RouteControllerInfo rc = new RouteControllerInfo(); + rc.routeId = rj.getString("routeId"); + rc.status = rj.getString("status"); + rc.uri = rj.getString("uri"); + rc.supervising = rj.getString("supervising"); + rc.attempts = rj.getLongOrDefault("attempts", 0); + rc.lastAttempt = rj.getLongOrDefault("lastAttempt", 0); + rc.nextAttempt = rj.getLongOrDefault("nextAttempt", 0); + rc.elapsed = rj.getLongOrDefault("elapsed", 0); + rc.error = rj.getString("error"); + info.routeControllerRoutes.add(rc); + } + } + } + // Parse endpoints (top-level "endpoints" is a JsonObject with nested "endpoints" array) JsonObject endpointsObj = (JsonObject) root.get("endpoints"); if (endpointsObj != null) { @@ -1220,4 +1274,20 @@ final class StatusParser { static long objToLong(Object o) { return TuiHelper.objToLong(o); } + + private static void parseEventArray(JsonObject eventsObj, String arrayKey, String category, IntegrationInfo info) { + JsonArray arr = (JsonArray) eventsObj.get(arrayKey); + if (arr != null) { + for (Object e : arr) { + JsonObject ej = (JsonObject) e; + EventInfo ei = new EventInfo(); + ei.type = ej.getString("type"); + ei.category = category; + ei.timestamp = ej.getLongOrDefault("timestamp", 0); + ei.exchangeId = ej.getString("exchangeId"); + ei.message = ej.getString("message"); + info.events.add(ei); + } + } + } } 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 ab90e81eece4..4ddbc11d3989 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 @@ -70,6 +70,9 @@ class TabRegistry { private DiagramTab diagramTab; private RoutesTab routesTab; private ConsumersTab consumersTab; + private ProducersTab producersTab; + private EventTab eventTab; + private RouteControllerTab routeControllerTab; private EndpointsTab endpointsTab; private HttpTab httpTab; private HealthTab healthTab; @@ -115,6 +118,9 @@ class TabRegistry { diagramTab = new DiagramTab(ctx); routesTab = new RoutesTab(ctx); consumersTab = new ConsumersTab(ctx); + producersTab = new ProducersTab(ctx); + eventTab = new EventTab(ctx); + routeControllerTab = new RouteControllerTab(ctx); kafkaTab = new KafkaTab(ctx); dataSourceTab = new DataSourceTab(ctx); heapHistogramTab = new HeapHistogramTab(ctx); @@ -160,9 +166,14 @@ class TabRegistry { new MoreTab(TuiIcons.TAB_CIRCUIT_BREAKER, "Circuit Breaker", "&Circuit Breaker", circuitBreakerTab, "Routing"), new MoreTab(TuiIcons.TAB_CONSUMERS, "Consumers", "Co&nsumers", consumersTab, "Routing"), new MoreTab(TuiIcons.TAB_INFLIGHT, "Inflight", "In&flight", inflightTab, "Routing"), + new MoreTab(TuiIcons.TAB_PRODUCERS, "Producers", "Prod&ucers", producersTab, "Routing"), + new MoreTab( + TuiIcons.TAB_ROUTE_CONTROLLER, "Route Controller", "Route Contr&oller", routeControllerTab, + "Routing"), // Observability new MoreTab(TuiIcons.TAB_HEALTH, "Health", "H&ealth", healthTab, "Observability"), new MoreTab(TuiIcons.TAB_METRICS, "Metrics", "Metr&ics", metricsTab, "Observability"), + new MoreTab(TuiIcons.TAB_EVENTS, "Events", "&Events", eventTab, "Observability"), new MoreTab(TuiIcons.TAB_SPANS, "Spans", "&OTel Spans", spansTab, "Observability"), // Data new MoreTab(TuiIcons.TAB_DATASOURCE, "JDBC DataSource", "&JDBC DataSource", dataSourceTab, "Data"), 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 395d0a118149..ad41009894f1 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 @@ -146,6 +146,9 @@ final class TuiIcons { static final String TAB_PROCESS = CLIPBOARD; static final String TAB_STARTUP = QUARKUS; static final String TAB_THREADS = "🧵"; + static final String TAB_PRODUCERS = "📤"; + static final String TAB_EVENTS = "📣"; + static final String TAB_ROUTE_CONTROLLER = "🚦"; /** Icons for {@link TabRegistry#TAB_OVERVIEW}..{@link TabRegistry#TAB_MORE} (in order). */ static final List<String> PRIMARY_TAB_ICONS = List.of(
