You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/06/04 11:36:25 UTC
(camel) branch main updated: CAMEL-23672: camel-tui - Interactive topology diagram with route drill-down (#23762)
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 5fadb6fe4dfb CAMEL-23672: camel-tui - Interactive topology diagram with route drill-down (#23762)
5fadb6fe4dfb is described below
commit 5fadb6fe4dfb2081098146bb377ab1c5de834d3d
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Thu Jun 4 13:36:11 2026 +0200
CAMEL-23672: camel-tui - Interactive topology diagram with route drill-down (#23762)
* CAMEL-23672: camel-tui - Interactive topology navigation with info panel and route drill-down
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
* CAMEL-23672: camel-tui - Native topology diagram renderer bypassing ASCII art
Adds TopologyDiagramWidget that renders directly to TamboUI Buffer using
Unicode box-drawing characters, eliminating the char[][] grid intermediary.
Batch loads topology + all route structures in a single background operation,
caching route layouts for instant drill-down without additional IPC calls.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
* CAMEL-23672: camel-tui - Single IPC call for topology + route structure
Add routes=true option to route-topology dev console that includes
route structure data in the same response, eliminating a second IPC
round-trip when loading the TUI diagram tab.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Fix race condition in selectedPid null check
Capture selectedPid in a local variable before the null check to
prevent NPE when another thread nulls the field between the guard
and the lambda.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Native route EIP diagram widget with node navigation
Renders route EIP diagrams directly to TamboUI Buffer, bypassing the ASCII
renderer. EIP nodes are drawn with type-specific colors, scope boxes use
dashed borders, and edges show metrics counters. Arrow keys navigate between
EIP nodes with an info panel showing processor-level metrics (total, failed,
inflight, processing times). Drill-down and escape use cached layout data
so no IPC is needed when switching between topology and route views.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Remove image diagram rendering, text-only mode
Remove all image-based diagram rendering from the TUI. The TUI is text-based
so image rendering via terminal protocols (iTerm2/Kitty/Sixel) is unnecessary
complexity. This removes ~230 lines of image handling code including crop/scroll
calculations, terminal capability detection, and image protocol negotiation.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Route-to-route navigation in diagram
Enter on a linkable EIP node jumps to the connected route's diagram.
Navigation history is stacked so Escape goes back through visited
routes before returning to topology. Linkable nodes show a yellow
arrow indicator and the info panel displays the target route name.
Also fixes scroll position not resetting when switching views.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Fix link indicator by extracting URI from code field
The code field uses format type[uri] (e.g. from[direct:foo]) but URI
matching compared the wrapped strings directly. Added extractBaseUri()
to strip the type wrapper and query parameters before matching.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Add uri field to ModelDumpLine and route structure JSON
Enriched the dev console route-structure response with a dedicated uri
field for endpoint-producing EIP nodes (from, to, toD, wireTap, etc).
This replaces fragile parsing of the code[uri] format for route linking.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Fix diagram not shown on Enter and uri fallback
Select first EIP node immediately on drill-down (index 0 instead of -1)
so the diagram renders on the first frame. Also add code-field fallback
when uri is null for compatibility with older Camel runtimes.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Fix route diagram not visible on drill-down
Layout each route independently starting at PADDING instead of stacking
with cumulative Y offsets. Since drill-down shows one route at a time,
non-first routes no longer render below the visible area.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Select from node on drill-down instead of route
When drilling down to a route or jumping between routes, select the
from node as the initial selection instead of the route structural
node, which is more natural for navigation.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Skip route structural node in route diagram
Remove the route label and route node box from the diagram since the
route name is already shown in the block title. The from node is now
the first visible element.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Right-align numbers in info panels
Align Total/Failed/Inflight and Mean/Max/Min/Last values so digits
line up in both topology and EIP info panels.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Extract reusable SourceViewer, add source view to diagram, add breadcrumb navigation
- Extract SourceViewer into a reusable class from RoutesTab for use across tabs
- Add 'c' key in route diagram drill-down to show source code at selected EIP node
- Parse source line numbers from route structure into NodeInfo.line
- Add breadcrumb navigation trail in route diagram title (route1 → route2 → route3)
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <da...@apache.org>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Add burst mode for faster process scanning after actions
After launching, stopping, restarting, or toggling routes, temporarily
reduce the full process scan interval from 2s to 1s for 20 seconds,
making the overview tab more responsive when changes are expected.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <da...@apache.org>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Collapse breadcrumb when navigating back to a previous route
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <da...@apache.org>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Do not clear topology diagram on Escape
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <da...@apache.org>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Diagram navigation improvements
- Add Home/End keys to select first/last node in topology and route diagrams
- Add 't' shortcut to jump directly back to topology from any route depth
- Show linked route ID in diagram (↵ routeId) next to navigable nodes
- Yellow styled route names in breadcrumb title matching the link indicator
- Normalize diagram Y positions with 2 rows padding below title
- Fix node selection using visual position instead of list order
- Fix loading placeholder showing when diagram data is cached
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <da...@apache.org>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Enrich route structure with remote flag and UI improvements
Add remote boolean to ModelDumpLine and route structure dev console JSON,
so consumers (TUI, CLI, 3rd party) can identify remote endpoints without
cross-referencing topology data. Route diagram now uses this to draw
dashed borders for remote endpoints. Also fix topology arrow positioning
when toggling description mode, and remove GROUP column from routes tab.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Diagram info panel improvements
- Show external indicator (external → / → external) instead of link
indicator for remote endpoints
- Dashed arrows to remote endpoint nodes
- Stable info panel height to prevent flicker when scrolling
- Remove conditional Enter hint from footer (keep in F1 help)
- Add since-last success/fail timestamps to both info panels
- Move coverage next to uptime/throughput in topology info
- Show Topology[camelId] with yellow styled name in title
- Rename EIP Info to Info
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Route diagram external styling and description mode
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Enrich route nodes with external flag from topology data
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Add description toggle to routes tab
Co-Authored-By: Claude <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Unify routes tab diagram with topology drill-down
Replace legacy ASCII diagram in the Routes tab with the same native
topology and route drill-down experience from the Diagram tab. Users
now get consistent navigation (arrow keys, Enter to drill down, Esc
to go back, t for topology) across both tabs. Drop the a (all/single)
diagram toggle.
Also swap F2 example browser keys so r runs directly and Enter opens
the tweak dialog, and move the run options dialog higher up.
Signed-off-by: Claus Ibsen <da...@apache.org>
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Show Stopping status immediately when process is stopped
Track PIDs that received SIGTERM so the overview tab can show Stopping
in yellow immediately, rather than staying on Running until the process
disappears. Also map Terminating/Terminated phases to Stopping/Stopped
for friendlier labels. Works for both single stop (x) and stop all.
Signed-off-by: Claus Ibsen <da...@apache.org>
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Reset routes tab diagram on tab switch and fix async snap-back
Pressing 4 for routes tab now always shows the table, closing any open
diagram. Pressing d resets diagram state fully before loading. Async
diagram load callbacks no longer re-enable showDiagram if the user
closed it while loading, preventing the snap-back to diagram mode.
Signed-off-by: Claus Ibsen <da...@apache.org>
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Cache diagram data and split Enter/d in routes tab
Cache diagram data per PID so reopening the diagram is instant without
IPC reload. Cache is invalidated on integration change. In the routes
table, Enter opens topology view and d opens route diagram drilled
into the selected route.
Signed-off-by: Claus Ibsen <da...@apache.org>
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Use same ROUTE column width in normal and top mode
Align the ROUTE column width to 24 in both modes so toggling top mode
does not cause the table layout to shift.
Signed-off-by: Claus Ibsen <da...@apache.org>
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Fix route diagram flicker on open
Pre-select the from node before the first render when opening a route
diagram via d, so the highlight appears immediately without a flicker.
Signed-off-by: Claus Ibsen <da...@apache.org>
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Preload diagram data in background on integration select
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Auto-select integration on startup and preload diagram for all tabs
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
* CAMEL-23672: camel-tui - Add topology minimap overlay in route drill-down
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
---------
Signed-off-by: Claus Ibsen <cl...@gmail.com>
Signed-off-by: Claus Ibsen <da...@apache.org>
Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
---
.../apache/camel/diagram/RouteDiagramHelper.java | 8 +
.../camel/diagram/RouteDiagramLayoutEngine.java | 13 +-
.../camel/diagram/TopologyAsciiRenderer.java | 11 +
.../java/org/apache/camel/spi/ModelDumpLine.java | 4 +-
.../impl/console/RouteStructureDevConsole.java | 6 +
.../impl/console/RouteTopologyDevConsole.java | 20 +
.../camel/impl/DefaultModelToStructureDumper.java | 64 +-
.../camel/cli/connector/LocalCliConnector.java | 4 +-
.../route-topology/route-topology.camel.yaml | 2 +
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 18 +-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 57 +-
.../jbang/core/commands/tui/DiagramSupport.java | 1510 ++++++++++++++++----
.../dsl/jbang/core/commands/tui/DiagramTab.java | 593 +++++++-
.../dsl/jbang/core/commands/tui/ErrorsTab.java | 11 +-
.../dsl/jbang/core/commands/tui/HistoryTab.java | 5 +-
.../jbang/core/commands/tui/MonitorContext.java | 10 +-
.../dsl/jbang/core/commands/tui/OverviewTab.java | 11 +-
.../dsl/jbang/core/commands/tui/RouteInfo.java | 1 +
.../dsl/jbang/core/commands/tui/RoutesTab.java | 1221 +++++++++-------
.../jbang/core/commands/tui/RunOptionsForm.java | 4 +-
.../dsl/jbang/core/commands/tui/SourceViewer.java | 341 +++++
.../dsl/jbang/core/commands/tui/StatusParser.java | 1 +
.../dsl/jbang/core/commands/tui/StopAllPopup.java | 18 +-
.../core/commands/tui/diagram/DiagramColors.java | 74 +
.../commands/tui/diagram/RouteDiagramWidget.java | 509 +++++++
.../tui/diagram/TopologyDiagramWidget.java | 399 ++++++
.../tui/diagram/TopologyMinimapWidget.java | 103 ++
27 files changed, 4155 insertions(+), 863 deletions(-)
diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
index 5ad89d4b11e4..1d298aa1f792 100644
--- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
+++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
@@ -70,9 +70,17 @@ public final class RouteDiagramHelper {
node.type = line.getString("type");
node.id = line.getString("id");
node.code = Jsoner.unescape(line.getString("code"));
+ String uri = line.getString("uri");
+ if (uri != null) {
+ node.uri = Jsoner.unescape(uri);
+ }
node.description = line.getString("description");
+ Boolean rem = line.getBoolean("remote");
+ node.remote = rem != null && rem;
Integer level = line.getInteger("level");
node.level = level != null ? level : 0;
+ Integer lineNum = line.getInteger("line");
+ node.line = lineNum != null ? lineNum : 0;
if (line.containsKey("statistics")) {
JsonObject ls = line.getJsonObject("statistics");
diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
index 7681aba8e338..de3656d6b085 100644
--- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
+++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
@@ -57,23 +57,23 @@ public class RouteDiagramLayoutEngine {
static final Set<String> BRANCHING_EIPS = Set.of(
"choice", "multicast", "doTry", "loadBalance", "recipientList", "circuitBreaker");
- static final Set<String> BRANCH_CHILD_TYPES = Set.of(
+ public static final Set<String> BRANCH_CHILD_TYPES = Set.of(
"when", "otherwise", "doCatch", "doFinally", "onFallback");
static final Set<String> STRUCTURAL_TYPES = Set.of(
"route", "from");
- static class Bounds {
- int minX, minY, maxX, maxY;
+ public static class Bounds {
+ public int minX, minY, maxX, maxY;
- Bounds(int minX, int minY, int maxX, int maxY) {
+ public Bounds(int minX, int minY, int maxX, int maxY) {
this.minX = minX;
this.minY = minY;
this.maxX = maxX;
this.maxY = maxY;
}
- void expand(Bounds other) {
+ public void expand(Bounds other) {
minX = Math.min(minX, other.minX);
minY = Math.min(minY, other.minY);
maxX = Math.max(maxX, other.maxX);
@@ -154,8 +154,11 @@ public class RouteDiagramLayoutEngine {
public String type;
public String id;
public String code;
+ public String uri;
public String description;
public int level;
+ public int line;
+ public boolean remote;
public StatInfo stat;
}
diff --git a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
index 84bbbd065f2f..9c1b6b9b3400 100644
--- a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
+++ b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
@@ -54,6 +54,7 @@ public class TopologyAsciiRenderer {
private final boolean metrics;
private final boolean showDescription;
private final List<CounterPos> counterPositions = new ArrayList<>();
+ private final List<NodeBox> nodeBoxes = new ArrayList<>();
public enum CounterType {
OK,
@@ -65,6 +66,9 @@ public class TopologyAsciiRenderer {
public record CounterPos(int row, int col, int length, CounterType type) {
}
+ public record NodeBox(String routeId, int startRow, int endRow, int startCol, int endCol, int layer) {
+ }
+
public TopologyAsciiRenderer(int nodeWidth, boolean unicode) {
this(nodeWidth, unicode, false, false);
}
@@ -85,6 +89,10 @@ public class TopologyAsciiRenderer {
return counterPositions;
}
+ public List<NodeBox> getNodeBoxes() {
+ return nodeBoxes;
+ }
+
public String renderDiagram(TopologyLayoutResult result) {
String plain = renderDiagramPlain(result);
return applyAnsiColors(plain);
@@ -92,6 +100,7 @@ public class TopologyAsciiRenderer {
public String renderDiagramPlain(TopologyLayoutResult result) {
counterPositions.clear();
+ nodeBoxes.clear();
int gridWidth = toCol(result.totalWidth) + boxWidth + 4;
int gridHeight = toRow(result.totalHeight) + 10;
@@ -235,6 +244,8 @@ public class TopologyAsciiRenderer {
}
}
}
+
+ nodeBoxes.add(new NodeBox(node.routeId, row, row + height - 1, col, col + boxWidth - 1, node.layer));
}
private void drawEdge(char[][] grid, TopologyLayoutEdge edge) {
diff --git a/core/camel-api/src/main/java/org/apache/camel/spi/ModelDumpLine.java b/core/camel-api/src/main/java/org/apache/camel/spi/ModelDumpLine.java
index 7f1e40646502..3b4070bde6cc 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/ModelDumpLine.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/ModelDumpLine.java
@@ -27,8 +27,10 @@ import org.jspecify.annotations.Nullable;
* @param level indent level of the EIP node
* @param code EIP code such as label or short name that is human-readable or pseudocode
* @param description optional description of the EIP node
+ * @param uri the raw endpoint URI for endpoint-producing EIP nodes (from, to, toD, wireTap, enrich, etc.)
+ * @param remote whether the endpoint connects to a remote system (true) or is local/in-JVM only (false)
* @since 4.16
*/
public record ModelDumpLine(@Nullable String location, String type, String id, int level, String code,
- @Nullable String description) {
+ @Nullable String description, @Nullable String uri, boolean remote) {
}
diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteStructureDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteStructureDevConsole.java
index a55dd747b06f..18166b40f9d7 100644
--- a/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteStructureDevConsole.java
+++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteStructureDevConsole.java
@@ -202,6 +202,12 @@ public class RouteStructureDevConsole extends AbstractDevConsole {
c.put("description", line.description());
}
c.put("code", Jsoner.escape(line.code()));
+ if (line.uri() != null) {
+ c.put("uri", Jsoner.escape(line.uri()));
+ if (line.remote()) {
+ c.put("remote", true);
+ }
+ }
if (metric && mcc != null) {
if (counter <= 2) {
diff --git a/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java b/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java
index 0caa74155434..2645d206ea9a 100644
--- a/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java
+++ b/core/camel-console/src/main/java/org/apache/camel/impl/console/RouteTopologyDevConsole.java
@@ -23,6 +23,7 @@ import java.util.Map;
import org.apache.camel.api.management.ManagedCamelContext;
import org.apache.camel.api.management.mbean.ManagedRouteMBean;
import org.apache.camel.api.management.mbean.ManagedSendProcessorMBean;
+import org.apache.camel.console.DevConsoleRegistry;
import org.apache.camel.spi.RouteTopologyDumper;
import org.apache.camel.spi.RouteTopologyDumper.TopologyEdge;
import org.apache.camel.spi.RouteTopologyDumper.TopologyExternalEndpoint;
@@ -40,6 +41,7 @@ public class RouteTopologyDevConsole extends AbstractDevConsole {
private static final String METRIC = "metric";
private static final String EXTERNAL = "external";
+ private static final String ROUTES = "routes";
public RouteTopologyDevConsole() {
super("camel", "route-topology", "Route Topology", "Route topology showing inter-route connections");
@@ -166,6 +168,24 @@ public class RouteTopologyDevConsole extends AbstractDevConsole {
root.put("externalEndpoints", extArr);
}
+ // Optionally include route structure data in the same response
+ if ("true".equals(options.get(ROUTES))) {
+ DevConsoleRegistry registry = getCamelContext().getCamelContextExtension()
+ .getContextPlugin(DevConsoleRegistry.class);
+ if (registry != null) {
+ var structureConsole = registry.resolveById("route-structure");
+ if (structureConsole != null) {
+ String metricStr = metric ? "true" : "false";
+ JsonObject structureResult = (JsonObject) structureConsole.call(
+ org.apache.camel.console.DevConsole.MediaType.JSON,
+ Map.of("filter", "*", "brief", "false", "metric", metricStr));
+ if (structureResult != null) {
+ root.put("routes", structureResult.get("routes"));
+ }
+ }
+ }
+ }
+
return root;
}
diff --git a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultModelToStructureDumper.java b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultModelToStructureDumper.java
index 3389e5bf6355..eca8d90ce6a3 100644
--- a/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultModelToStructureDumper.java
+++ b/core/camel-core-engine/src/main/java/org/apache/camel/impl/DefaultModelToStructureDumper.java
@@ -17,9 +17,12 @@
package org.apache.camel.impl;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import org.apache.camel.CamelContext;
+import org.apache.camel.Endpoint;
import org.apache.camel.NamedNode;
import org.apache.camel.model.EndpointRequiredDefinition;
import org.apache.camel.model.Model;
@@ -39,6 +42,8 @@ public class DefaultModelToStructureDumper implements ModelToStructureDumper {
// dump in text format padded by level
List<ModelDumpLine> answer = new ArrayList<>();
+ Map<String, Boolean> schemeRemoteMap = buildSchemeRemoteMap(context);
+
// lookup model and runtime route
final Model model = context.getCamelContextExtension().getContextPlugin(Model.class);
final RouteDefinition def = model.getRouteDefinition(routeId);
@@ -46,19 +51,25 @@ public class DefaultModelToStructureDumper implements ModelToStructureDumper {
String loc = scheme + ":" + LoggerHelper.getLineNumberLoggerName(def);
answer.add(
- new ModelDumpLine(loc, "route", def.getRouteId(), 0, "route[" + def.getRouteId() + "]", def.getDescription()));
+ new ModelDumpLine(
+ loc, "route", def.getRouteId(), 0, "route[" + def.getRouteId() + "]", def.getDescription(),
+ null, false));
String uri = def.getInput().getLabel();
if (brief) {
uri = StringHelper.before(uri, "?", uri);
}
- answer.add(new ModelDumpLine(loc, "from", routeId, 1, "from[" + uri + "]", def.getDescription()));
+ String fromUri = def.getInput().getEndpointUri();
+ boolean fromRemote = isRemoteUri(fromUri, schemeRemoteMap);
+ answer.add(new ModelDumpLine(loc, "from", routeId, 1, "from[" + uri + "]", def.getDescription(), fromUri, fromRemote));
- dumpChildren(def, scheme, brief, 2, answer);
+ dumpChildren(def, scheme, brief, 2, answer, schemeRemoteMap);
return answer;
}
- private static void dumpChildren(NamedNode parent, String scheme, boolean brief, int level, List<ModelDumpLine> answer) {
+ private static void dumpChildren(
+ NamedNode parent, String scheme, boolean brief, int level,
+ List<ModelDumpLine> answer, Map<String, Boolean> schemeRemoteMap) {
for (NamedNode child : parent.getChildren()) {
if (child instanceof OptionalIdentifiedDefinition<?> output) {
String loc = scheme + ":" + output.getLocation();
@@ -69,14 +80,49 @@ public class DefaultModelToStructureDumper implements ModelToStructureDumper {
String id = output.getId();
boolean choice = "choice".equals(kind);
String code = choice || brief ? output.getShortName() : output.getLabel();
- if (brief && output instanceof EndpointRequiredDefinition erd) {
- String uri = StringHelper.before(erd.getEndpointUri(), "?", erd.getEndpointUri());
- code = output.getShortName() + "[" + uri + "]";
+ String endpointUri = null;
+ boolean remote = false;
+ if (output instanceof EndpointRequiredDefinition erd) {
+ endpointUri = erd.getEndpointUri();
+ remote = isRemoteUri(endpointUri, schemeRemoteMap);
+ if (brief) {
+ String uri = StringHelper.before(endpointUri, "?", endpointUri);
+ code = output.getShortName() + "[" + uri + "]";
+ }
}
- answer.add(new ModelDumpLine(loc, kind, id, level, code, output.getDescription()));
+ answer.add(new ModelDumpLine(loc, kind, id, level, code, output.getDescription(), endpointUri, remote));
+ }
+ dumpChildren(child, scheme, brief, level + 1, answer, schemeRemoteMap);
+ }
+ }
+
+ private static Map<String, Boolean> buildSchemeRemoteMap(CamelContext context) {
+ Map<String, Boolean> map = new HashMap<>();
+ for (Endpoint ep : context.getEndpoints()) {
+ if ("StubEndpoint".equals(ep.getClass().getSimpleName())) {
+ continue;
}
- dumpChildren(child, scheme, brief, level + 1, answer);
+ String uri = ep.getEndpointUri();
+ int colonIdx = uri.indexOf(':');
+ if (colonIdx > 0) {
+ String s = uri.substring(0, colonIdx);
+ map.putIfAbsent(s, ep.isRemote());
+ }
+ }
+ return map;
+ }
+
+ private static boolean isRemoteUri(String uri, Map<String, Boolean> schemeRemoteMap) {
+ if (uri == null) {
+ return false;
+ }
+ int colonIdx = uri.indexOf(':');
+ if (colonIdx <= 0) {
+ return false;
}
+ String scheme = uri.substring(0, colonIdx);
+ Boolean remote = schemeRemoteMap.get(scheme);
+ return remote != null && remote;
}
}
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 91296898a42a..dfe522aa3af9 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
@@ -756,8 +756,10 @@ public class LocalCliConnector extends ServiceSupport implements CliConnector, C
if (dc != null) {
String metric = root.getStringOrDefault("metric", "false");
String external = root.getStringOrDefault("external", "false");
+ String routes = root.getStringOrDefault("routes", "false");
JsonObject json
- = (JsonObject) dc.call(DevConsole.MediaType.JSON, Map.of("metric", metric, "external", external));
+ = (JsonObject) dc.call(DevConsole.MediaType.JSON,
+ Map.of("metric", metric, "external", external, "routes", routes));
LOG.trace("Updating output file: {}", outputFile);
IOHelper.writeText(json.toJson(), outputFile);
} else {
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/route-topology/route-topology.camel.yaml b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/route-topology/route-topology.camel.yaml
index a89d27b967de..b1338b96b462 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/route-topology/route-topology.camel.yaml
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/resources/examples/route-topology/route-topology.camel.yaml
@@ -83,6 +83,7 @@
message: "Fulfilling order: ${body}"
- to:
uri: kafka:warehouse-shipments
+ description: Ship to Warehouse
# Kafka consumer: sends notifications via external email service
- route:
@@ -95,3 +96,4 @@
message: "Sending notification for: ${body}"
- to:
uri: kafka:email-outbox
+ description: Send Email
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
index c37c0738799c..6dcfcc91f5c0 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
@@ -97,6 +97,7 @@ class ActionsPopup {
private final Runnable toggleKeystrokes;
private final Supplier<Boolean> keystrokesEnabled;
private final Runnable toggleTapeRecording;
+ private final Runnable burstCallback;
private Runnable resetStatsAction;
private Runnable resetScreenAction;
private final Supplier<Boolean> tapeRecordingActive;
@@ -159,7 +160,8 @@ class ActionsPopup {
ActionsPopup(Supplier<Set<String>> runningNames, Supplier<List<IntegrationInfo>> integrations,
Supplier<List<InfraInfo>> infraServices, CaptionOverlay captionOverlay,
Runnable screenshotAction, Runnable toggleKeystrokes, Supplier<Boolean> keystrokesEnabled,
- Runnable toggleTapeRecording, Supplier<Boolean> tapeRecordingActive) {
+ Runnable toggleTapeRecording, Supplier<Boolean> tapeRecordingActive,
+ Runnable burstCallback, Set<String> stoppingPids) {
this.runningNames = runningNames;
this.integrations = integrations;
this.infraServices = infraServices;
@@ -169,7 +171,8 @@ class ActionsPopup {
this.keystrokesEnabled = keystrokesEnabled;
this.toggleTapeRecording = toggleTapeRecording;
this.tapeRecordingActive = tapeRecordingActive;
- this.stopAllPopup = new StopAllPopup(integrations, infraServices);
+ this.burstCallback = burstCallback;
+ this.stopAllPopup = new StopAllPopup(integrations, infraServices, burstCallback, stoppingPids);
}
void setContext(MonitorContext ctx) {
@@ -499,11 +502,11 @@ class ActionsPopup {
} else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
navigateExampleBrowser(10);
} else if (ke.isChar('r')) {
- openNameInput();
+ launchSelectedExample();
} else if (ke.isChar('d')) {
loadDocFromExample();
} else if (ke.isConfirm()) {
- launchSelectedExample();
+ openNameInput();
}
return true;
}
@@ -684,8 +687,8 @@ class ActionsPopup {
}
if (showExampleBrowser) {
hint(spans, "↑↓", "navigate");
- hint(spans, "Enter", "run");
- hint(spans, "r", "run...");
+ hint(spans, "r", "run");
+ hint(spans, "Enter", "run...");
hint(spans, "d", "docs");
hintLast(spans, "Esc", "back");
return;
@@ -1300,6 +1303,7 @@ class ActionsPopup {
Process process = pb.start();
pendingLaunches.add(new PendingLaunch(displayName, process, outputFile, System.currentTimeMillis()));
pendingAutoSelect = displayName;
+ burstCallback.run();
setNotification("Starting: " + displayName, false);
} catch (Exception e) {
setNotification("Failed to start: " + folder + " - " + e.getMessage(), true);
@@ -1801,6 +1805,7 @@ class ActionsPopup {
pb.redirectOutput(outputFile.toFile());
Process process = pb.start();
pendingLaunches.add(new PendingLaunch(alias, process, outputFile, System.currentTimeMillis()));
+ burstCallback.run();
setNotification("Starting infra: " + alias, false);
// force reload next time browser opens
infraCatalog = null;
@@ -1851,6 +1856,7 @@ class ActionsPopup {
Process process = pb.start();
pendingLaunches.add(new PendingLaunch(displayName, process, outputFile, System.currentTimeMillis()));
pendingAutoSelect = displayName;
+ burstCallback.run();
setNotification("Starting: " + displayName, false);
} catch (Exception e) {
setNotification("Failed to start: " + exampleName + " - " + e.getMessage(), true);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index f3f03a59f330..cc99756562b6 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -198,9 +198,11 @@ public class CamelMonitor extends CamelCommand {
private final Map<String, LoadAvg> cpuLoadAvg = new ConcurrentHashMap<>();
private final Map<String, long[]> prevCpuSample = new ConcurrentHashMap<>();
- // Cached PID list — full process scan throttled to every 2 seconds
+ // Cached PID list — full process scan throttled to every 2 seconds (1 second in burst mode)
private volatile List<Long> cachedPids = Collections.emptyList();
private volatile long lastFullScanTime;
+ private volatile long burstModeUntil;
+ final Set<String> stoppingPids = ConcurrentHashMap.newKeySet();
// Trace/history data — shared between CamelMonitor and tabs
private final AtomicReference<List<TraceEntry>> traces = new AtomicReference<>(Collections.emptyList());
@@ -245,7 +247,8 @@ public class CamelMonitor extends CamelCommand {
() -> recording = !recording,
() -> recording,
this::toggleTapeRecording,
- () -> tapeRecorder != null && tapeRecorder.isActive());
+ () -> tapeRecorder != null && tapeRecorder.isActive(),
+ this::enableBurstMode, stoppingPids);
private final AtomicBoolean refreshInProgress = new AtomicBoolean(false);
private TuiRunner runner;
@@ -342,12 +345,15 @@ public class CamelMonitor extends CamelCommand {
memoryTab = new MemoryTab(ctx, heapMemHistory);
threadsTab = new ThreadsTab(ctx);
overviewTab = new OverviewTab(
- ctx, throughputHistory, failedHistory, cpuLoadAvg,
+ ctx, throughputHistory, failedHistory, cpuLoadAvg, stoppingPids,
this::resetIntegrationTabState);
// Initial data load (synchronous before TUI starts)
refreshDataSync();
+ // Auto-select if there's exactly one integration running
+ overviewTab.selectCurrentIntegration();
+
eventLog = new TuiEventLog(500);
Path mcpJsonFile = null;
if (mcp) {
@@ -369,6 +375,9 @@ public class CamelMonitor extends CamelCommand {
ctx.runner = tui;
actionsPopup.setScheduler(tui.scheduler());
actionsPopup.setResetScreenAction(() -> tui.terminal().clear());
+ // Preload diagram data if an integration was auto-selected
+ routesTab.preloadDiagram();
+ diagramTab.preloadDiagram();
// Intercept Ctrl+C: quit the TUI cleanly instead of letting
// the JVM tear down the classloader while we're still running
Signal.handle(new Signal("INT"), sig -> tui.quit());
@@ -864,11 +873,16 @@ public class CamelMonitor extends CamelCommand {
private boolean handleTabKey(int tab) {
if (tab != TAB_OVERVIEW) {
overviewTab.selectCurrentIntegration();
+ routesTab.preloadDiagram();
+ diagramTab.preloadDiagram();
}
if (tab == TAB_LOG) {
refreshLogData();
logTab.onTabSelected();
}
+ if (tab == TAB_ROUTES && routesTab != null && routesTab.isShowDiagram()) {
+ routesTab.closeDiagram();
+ }
if (tab == TAB_DIAGRAM) {
diagramTab.onTabSelected();
}
@@ -928,6 +942,10 @@ public class CamelMonitor extends CamelCommand {
consumersTab.onIntegrationChanged();
circuitBreakerTab.onIntegrationChanged();
inflightTab.onIntegrationChanged();
+
+ // Preload diagram data in background so it's ready when the user switches tabs
+ routesTab.preloadDiagram();
+ diagramTab.preloadDiagram();
}
private void navigateUp() {
@@ -1346,6 +1364,7 @@ public class CamelMonitor extends CamelCommand {
}
private void stopSelectedProcess(boolean forceKill) {
+ enableBurstMode();
if (ctx.selectedPid == null) {
return;
}
@@ -1366,19 +1385,21 @@ public class CamelMonitor extends CamelCommand {
ProcessHandle.of(pid).ifPresent(ProcessHandle::destroyForcibly);
}
} else {
+ String pidStr = ctx.selectedPid;
ProcessHandle.of(pid).ifPresent(ph -> {
if (forceKill) {
ph.destroyForcibly();
Path camelDir = CommandLineHelper.getCamelDir();
- PathUtils.deleteFile(camelDir.resolve(ctx.selectedPid + ".log"));
- PathUtils.deleteFile(camelDir.resolve(ctx.selectedPid + "-status.json"));
- PathUtils.deleteFile(camelDir.resolve(ctx.selectedPid + "-action.json"));
- PathUtils.deleteFile(camelDir.resolve(ctx.selectedPid + "-output.json"));
- PathUtils.deleteFile(camelDir.resolve(ctx.selectedPid + "-trace.json"));
- PathUtils.deleteFile(camelDir.resolve(ctx.selectedPid + "-history.json"));
- PathUtils.deleteFile(camelDir.resolve(ctx.selectedPid + "-debug.json"));
- PathUtils.deleteFile(camelDir.resolve(ctx.selectedPid + "-receive.json"));
+ PathUtils.deleteFile(camelDir.resolve(pidStr + ".log"));
+ PathUtils.deleteFile(camelDir.resolve(pidStr + "-status.json"));
+ PathUtils.deleteFile(camelDir.resolve(pidStr + "-action.json"));
+ PathUtils.deleteFile(camelDir.resolve(pidStr + "-output.json"));
+ PathUtils.deleteFile(camelDir.resolve(pidStr + "-trace.json"));
+ PathUtils.deleteFile(camelDir.resolve(pidStr + "-history.json"));
+ PathUtils.deleteFile(camelDir.resolve(pidStr + "-debug.json"));
+ PathUtils.deleteFile(camelDir.resolve(pidStr + "-receive.json"));
} else {
+ stoppingPids.add(pidStr);
ph.destroy();
}
});
@@ -1386,6 +1407,7 @@ public class CamelMonitor extends CamelCommand {
}
private void restartSelectedProcess() {
+ enableBurstMode();
if (ctx.selectedPid == null || isInfraSelected()) {
return;
}
@@ -1466,6 +1488,14 @@ public class CamelMonitor extends CamelCommand {
}
}
+ private void enableBurstMode() {
+ burstModeUntil = System.currentTimeMillis() + 20_000;
+ }
+
+ private boolean isBurstMode() {
+ return System.currentTimeMillis() < burstModeUntil;
+ }
+
private void setNotification(String message, boolean error) {
monitorNotification = message;
monitorNotificationError = error;
@@ -1544,6 +1574,7 @@ public class CamelMonitor extends CamelCommand {
}
private void sendRouteCommand(String pid, String routeId, String command) {
+ enableBurstMode();
JsonObject root = new JsonObject();
root.put("action", "route");
root.put("id", routeId);
@@ -1814,7 +1845,8 @@ public class CamelMonitor extends CamelCommand {
List<IntegrationInfo> infos = new ArrayList<>();
long now = System.currentTimeMillis();
boolean wantFullScan = tabsState.selected() == TAB_OVERVIEW || showSwitchPopup || cachedPids.isEmpty();
- boolean fullScan = wantFullScan && (now - lastFullScanTime >= 2000);
+ long scanInterval = isBurstMode() ? 1000 : 2000;
+ boolean fullScan = wantFullScan && (now - lastFullScanTime >= scanInterval);
List<Long> pids;
if (fullScan) {
pids = findPids(name);
@@ -1868,6 +1900,7 @@ public class CamelMonitor extends CamelCommand {
List<IntegrationInfo> previous = data.get();
for (IntegrationInfo prev : previous) {
if (!prev.vanishing && !livePids.contains(prev.pid) && !vanishing.containsKey(prev.pid)) {
+ stoppingPids.remove(prev.pid);
vanishing.put(prev.pid, new VanishingInfo(prev, System.currentTimeMillis()));
}
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
index 193419ff8e52..d9549e4a3e9f 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
@@ -19,17 +19,14 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
-import dev.tamboui.image.Image;
-import dev.tamboui.image.ImageData;
-import dev.tamboui.image.ImageScaling;
-import dev.tamboui.image.capability.TerminalImageCapabilities;
-import dev.tamboui.image.protocol.ImageProtocol;
import dev.tamboui.layout.Constraint;
import dev.tamboui.layout.Layout;
import dev.tamboui.layout.Rect;
@@ -41,23 +38,26 @@ import dev.tamboui.text.Line;
import dev.tamboui.text.Span;
import dev.tamboui.text.Text;
import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.Clear;
import dev.tamboui.widgets.block.Block;
import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Title;
import dev.tamboui.widgets.paragraph.Paragraph;
import dev.tamboui.widgets.scrollbar.Scrollbar;
import dev.tamboui.widgets.scrollbar.ScrollbarState;
import org.apache.camel.diagram.RouteDiagramAsciiRenderer;
import org.apache.camel.diagram.RouteDiagramHelper;
import org.apache.camel.diagram.RouteDiagramLayoutEngine;
-import org.apache.camel.diagram.RouteDiagramRenderer;
import org.apache.camel.diagram.TopologyAsciiRenderer;
import org.apache.camel.diagram.TopologyHelper;
-import org.apache.camel.diagram.TopologyImageRenderer;
import org.apache.camel.diagram.TopologyLayoutEngine;
import org.apache.camel.diagram.TopologyLayoutEngine.TopologyEdgeInfo;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutEdge;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode;
import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
import org.apache.camel.diagram.TopologyLayoutEngine.TopologyNodeInfo;
import org.apache.camel.dsl.jbang.core.common.PathUtils;
+import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*;
@@ -65,7 +65,6 @@ import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*;
class DiagramSupport {
private boolean showDiagram;
- private boolean diagramTextMode;
private boolean topologyMode;
private boolean showDescription;
private List<RouteDiagramAsciiRenderer.CounterPos> counterPositions = Collections.emptyList();
@@ -75,14 +74,21 @@ class DiagramSupport {
private int scrollX;
private final ScrollbarState vScrollState = new ScrollbarState();
private final ScrollbarState hScrollState = new ScrollbarState();
- private ImageData imageData;
- private ImageData fullImageData;
- private ImageProtocol protocol;
- private int cropX = -1;
- private int cropY = -1;
- private int cropW = -1;
- private int cropH = -1;
private final AtomicBoolean loading = new AtomicBoolean(false);
+ private List<TopologyAsciiRenderer.NodeBox> nodeBoxes = Collections.emptyList();
+ private List<TopologyLayoutNode> topologyNodes = Collections.emptyList();
+ private List<TopologyLayoutEdge> topologyEdges = Collections.emptyList();
+ private int selectedNodeIndex = -1;
+ private String pendingSelectionRouteId;
+ private int lastVisibleHeight;
+ private int lastVisibleWidth;
+
+ // Native widget rendering data
+ private TopologyLayoutResult topologyLayout;
+ private int topologyNodeWidth;
+ private java.util.Map<String, RouteDiagramLayoutEngine.LayoutRoute> routeLayouts = Collections.emptyMap();
+ private String cachedPid;
+ private volatile boolean preloading;
List<String> getLines() {
return lines;
@@ -92,8 +98,12 @@ class DiagramSupport {
return showDiagram;
}
- boolean isDiagramTextMode() {
- return diagramTextMode;
+ void setShowDiagram(boolean show) {
+ this.showDiagram = show;
+ }
+
+ boolean isLoading() {
+ return loading.get();
}
boolean isTopologyMode() {
@@ -112,12 +122,91 @@ class DiagramSupport {
this.showDescription = showDescription;
}
+ List<TopologyAsciiRenderer.NodeBox> getNodeBoxes() {
+ return nodeBoxes;
+ }
+
+ int getSelectedNodeIndex() {
+ return selectedNodeIndex;
+ }
+
+ void setSelectedNodeIndex(int idx) {
+ this.selectedNodeIndex = idx;
+ }
+
+ void setPendingSelectionRouteId(String routeId) {
+ this.pendingSelectionRouteId = routeId;
+ }
+
+ String getSelectedRouteId() {
+ if (selectedNodeIndex >= 0 && selectedNodeIndex < nodeBoxes.size()) {
+ return nodeBoxes.get(selectedNodeIndex).routeId();
+ }
+ return null;
+ }
+
+ TopologyLayoutNode getSelectedTopologyNode() {
+ String routeId = getSelectedRouteId();
+ if (routeId == null) {
+ return null;
+ }
+ for (TopologyLayoutNode node : topologyNodes) {
+ if (routeId.equals(node.routeId)) {
+ return node;
+ }
+ }
+ return null;
+ }
+
+ String getConnectedRouteId(String externalNodeId) {
+ for (TopologyLayoutEdge edge : topologyEdges) {
+ if (externalNodeId.equals(edge.from.routeId)) {
+ return edge.to.routeId;
+ }
+ if (externalNodeId.equals(edge.to.routeId)) {
+ return edge.from.routeId;
+ }
+ }
+ return null;
+ }
+
boolean hasDiagramData() {
- return diagramTextMode ? !lines.isEmpty() : fullImageData != null;
+ if (topologyLayout != null || !routeLayouts.isEmpty()) {
+ return true;
+ }
+ return !lines.isEmpty();
+ }
+
+ boolean hasNativeLayout() {
+ return topologyLayout != null;
+ }
+
+ TopologyLayoutResult getTopologyLayout() {
+ return topologyLayout;
+ }
+
+ int getTopologyNodeWidth() {
+ return topologyNodeWidth;
+ }
+
+ RouteDiagramLayoutEngine.LayoutRoute getRouteLayout(String routeId) {
+ return routeLayouts.get(routeId);
+ }
+
+ int getScrollX() {
+ return scrollX;
+ }
+
+ int getScrollY() {
+ return scrollY;
+ }
+
+ ScrollbarState getVScrollState() {
+ return vScrollState;
}
- ImageData getFullImageData() {
- return fullImageData;
+ ScrollbarState getHScrollState() {
+ return hScrollState;
}
boolean handleScrollKeys(KeyEvent ke) {
@@ -160,34 +249,19 @@ class DiagramSupport {
return false;
}
- void toggleImageDiagram(Runnable loadTrigger) {
- if (showDiagram) {
- close();
- } else {
- diagramTextMode = false;
- loadTrigger.run();
- }
+ void resetScroll() {
+ scrollY = 0;
+ scrollX = 0;
}
- void toggleTextDiagram(Runnable loadTrigger) {
+ void toggleDiagram(Runnable loadTrigger) {
if (showDiagram) {
close();
} else {
- diagramTextMode = true;
loadTrigger.run();
}
}
- void switchToImageMode() {
- diagramTextMode = false;
- showDiagram = true;
- }
-
- void switchToTextMode() {
- diagramTextMode = true;
- showDiagram = true;
- }
-
boolean handleEscape() {
if (showDiagram) {
close();
@@ -198,64 +272,736 @@ class DiagramSupport {
void close() {
showDiagram = false;
- imageData = null;
- fullImageData = null;
}
void reset() {
close();
+ invalidateCache();
+ }
+
+ void invalidateCache() {
+ cachedPid = null;
lines = Collections.emptyList();
+ nodeBoxes = Collections.emptyList();
+ topologyNodes = Collections.emptyList();
+ topologyEdges = Collections.emptyList();
+ topologyLayout = null;
+ topologyNodeWidth = 0;
+ routeLayouts = Collections.emptyMap();
+ selectedNodeIndex = -1;
+ eipNodeBoxes = Collections.emptyList();
+ selectedEipNodeIndex = -1;
+ scrollY = 0;
+ scrollX = 0;
+ }
+
+ boolean hasCachedData(String pid) {
+ return pid != null && pid.equals(cachedPid) && hasDiagramData();
+ }
+
+ void showCached() {
+ showDiagram = true;
scrollY = 0;
scrollX = 0;
}
+ void applyPendingSelection() {
+ if (pendingSelectionRouteId != null && !nodeBoxes.isEmpty()) {
+ int newIdx = -1;
+ for (int i = 0; i < nodeBoxes.size(); i++) {
+ if (pendingSelectionRouteId.equals(nodeBoxes.get(i).routeId())) {
+ newIdx = i;
+ break;
+ }
+ }
+ if (newIdx >= 0) {
+ selectedNodeIndex = newIdx;
+ }
+ }
+ pendingSelectionRouteId = null;
+ }
+
+ void preload(MonitorContext ctx, String pid) {
+ if (hasCachedData(pid)) {
+ return;
+ }
+ if (!beginLoad()) {
+ return;
+ }
+ preloading = true;
+ if (ctx.runner != null) {
+ ctx.runner.scheduler().execute(() -> {
+ try {
+ setTopologyMode(true);
+ loadAllDiagramsInBackground(ctx, pid, false, false);
+ } finally {
+ endLoad();
+ }
+ });
+ } else {
+ endLoad();
+ }
+ }
+
void renderFooterHints(List<Span> spans) {
- String closeKey = diagramTextMode ? "D" : "d";
- hint(spans, closeKey + "/Esc", "close");
+ hint(spans, "Esc", "close");
hint(spans, "↑↓←→", "scroll");
hint(spans, "PgUp/PgDn", "page");
hint(spans, "Home/End", "top/end");
}
- // ---- Rendering ----
+ // ---- Node selection ----
- void renderDiagram(Frame frame, Rect area, String title) {
- Block block = Block.builder()
- .borderType(BorderType.ROUNDED)
- .title(title)
- .build();
+ private static int findTopLeftNode(List<TopologyAsciiRenderer.NodeBox> boxes) {
+ int bestIdx = 0;
+ for (int i = 1; i < boxes.size(); i++) {
+ TopologyAsciiRenderer.NodeBox nb = boxes.get(i);
+ TopologyAsciiRenderer.NodeBox best = boxes.get(bestIdx);
+ if (nb.startRow() < best.startRow()
+ || (nb.startRow() == best.startRow() && nb.startCol() < best.startCol())) {
+ bestIdx = i;
+ }
+ }
+ return bestIdx;
+ }
+
+ // ---- Node selection navigation ----
- if (fullImageData != null) {
- renderImageDiagram(frame, area, block);
+ void selectNodeUp() {
+ if (nodeBoxes.isEmpty()) {
+ return;
+ }
+ if (selectedNodeIndex < 0) {
+ selectedNodeIndex = 0;
return;
}
+ TopologyAsciiRenderer.NodeBox current = nodeBoxes.get(selectedNodeIndex);
+ int currentMidCol = (current.startCol() + current.endCol()) / 2;
+ int bestIdx = -1;
+ int bestDist = Integer.MAX_VALUE;
+ for (int i = 0; i < nodeBoxes.size(); i++) {
+ TopologyAsciiRenderer.NodeBox nb = nodeBoxes.get(i);
+ if (nb.layer() < current.layer()) {
+ int midCol = (nb.startCol() + nb.endCol()) / 2;
+ int dist = Math.abs(midCol - currentMidCol);
+ if (nb.layer() > (bestIdx >= 0 ? nodeBoxes.get(bestIdx).layer() : -1) || dist < bestDist) {
+ bestIdx = i;
+ bestDist = dist;
+ }
+ }
+ }
+ if (bestIdx >= 0) {
+ // find the closest layer above, then pick closest column within that layer
+ int targetLayer = nodeBoxes.get(bestIdx).layer();
+ bestIdx = -1;
+ bestDist = Integer.MAX_VALUE;
+ for (int i = 0; i < nodeBoxes.size(); i++) {
+ TopologyAsciiRenderer.NodeBox nb = nodeBoxes.get(i);
+ if (nb.layer() == targetLayer) {
+ int midCol = (nb.startCol() + nb.endCol()) / 2;
+ int dist = Math.abs(midCol - currentMidCol);
+ if (dist < bestDist) {
+ bestIdx = i;
+ bestDist = dist;
+ }
+ }
+ }
+ if (bestIdx >= 0) {
+ selectedNodeIndex = bestIdx;
+ }
+ }
+ }
- int maxWidth = 0;
- for (String line : lines) {
- maxWidth = Math.max(maxWidth, CharWidth.of(line));
+ void selectNodeDown() {
+ if (nodeBoxes.isEmpty()) {
+ return;
+ }
+ if (selectedNodeIndex < 0) {
+ selectedNodeIndex = 0;
+ return;
+ }
+ TopologyAsciiRenderer.NodeBox current = nodeBoxes.get(selectedNodeIndex);
+ int currentMidCol = (current.startCol() + current.endCol()) / 2;
+ int bestIdx = -1;
+ for (int i = 0; i < nodeBoxes.size(); i++) {
+ TopologyAsciiRenderer.NodeBox nb = nodeBoxes.get(i);
+ if (nb.layer() > current.layer()) {
+ if (bestIdx < 0 || nb.layer() < nodeBoxes.get(bestIdx).layer()) {
+ bestIdx = i;
+ }
+ }
+ }
+ if (bestIdx >= 0) {
+ int targetLayer = nodeBoxes.get(bestIdx).layer();
+ bestIdx = -1;
+ int bestDist = Integer.MAX_VALUE;
+ for (int i = 0; i < nodeBoxes.size(); i++) {
+ TopologyAsciiRenderer.NodeBox nb = nodeBoxes.get(i);
+ if (nb.layer() == targetLayer) {
+ int midCol = (nb.startCol() + nb.endCol()) / 2;
+ int dist = Math.abs(midCol - currentMidCol);
+ if (dist < bestDist) {
+ bestIdx = i;
+ bestDist = dist;
+ }
+ }
+ }
+ if (bestIdx >= 0) {
+ selectedNodeIndex = bestIdx;
+ }
+ }
+ }
+
+ void selectNodeLeft() {
+ if (nodeBoxes.isEmpty()) {
+ return;
+ }
+ if (selectedNodeIndex < 0) {
+ selectedNodeIndex = 0;
+ return;
+ }
+ TopologyAsciiRenderer.NodeBox current = nodeBoxes.get(selectedNodeIndex);
+ int bestIdx = -1;
+ int bestCol = -1;
+ for (int i = 0; i < nodeBoxes.size(); i++) {
+ TopologyAsciiRenderer.NodeBox nb = nodeBoxes.get(i);
+ if (nb.layer() == current.layer() && nb.startCol() < current.startCol()) {
+ if (nb.startCol() > bestCol) {
+ bestIdx = i;
+ bestCol = nb.startCol();
+ }
+ }
+ }
+ if (bestIdx >= 0) {
+ selectedNodeIndex = bestIdx;
+ }
+ }
+
+ void selectNodeRight() {
+ if (nodeBoxes.isEmpty()) {
+ return;
+ }
+ if (selectedNodeIndex < 0) {
+ selectedNodeIndex = 0;
+ return;
}
+ TopologyAsciiRenderer.NodeBox current = nodeBoxes.get(selectedNodeIndex);
+ int bestIdx = -1;
+ int bestCol = Integer.MAX_VALUE;
+ for (int i = 0; i < nodeBoxes.size(); i++) {
+ TopologyAsciiRenderer.NodeBox nb = nodeBoxes.get(i);
+ if (nb.layer() == current.layer() && nb.startCol() > current.startCol()) {
+ if (nb.startCol() < bestCol) {
+ bestIdx = i;
+ bestCol = nb.startCol();
+ }
+ }
+ }
+ if (bestIdx >= 0) {
+ selectedNodeIndex = bestIdx;
+ }
+ }
+
+ void selectFirstNode() {
+ if (nodeBoxes.isEmpty()) {
+ return;
+ }
+ int bestIdx = 0;
+ for (int i = 1; i < nodeBoxes.size(); i++) {
+ var best = nodeBoxes.get(bestIdx);
+ var nb = nodeBoxes.get(i);
+ if (nb.startRow() < best.startRow()
+ || (nb.startRow() == best.startRow() && nb.startCol() < best.startCol())) {
+ bestIdx = i;
+ }
+ }
+ selectedNodeIndex = bestIdx;
+ }
+
+ void selectLastNode() {
+ if (nodeBoxes.isEmpty()) {
+ return;
+ }
+ int bestIdx = 0;
+ for (int i = 1; i < nodeBoxes.size(); i++) {
+ var best = nodeBoxes.get(bestIdx);
+ var nb = nodeBoxes.get(i);
+ if (nb.startRow() > best.startRow()
+ || (nb.startRow() == best.startRow() && nb.startCol() > best.startCol())) {
+ bestIdx = i;
+ }
+ }
+ selectedNodeIndex = bestIdx;
+ }
+
+ void scrollToSelectedNode() {
+ if (selectedNodeIndex < 0 || selectedNodeIndex >= nodeBoxes.size()) {
+ return;
+ }
+ TopologyAsciiRenderer.NodeBox box = nodeBoxes.get(selectedNodeIndex);
+ if (lastVisibleHeight > 0) {
+ if (box.startRow() < scrollY + 1) {
+ scrollY = Math.max(0, box.startRow() - 1);
+ }
+ if (box.endRow() >= scrollY + lastVisibleHeight - 1) {
+ scrollY = box.endRow() - lastVisibleHeight + 2;
+ }
+ }
+ if (lastVisibleWidth > 0) {
+ if (box.startCol() < scrollX + 1) {
+ scrollX = Math.max(0, box.startCol() - 1);
+ }
+ if (box.endCol() >= scrollX + lastVisibleWidth - 1) {
+ scrollX = box.endCol() - lastVisibleWidth + 2;
+ }
+ }
+ }
+
+ private List<TopologyAsciiRenderer.NodeBox> computeNodeBoxes(
+ TopologyLayoutResult result, int nodeW, boolean metrics) {
+ int bw = Math.max(16, nodeW / 15);
+ List<TopologyAsciiRenderer.NodeBox> boxes = new ArrayList<>();
+ for (TopologyLayoutNode node : result.nodes) {
+ int col = nodeW == 0 ? 0 : node.x * bw / nodeW;
+ int row = node.y / 20;
+ boolean ext = "external-in".equals(node.nodeType) || "external-out".equals(node.nodeType);
+ int contentLines;
+ if (ext) {
+ contentLines = 1;
+ if (metrics && node.exchangesTotal > 0) {
+ contentLines++;
+ }
+ } else {
+ contentLines = 3; // routeId + from (2 lines reserved)
+ if (metrics) {
+ contentLines++;
+ }
+ contentLines = Math.min(contentLines, 4);
+ }
+ int height = 2 + contentLines;
+ boxes.add(new TopologyAsciiRenderer.NodeBox(
+ node.routeId, row, row + height - 1, col, col + bw - 1, node.layer));
+ }
+ return boxes;
+ }
+
+ void renderNativeDiagram(Frame frame, Rect area, Line title, boolean metrics) {
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(Title.from(title))
+ .build();
+ frame.renderWidget(block, area);
Rect inner = block.inner(area);
+
+ var widget = new org.apache.camel.dsl.jbang.core.commands.tui.diagram.TopologyDiagramWidget(
+ topologyLayout, topologyNodeWidth, selectedNodeIndex, scrollX, scrollY, metrics, showDescription);
+
+ int totalRows = widget.getTotalRows();
+ int totalCols = widget.getTotalCols();
int visibleLines = Math.max(1, inner.height() - 1);
int visibleCols = Math.max(1, inner.width() - 1);
+ lastVisibleHeight = visibleLines;
+ lastVisibleWidth = visibleCols;
- int maxVScroll = Math.max(0, lines.size() - visibleLines);
- int maxHScroll = Math.max(0, maxWidth - visibleCols);
+ int maxVScroll = Math.max(0, totalRows - visibleLines);
+ int maxHScroll = Math.max(0, totalCols - visibleCols);
scrollY = Math.min(scrollY, maxVScroll);
scrollX = Math.min(scrollX, maxHScroll);
- List<Line> rendered = new ArrayList<>();
- int end = Math.min(scrollY + visibleLines, lines.size());
- for (int i = scrollY; i < end; i++) {
- String line = lines.get(i);
- if (scrollX > 0) {
- line = scrollX < line.length() ? line.substring(scrollX) : "";
+ // Re-create widget with clamped scroll
+ var finalWidget = new org.apache.camel.dsl.jbang.core.commands.tui.diagram.TopologyDiagramWidget(
+ topologyLayout, topologyNodeWidth, selectedNodeIndex, scrollX, scrollY, metrics, showDescription);
+
+ List<Rect> vChunks = Layout.vertical()
+ .constraints(Constraint.fill(), Constraint.length(1))
+ .split(inner);
+
+ List<Rect> hChunks = Layout.horizontal()
+ .constraints(Constraint.fill(), Constraint.length(1))
+ .split(vChunks.get(0));
+
+ frame.renderWidget(finalWidget, hChunks.get(0));
+
+ // Update nodeBoxes from widget
+ List<TopologyAsciiRenderer.NodeBox> widgetBoxes = new ArrayList<>();
+ for (var nb : finalWidget.getNodeBoxes()) {
+ widgetBoxes.add(new TopologyAsciiRenderer.NodeBox(
+ nb.routeId(), nb.startRow(), nb.endRow(), nb.startCol(), nb.endCol(), nb.layer()));
+ }
+ if (!widgetBoxes.isEmpty()) {
+ nodeBoxes = widgetBoxes;
+ }
+
+ vScrollState.contentLength(totalRows);
+ vScrollState.viewportContentLength(visibleLines);
+ vScrollState.position(scrollY);
+ frame.renderStatefulWidget(
+ Scrollbar.builder().build(),
+ hChunks.get(1), vScrollState);
+
+ if (totalCols > visibleCols) {
+ hScrollState.contentLength(totalCols);
+ hScrollState.viewportContentLength(visibleCols);
+ hScrollState.position(scrollX);
+ frame.renderStatefulWidget(
+ Scrollbar.horizontal(),
+ vChunks.get(1), hScrollState);
+ }
+ }
+
+ // ---- Route EIP node selection ----
+
+ private List<org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget.EipNodeBox> eipNodeBoxes
+ = Collections.emptyList();
+ private int selectedEipNodeIndex = -1;
+
+ List<org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget.EipNodeBox> getEipNodeBoxes() {
+ return eipNodeBoxes;
+ }
+
+ int getSelectedEipNodeIndex() {
+ return selectedEipNodeIndex;
+ }
+
+ void setSelectedEipNodeIndex(int idx) {
+ this.selectedEipNodeIndex = idx;
+ }
+
+ void selectFromNode(String routeId) {
+ selectedEipNodeIndex = 0;
+ }
+
+ org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget.EipNodeBox getSelectedEipNodeBox() {
+ if (selectedEipNodeIndex >= 0 && selectedEipNodeIndex < eipNodeBoxes.size()) {
+ return eipNodeBoxes.get(selectedEipNodeIndex);
+ }
+ return null;
+ }
+
+ /**
+ * Finds the route ID that the selected EIP node links to, by matching the node's endpoint URI against topology
+ * edges and route "from" endpoints.
+ */
+ String findLinkedRouteId(String currentRouteId) {
+ var box = getSelectedEipNodeBox();
+ if (box == null || box.layoutNode() == null || box.layoutNode().treeNode == null) {
+ return null;
+ }
+ String type = box.type();
+ if (type == null) {
+ return null;
+ }
+ // Only "to"-style nodes can link to other routes
+ if (!"to".equals(type) && !"toD".equals(type) && !"wireTap".equals(type)
+ && !"enrich".equals(type) && !"pollEnrich".equals(type)
+ && !"from".equals(type)) {
+ return null;
+ }
+ String baseUri = getBaseUri(box.layoutNode().treeNode.info);
+ if (baseUri == null || baseUri.isBlank()) {
+ return null;
+ }
+
+ // First try topology edges for direct matching
+ for (TopologyLayoutEdge edge : topologyEdges) {
+ if ("from".equals(type)) {
+ // "from" node: find route that sends TO this endpoint
+ if (currentRouteId.equals(edge.to.routeId) && !currentRouteId.equals(edge.from.routeId)) {
+ return edge.from.routeId;
+ }
+ } else {
+ // "to" node: find route that consumes FROM this endpoint
+ if (currentRouteId.equals(edge.from.routeId) && !currentRouteId.equals(edge.to.routeId)) {
+ String targetFrom = stripQueryParams(edge.to.from);
+ if (baseUri.equals(targetFrom)) {
+ return edge.to.routeId;
+ }
+ }
}
- rendered.add(styleDiagramLine(line, i, scrollX));
}
+ // Fallback: match uri against route "from" endpoints in routeLayouts
+ if (!"from".equals(type)) {
+ for (var entry : routeLayouts.entrySet()) {
+ if (currentRouteId.equals(entry.getKey())) {
+ continue;
+ }
+ String fromBaseUri = findFromUri(entry.getValue());
+ if (baseUri.equals(fromBaseUri)) {
+ return entry.getKey();
+ }
+ }
+ }
+ return null;
+ }
+
+ static String getBaseUri(RouteDiagramLayoutEngine.NodeInfo info) {
+ String uri = info.uri;
+ if (uri == null) {
+ uri = extractUriFromCode(info.code);
+ }
+ return stripQueryParams(uri);
+ }
+
+ private static String extractUriFromCode(String code) {
+ if (code == null) {
+ return null;
+ }
+ int open = code.indexOf('[');
+ int close = code.lastIndexOf(']');
+ return (open >= 0 && close > open) ? code.substring(open + 1, close) : code;
+ }
+
+ static String stripQueryParams(String uri) {
+ if (uri == null) {
+ return null;
+ }
+ int q = uri.indexOf('?');
+ return q >= 0 ? uri.substring(0, q) : uri;
+ }
+
+ void selectEipNodeUp() {
+ if (eipNodeBoxes.isEmpty()) {
+ return;
+ }
+ if (selectedEipNodeIndex < 0) {
+ selectedEipNodeIndex = 0;
+ return;
+ }
+ // Move to previous node in list (follow flow upward)
+ if (selectedEipNodeIndex > 0) {
+ selectedEipNodeIndex--;
+ }
+ }
+
+ void selectEipNodeDown() {
+ if (eipNodeBoxes.isEmpty()) {
+ return;
+ }
+ if (selectedEipNodeIndex < 0) {
+ selectedEipNodeIndex = 0;
+ return;
+ }
+ if (selectedEipNodeIndex < eipNodeBoxes.size() - 1) {
+ selectedEipNodeIndex++;
+ }
+ }
+
+ void selectEipNodeLeft() {
+ if (eipNodeBoxes.isEmpty() || selectedEipNodeIndex < 0) {
+ return;
+ }
+ var current = eipNodeBoxes.get(selectedEipNodeIndex);
+ int bestIdx = -1;
+ int bestCol = -1;
+ for (int i = 0; i < eipNodeBoxes.size(); i++) {
+ var nb = eipNodeBoxes.get(i);
+ if (nb.startCol() < current.startCol()
+ && Math.abs(nb.startRow() - current.startRow()) <= 2) {
+ if (nb.startCol() > bestCol) {
+ bestIdx = i;
+ bestCol = nb.startCol();
+ }
+ }
+ }
+ if (bestIdx >= 0) {
+ selectedEipNodeIndex = bestIdx;
+ }
+ }
+
+ void selectEipNodeRight() {
+ if (eipNodeBoxes.isEmpty() || selectedEipNodeIndex < 0) {
+ return;
+ }
+ var current = eipNodeBoxes.get(selectedEipNodeIndex);
+ int bestIdx = -1;
+ int bestCol = Integer.MAX_VALUE;
+ for (int i = 0; i < eipNodeBoxes.size(); i++) {
+ var nb = eipNodeBoxes.get(i);
+ if (nb.startCol() > current.startCol()
+ && Math.abs(nb.startRow() - current.startRow()) <= 2) {
+ if (nb.startCol() < bestCol) {
+ bestIdx = i;
+ bestCol = nb.startCol();
+ }
+ }
+ }
+ if (bestIdx >= 0) {
+ selectedEipNodeIndex = bestIdx;
+ }
+ }
+
+ void selectFirstEipNode() {
+ if (eipNodeBoxes.isEmpty()) {
+ return;
+ }
+ int bestIdx = 0;
+ for (int i = 1; i < eipNodeBoxes.size(); i++) {
+ var best = eipNodeBoxes.get(bestIdx);
+ var nb = eipNodeBoxes.get(i);
+ if (nb.startRow() < best.startRow()
+ || (nb.startRow() == best.startRow() && nb.startCol() < best.startCol())) {
+ bestIdx = i;
+ }
+ }
+ selectedEipNodeIndex = bestIdx;
+ }
+
+ void selectLastEipNode() {
+ if (eipNodeBoxes.isEmpty()) {
+ return;
+ }
+ int bestIdx = 0;
+ for (int i = 1; i < eipNodeBoxes.size(); i++) {
+ var best = eipNodeBoxes.get(bestIdx);
+ var nb = eipNodeBoxes.get(i);
+ if (nb.startRow() > best.startRow()
+ || (nb.startRow() == best.startRow() && nb.startCol() > best.startCol())) {
+ bestIdx = i;
+ }
+ }
+ selectedEipNodeIndex = bestIdx;
+ }
+
+ void scrollToSelectedEipNode() {
+ if (selectedEipNodeIndex < 0 || selectedEipNodeIndex >= eipNodeBoxes.size()) {
+ return;
+ }
+ var box = eipNodeBoxes.get(selectedEipNodeIndex);
+ if (lastVisibleHeight > 0) {
+ if (box.startRow() < scrollY + 1) {
+ scrollY = Math.max(0, box.startRow() - 1);
+ }
+ if (box.endRow() >= scrollY + lastVisibleHeight - 1) {
+ scrollY = box.endRow() - lastVisibleHeight + 2;
+ }
+ }
+ if (lastVisibleWidth > 0) {
+ if (box.startCol() < scrollX + 1) {
+ scrollX = Math.max(0, box.startCol() - 1);
+ }
+ if (box.endCol() >= scrollX + lastVisibleWidth - 1) {
+ scrollX = box.endCol() - lastVisibleWidth + 2;
+ }
+ }
+ }
+
+ /**
+ * Computes a mapping from base endpoint URI to target route ID for navigable links from the current route.
+ */
+ private Map<String, String> computeLinkableEndpoints(String currentRouteId) {
+ Map<String, String> endpoints = new HashMap<>();
+ String currentFromUri = null;
+ var currentLayout = routeLayouts.get(currentRouteId);
+ if (currentLayout != null) {
+ currentFromUri = findFromUri(currentLayout);
+ }
+
+ for (var entry : routeLayouts.entrySet()) {
+ if (currentRouteId.equals(entry.getKey())) {
+ continue;
+ }
+ var lr = entry.getValue();
+ String fromUri = findFromUri(lr);
+ if (fromUri != null) {
+ endpoints.put(fromUri, entry.getKey());
+ }
+ if (currentFromUri != null) {
+ for (var node : lr.nodes) {
+ String type = node.type;
+ if (("to".equals(type) || "toD".equals(type) || "wireTap".equals(type))
+ && node.treeNode != null) {
+ String uri = getBaseUri(node.treeNode.info);
+ if (currentFromUri.equals(uri)) {
+ endpoints.put(currentFromUri, entry.getKey());
+ }
+ }
+ }
+ }
+ }
+ return endpoints;
+ }
+
+ private Map<String, String> computeRouteDescriptions() {
+ Map<String, String> descriptions = new HashMap<>();
+ for (var entry : routeLayouts.entrySet()) {
+ var lr = entry.getValue();
+ for (var node : lr.nodes) {
+ if ("route".equals(node.type) && node.treeNode != null) {
+ String desc = node.treeNode.info.description;
+ if (desc != null && !desc.isBlank()) {
+ descriptions.put(entry.getKey(), desc);
+ }
+ break;
+ }
+ }
+ }
+ return descriptions;
+ }
+
+ private static Set<String> parseExternalUris(JsonObject topoJson) {
+ Set<String> uris = new HashSet<>();
+ JsonArray arr = topoJson.getJsonArray("externalEndpoints");
+ if (arr == null) {
+ return uris;
+ }
+ for (int i = 0; i < arr.size(); i++) {
+ JsonObject eo = arr.getJsonObject(i);
+ String uri = eo.getString("uri");
+ if (uri != null) {
+ String base = stripQueryParams(uri);
+ if (base != null) {
+ uris.add(base);
+ }
+ }
+ }
+ return uris;
+ }
+
+ private static String findFromUri(RouteDiagramLayoutEngine.LayoutRoute lr) {
+ for (var node : lr.nodes) {
+ if ("from".equals(node.type) && node.treeNode != null) {
+ return getBaseUri(node.treeNode.info);
+ }
+ }
+ return null;
+ }
+
+ void renderNativeRouteDiagram(
+ Frame frame, Rect area, Line title, boolean metrics,
+ String currentRouteId, RouteDiagramLayoutEngine.LayoutRoute routeLayout) {
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(Title.from(title))
+ .build();
frame.renderWidget(block, area);
+ Rect inner = block.inner(area);
+ int nw = RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH * RouteDiagramLayoutEngine.SCALE;
+ Map<String, String> linkable = computeLinkableEndpoints(currentRouteId);
+ Map<String, String> routeDescs = showDescription ? computeRouteDescriptions() : Collections.emptyMap();
+
+ var widget = new org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget(
+ routeLayout, nw, selectedEipNodeIndex, scrollX, scrollY, metrics, linkable,
+ showDescription, routeDescs);
+
+ int totalRows = widget.getTotalRows();
+ int totalCols = widget.getTotalCols();
+ int visibleLines = Math.max(1, inner.height() - 1);
+ int visibleCols = Math.max(1, inner.width() - 1);
+ lastVisibleHeight = visibleLines;
+ lastVisibleWidth = visibleCols;
+
+ int maxVScroll = Math.max(0, totalRows - visibleLines);
+ int maxHScroll = Math.max(0, totalCols - visibleCols);
+ scrollY = Math.min(scrollY, maxVScroll);
+ scrollX = Math.min(scrollX, maxHScroll);
+
+ var finalWidget = new org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget(
+ routeLayout, nw, selectedEipNodeIndex, scrollX, scrollY, metrics, linkable,
+ showDescription, routeDescs);
+
List<Rect> vChunks = Layout.vertical()
.constraints(Constraint.fill(), Constraint.length(1))
.split(inner);
@@ -264,60 +1010,92 @@ class DiagramSupport {
.constraints(Constraint.fill(), Constraint.length(1))
.split(vChunks.get(0));
- Paragraph paragraph = Paragraph.builder()
- .text(Text.from(rendered))
- .build();
- frame.renderWidget(paragraph, hChunks.get(0));
+ frame.renderWidget(finalWidget, hChunks.get(0));
- vScrollState.contentLength(lines.size());
+ eipNodeBoxes = new ArrayList<>(finalWidget.getNodeBoxes());
+ if (selectedEipNodeIndex < 0 && !eipNodeBoxes.isEmpty()) {
+ selectedEipNodeIndex = 0;
+ }
+
+ vScrollState.contentLength(totalRows);
vScrollState.viewportContentLength(visibleLines);
vScrollState.position(scrollY);
frame.renderStatefulWidget(
Scrollbar.builder().build(),
hChunks.get(1), vScrollState);
- if (maxWidth > visibleCols) {
- hScrollState.contentLength(maxWidth);
+ if (totalCols > visibleCols) {
+ hScrollState.contentLength(totalCols);
hScrollState.viewportContentLength(visibleCols);
hScrollState.position(scrollX);
frame.renderStatefulWidget(
Scrollbar.horizontal(),
vChunks.get(1), hScrollState);
}
+
+ renderMinimap(frame, hChunks.get(0), currentRouteId);
}
- private void renderImageDiagram(Frame frame, Rect area, Block block) {
- int imgW = fullImageData.width();
- int imgH = fullImageData.height();
+ private void renderMinimap(Frame frame, Rect area, String currentRouteId) {
+ if (topologyLayout == null || topologyLayout.nodes.size() < 2) {
+ return;
+ }
+ int mapW = Math.min(22, area.width() / 3);
+ int mapH = Math.min(8, area.height() / 3);
+ if (mapW < 6 || mapH < 3 || area.width() < 40 || area.height() < 12) {
+ return;
+ }
+
+ int mapX = area.x() + area.width() - mapW - 1;
+ int mapY = area.y() + area.height() - mapH - 1;
+ Rect mapRect = new Rect(mapX, mapY, mapW, mapH);
+
+ frame.renderWidget(Clear.INSTANCE, mapRect);
+
+ Block mapBlock = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(Title.from(Line.from(Span.styled(" Map ", Style.EMPTY.dim()))))
+ .build();
+ frame.renderWidget(mapBlock, mapRect);
+
+ Rect innerRect = mapBlock.inner(mapRect);
+ var minimap = new org.apache.camel.dsl.jbang.core.commands.tui.diagram.TopologyMinimapWidget(
+ topologyLayout, currentRouteId);
+ frame.renderWidget(minimap, innerRect);
+ }
+
+ // ---- Rendering (legacy text) ----
+
+ void renderDiagram(Frame frame, Rect area, String title) {
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(title)
+ .build();
+
+ int maxWidth = 0;
+ for (String line : lines) {
+ maxWidth = Math.max(maxWidth, CharWidth.of(line));
+ }
Rect inner = block.inner(area);
- int pxPerCol = protocol.resolution().widthMultiplier();
- int pxPerRow = protocol.resolution().heightMultiplier();
- int viewCols = Math.max(1, inner.width() - 1);
- int viewRows = Math.max(1, inner.height() - 1);
- int viewW = viewCols * pxPerCol;
- int viewH = viewRows * pxPerRow;
-
- int maxScrollY = Math.max(0, (imgH - viewH + pxPerRow - 1) / pxPerRow);
- int maxScrollX = Math.max(0, (imgW - viewW + pxPerCol - 1) / pxPerCol);
- scrollY = Math.min(scrollY, maxScrollY);
- scrollX = Math.min(scrollX, maxScrollX);
-
- int cx = Math.min(scrollX * pxPerCol, imgW);
- int cy = Math.min(scrollY * pxPerRow, imgH);
- int cw = Math.min(viewW, imgW - cx);
- int ch = Math.min(viewH, imgH - cy);
-
- if (cw > 0 && ch > 0) {
- if (cx != cropX || cy != cropY || cw != cropW || ch != cropH) {
- imageData = fullImageData.crop(cx, cy, cw, ch);
- cropX = cx;
- cropY = cy;
- cropW = cw;
- cropH = ch;
- }
- } else if (imageData != fullImageData) {
- imageData = fullImageData;
+ int visibleLines = Math.max(1, inner.height() - 1);
+ int visibleCols = Math.max(1, inner.width() - 1);
+ lastVisibleHeight = visibleLines;
+ lastVisibleWidth = visibleCols;
+
+ int maxVScroll = Math.max(0, lines.size() - visibleLines);
+ int maxHScroll = Math.max(0, maxWidth - visibleCols);
+ scrollY = Math.min(scrollY, maxVScroll);
+ scrollX = Math.min(scrollX, maxHScroll);
+
+ List<Line> rendered = new ArrayList<>();
+ int end = Math.min(scrollY + visibleLines, lines.size());
+ for (int i = scrollY; i < end; i++) {
+ String line = lines.get(i);
+ if (scrollX > 0) {
+ line = scrollX < line.length() ? line.substring(scrollX) : "";
+ }
+ rendered.add(styleDiagramLine(line, i, scrollX));
}
frame.renderWidget(block, area);
@@ -330,25 +1108,21 @@ class DiagramSupport {
.constraints(Constraint.fill(), Constraint.length(1))
.split(vChunks.get(0));
- Image img = Image.builder()
- .data(imageData)
- .protocol(protocol)
- .scaling(ImageScaling.FIT)
+ Paragraph paragraph = Paragraph.builder()
+ .text(Text.from(rendered))
.build();
- frame.renderWidget(img, hChunks.get(0));
+ frame.renderWidget(paragraph, hChunks.get(0));
- int totalRows = (imgH + pxPerRow - 1) / pxPerRow;
- vScrollState.contentLength(totalRows);
- vScrollState.viewportContentLength(viewRows);
+ vScrollState.contentLength(lines.size());
+ vScrollState.viewportContentLength(visibleLines);
vScrollState.position(scrollY);
frame.renderStatefulWidget(
Scrollbar.builder().build(),
hChunks.get(1), vScrollState);
- if (imgW > viewW) {
- int totalCols = (imgW + pxPerCol - 1) / pxPerCol;
- hScrollState.contentLength(totalCols);
- hScrollState.viewportContentLength(viewCols);
+ if (maxWidth > visibleCols) {
+ hScrollState.contentLength(maxWidth);
+ hScrollState.viewportContentLength(visibleCols);
hScrollState.position(scrollX);
frame.renderStatefulWidget(
Scrollbar.horizontal(),
@@ -368,19 +1142,17 @@ class DiagramSupport {
void setLoadingPlaceholder() {
lines = List.of("(Loading diagram...)");
- imageData = null;
- fullImageData = null;
showDiagram = true;
scrollY = 0;
scrollX = 0;
}
void loadHighlightedDiagramInBackground(
- MonitorContext ctx, String pid, boolean textMode,
+ MonitorContext ctx, String pid,
String[] messageHistory, RouteDiagramHelper.HighlightStyle hlStyle) {
JsonObject jo = requestRouteStructure(ctx, pid);
if (jo == null) {
- applyResult(ctx, List.of("(No response from integration)"), null, null, null);
+ applyResult(ctx, List.of("(No response from integration)"));
return;
}
@@ -390,7 +1162,7 @@ class DiagramSupport {
List<RouteDiagramLayoutEngine.RouteInfo> routes = RouteDiagramHelper.parseRoutes(jo);
if (routes.isEmpty()) {
- applyResult(ctx, List.of("(No routes in response)"), null, null, null);
+ applyResult(ctx, List.of("(No routes in response)"));
return;
}
@@ -405,25 +1177,135 @@ class DiagramSupport {
= new RouteDiagramHelper.HighlightInfo(nodeIds, highlightInfo.getRouteOrder(), hlStyle);
routes = RouteDiagramHelper.filterAndOrderRoutes(routes, fullHighlight);
if (routes.isEmpty()) {
- applyResult(ctx, List.of("(No routes contain highlighted nodes)"), null, null, null);
+ applyResult(ctx, List.of("(No routes contain highlighted nodes)"));
return;
}
- renderRoutes(ctx, textMode, routes, false, nodeIds, hlStyle);
+ renderRoutes(ctx, routes, false, nodeIds, hlStyle);
+ }
+
+ void loadAllDiagramsInBackground(
+ MonitorContext ctx, String pid, boolean metrics, boolean external) {
+ // Single IPC call: topology + route structures
+ JsonObject topoJson = requestRouteTopology(ctx, pid, external, true);
+
+ TopologyLayoutResult topoResult = null;
+ int nodeW = 0;
+ List<TopologyLayoutNode> topoNodes = Collections.emptyList();
+ List<TopologyLayoutEdge> topoEdges = Collections.emptyList();
+
+ if (topoJson != null) {
+ List<TopologyNodeInfo> nodes = TopologyHelper.parseNodes(topoJson);
+ List<TopologyEdgeInfo> edges = TopologyHelper.parseEdges(topoJson);
+ if (external) {
+ TopologyHelper.addExternalEndpoints(nodes, edges, topoJson);
+ }
+ if (!nodes.isEmpty()) {
+ TopologyLayoutEngine engine = new TopologyLayoutEngine();
+ topoResult = engine.layout(nodes, edges);
+ normalizeTopologyLayoutY(topoResult);
+ nodeW = engine.getNodeWidth();
+ topoNodes = topoResult.nodes;
+ topoEdges = topoResult.edges;
+ }
+ }
+
+ // Route structure is included in the topology response (routes=true)
+ java.util.Map<String, RouteDiagramLayoutEngine.LayoutRoute> routeMap = new java.util.LinkedHashMap<>();
+ if (topoJson != null) {
+ List<RouteDiagramLayoutEngine.RouteInfo> routes = RouteDiagramHelper.parseRoutes(topoJson);
+ if (!routes.isEmpty()) {
+ // Enrich route nodes with remote flag from topology external endpoints
+ Set<String> externalUris = parseExternalUris(topoJson);
+ if (!externalUris.isEmpty()) {
+ for (RouteDiagramLayoutEngine.RouteInfo r : routes) {
+ for (RouteDiagramLayoutEngine.NodeInfo ni : r.nodes) {
+ if (!ni.remote) {
+ String baseUri = getBaseUri(ni);
+ if (baseUri != null && externalUris.contains(baseUri)) {
+ ni.remote = true;
+ }
+ }
+ }
+ }
+ }
+ RouteDiagramLayoutEngine.NodeLabelMode labelMode = showDescription
+ ? RouteDiagramLayoutEngine.NodeLabelMode.DESCRIPTION
+ : RouteDiagramLayoutEngine.NodeLabelMode.CODE;
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(
+ RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE,
+ labelMode);
+ for (RouteDiagramLayoutEngine.RouteInfo r : routes) {
+ RouteDiagramLayoutEngine.LayoutRoute lr = engine.layoutRoute(r, 0);
+ normalizeRouteLayoutY(lr);
+ routeMap.put(r.routeId, lr);
+ }
+ }
+ }
+
+ // Apply results on render thread
+ TopologyLayoutResult finalTopoResult = topoResult;
+ int finalNodeW = nodeW;
+ List<TopologyLayoutNode> finalTopoNodes = topoNodes;
+ List<TopologyLayoutEdge> finalTopoEdges = topoEdges;
+
+ if (ctx.runner == null) {
+ return;
+ }
+ ctx.runner.runOnRenderThread(() -> {
+ if (!showDiagram && !preloading) {
+ return;
+ }
+ preloading = false;
+ cachedPid = pid;
+ topologyLayout = finalTopoResult;
+ topologyNodeWidth = finalNodeW;
+ topologyNodes = finalTopoNodes;
+ topologyEdges = finalTopoEdges;
+ routeLayouts = routeMap;
+
+ // Preserve selection
+ String prevRouteId = getSelectedRouteId();
+ if (prevRouteId == null && pendingSelectionRouteId != null) {
+ prevRouteId = pendingSelectionRouteId;
+ }
+ pendingSelectionRouteId = null;
+
+ if (finalTopoResult != null) {
+ List<TopologyAsciiRenderer.NodeBox> boxes
+ = computeNodeBoxes(finalTopoResult, finalNodeW, metrics);
+ nodeBoxes = boxes;
+
+ if (prevRouteId != null && !boxes.isEmpty()) {
+ int newIdx = -1;
+ for (int i = 0; i < boxes.size(); i++) {
+ if (prevRouteId.equals(boxes.get(i).routeId())) {
+ newIdx = i;
+ break;
+ }
+ }
+ selectedNodeIndex = newIdx >= 0 ? newIdx : 0;
+ } else if (!boxes.isEmpty() && selectedNodeIndex < 0) {
+ selectedNodeIndex = findTopLeftNode(boxes);
+ } else if (boxes.isEmpty()) {
+ selectedNodeIndex = -1;
+ }
+ }
+ });
}
void loadRouteDiagramInBackground(
- MonitorContext ctx, String pid, boolean textMode,
+ MonitorContext ctx, String pid,
String routeId, boolean metrics) {
JsonObject jo = requestRouteStructure(ctx, pid);
if (jo == null) {
- applyResult(ctx, List.of("(No response from integration)"), null, null, null);
+ applyResult(ctx, List.of("(No response from integration)"));
return;
}
List<RouteDiagramLayoutEngine.RouteInfo> routes = RouteDiagramHelper.parseRoutes(jo);
if (routes.isEmpty()) {
- applyResult(ctx, List.of("(No routes in response)"), null, null, null);
+ applyResult(ctx, List.of("(No routes in response)"));
return;
}
@@ -431,14 +1313,14 @@ class DiagramSupport {
routes.removeIf(r -> !routeId.equals(r.routeId));
}
- renderRoutes(ctx, textMode, routes, metrics, null, null);
+ renderRoutes(ctx, routes, metrics, null, null);
}
void loadTopologyDiagramInBackground(
- MonitorContext ctx, String pid, boolean textMode, boolean metrics, boolean external) {
- JsonObject jo = requestRouteTopology(ctx, pid, external);
+ MonitorContext ctx, String pid, boolean metrics, boolean external) {
+ JsonObject jo = requestRouteTopology(ctx, pid, external, false);
if (jo == null) {
- applyResult(ctx, List.of("(No response from integration)"), null, null, null);
+ applyResult(ctx, List.of("(No response from integration)"));
return;
}
@@ -448,75 +1330,71 @@ class DiagramSupport {
TopologyHelper.addExternalEndpoints(nodes, edges, jo);
}
if (nodes.isEmpty()) {
- applyResult(ctx, List.of("(No routes in response)"), null, null, null);
+ applyResult(ctx, List.of("(No routes in response)"));
return;
}
TopologyLayoutEngine engine = new TopologyLayoutEngine();
TopologyLayoutResult result = engine.layout(nodes, edges);
- if (textMode) {
- TopologyAsciiRenderer renderer = new TopologyAsciiRenderer(
- engine.getNodeWidth(), true, metrics, showDescription);
- String text = renderer.renderDiagramPlain(result);
-
- List<String> resultLines = new ArrayList<>();
- List<RouteDiagramAsciiRenderer.CounterPos> positions = new ArrayList<>();
- String[] rawLines = text.split("\n", -1);
- int[] rowMapping = new int[rawLines.length];
- int newRow = 0;
- for (int i = 0; i < rawLines.length; i++) {
- if (!rawLines[i].isEmpty()) {
- rowMapping[i] = newRow++;
- resultLines.add(rawLines[i]);
- } else {
- rowMapping[i] = -1;
- }
+ TopologyAsciiRenderer renderer = new TopologyAsciiRenderer(
+ engine.getNodeWidth(), true, metrics, showDescription);
+ String text = renderer.renderDiagramPlain(result);
+
+ List<String> resultLines = new ArrayList<>();
+ List<RouteDiagramAsciiRenderer.CounterPos> positions = new ArrayList<>();
+ String[] rawLines = text.split("\n", -1);
+ int[] rowMapping = new int[rawLines.length];
+ int newRow = 0;
+ for (int i = 0; i < rawLines.length; i++) {
+ if (!rawLines[i].isEmpty()) {
+ rowMapping[i] = newRow++;
+ resultLines.add(rawLines[i]);
+ } else {
+ rowMapping[i] = -1;
}
+ }
- for (TopologyAsciiRenderer.CounterPos cp : renderer.getCounterPositions()) {
- if (cp.row() >= 0 && cp.row() < rowMapping.length && rowMapping[cp.row()] >= 0) {
- RouteDiagramAsciiRenderer.CounterType mapped = switch (cp.type()) {
- case OK -> RouteDiagramAsciiRenderer.CounterType.OK;
- case FAIL -> RouteDiagramAsciiRenderer.CounterType.FAIL;
- case TRIGGER -> RouteDiagramAsciiRenderer.CounterType.HIGHLIGHT_SUCCESS;
- case EXTERNAL -> RouteDiagramAsciiRenderer.CounterType.EXTERNAL;
- };
- positions.add(new RouteDiagramAsciiRenderer.CounterPos(
- rowMapping[cp.row()], cp.col(), cp.length(), mapped));
- }
+ for (TopologyAsciiRenderer.CounterPos cp : renderer.getCounterPositions()) {
+ if (cp.row() >= 0 && cp.row() < rowMapping.length && rowMapping[cp.row()] >= 0) {
+ RouteDiagramAsciiRenderer.CounterType mapped = switch (cp.type()) {
+ case OK -> RouteDiagramAsciiRenderer.CounterType.OK;
+ case FAIL -> RouteDiagramAsciiRenderer.CounterType.FAIL;
+ case TRIGGER -> RouteDiagramAsciiRenderer.CounterType.HIGHLIGHT_SUCCESS;
+ case EXTERNAL -> RouteDiagramAsciiRenderer.CounterType.EXTERNAL;
+ };
+ positions.add(new RouteDiagramAsciiRenderer.CounterPos(
+ rowMapping[cp.row()], cp.col(), cp.length(), mapped));
}
+ }
- applyResult(ctx, resultLines, null, null, null, positions, Collections.emptySet());
- } else {
- TerminalImageCapabilities caps = TerminalImageCapabilities.detect();
- if (caps.supportsNativeImages()) {
- RouteDiagramRenderer.DiagramColors colors
- = RouteDiagramRenderer.DiagramColors.parse("transparent");
- java.awt.image.BufferedImage image = TopologyImageRenderer.renderImage(
- result, colors, TopologyLayoutEngine.DEFAULT_FONT_SIZE,
- TopologyLayoutEngine.DEFAULT_NODE_WIDTH, metrics, false);
- ImageData full = ImageData.fromBufferedImage(image);
- ImageData resized = full.resize(full.width() / 2, full.height() / 2);
- ImageProtocol proto = caps.bestProtocol();
- applyResult(ctx, Collections.emptyList(), resized, resized, proto);
- } else {
- applyResult(ctx, List.of(
- "(Terminal does not support image rendering)",
- "(Press Shift+D for text diagram)"), null, null, null);
+ List<TopologyAsciiRenderer.NodeBox> boxes = new ArrayList<>();
+ for (TopologyAsciiRenderer.NodeBox nb : renderer.getNodeBoxes()) {
+ int mappedStart = (nb.startRow() >= 0 && nb.startRow() < rowMapping.length)
+ ? rowMapping[nb.startRow()] : -1;
+ int mappedEnd = (nb.endRow() >= 0 && nb.endRow() < rowMapping.length)
+ ? rowMapping[nb.endRow()] : -1;
+ if (mappedStart >= 0 && mappedEnd >= 0) {
+ boxes.add(new TopologyAsciiRenderer.NodeBox(
+ nb.routeId(), mappedStart, mappedEnd, nb.startCol(), nb.endCol(), nb.layer()));
}
}
+
+ applyResult(ctx, resultLines, positions, Collections.emptySet(), boxes,
+ result.nodes, result.edges);
}
- private JsonObject requestRouteTopology(MonitorContext ctx, String pid, boolean external) {
+ private JsonObject requestRouteTopology(MonitorContext ctx, String pid, boolean external, boolean routes) {
Path outputFile = ctx.getOutputFile(pid);
PathUtils.deleteFile(outputFile);
JsonObject root = new JsonObject();
root.put("action", "route-topology");
root.put("metric", "true");
- if (external) {
- root.put("external", "true");
+ // Always request external endpoints so route diagrams can show dashed borders
+ root.put("external", "true");
+ if (routes) {
+ root.put("routes", "true");
}
Path actionFile = ctx.getActionFile(pid);
@@ -528,98 +1406,65 @@ class DiagramSupport {
}
private void renderRoutes(
- MonitorContext ctx, boolean textMode,
+ MonitorContext ctx,
List<RouteDiagramLayoutEngine.RouteInfo> routes, boolean metrics,
Set<String> highlightNodeIds, RouteDiagramHelper.HighlightStyle hlStyle) {
- if (textMode) {
- RouteDiagramLayoutEngine.NodeLabelMode labelMode = showDescription
- ? RouteDiagramLayoutEngine.NodeLabelMode.DESCRIPTION
- : RouteDiagramLayoutEngine.NodeLabelMode.CODE;
- RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(
- RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE,
- labelMode);
-
- List<String> result = new ArrayList<>();
- List<RouteDiagramAsciiRenderer.CounterPos> positions = new ArrayList<>();
- Set<Integer> titleRows = new HashSet<>();
-
- int currentY = RouteDiagramLayoutEngine.PADDING;
- for (RouteDiagramLayoutEngine.RouteInfo r : routes) {
- if (!result.isEmpty()) {
- result.add("");
- result.add("");
- }
+ RouteDiagramLayoutEngine.NodeLabelMode labelMode = showDescription
+ ? RouteDiagramLayoutEngine.NodeLabelMode.DESCRIPTION
+ : RouteDiagramLayoutEngine.NodeLabelMode.CODE;
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(
+ RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH, RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE,
+ labelMode);
+
+ List<String> result = new ArrayList<>();
+ List<RouteDiagramAsciiRenderer.CounterPos> positions = new ArrayList<>();
+ Set<Integer> titleRows = new HashSet<>();
+
+ int currentY = RouteDiagramLayoutEngine.PADDING;
+ for (RouteDiagramLayoutEngine.RouteInfo r : routes) {
+ if (!result.isEmpty()) {
+ result.add("");
+ result.add("");
+ }
- int titleRow = result.size();
+ int titleRow = result.size();
- RouteDiagramLayoutEngine.LayoutRoute lr = engine.layoutRoute(r, currentY);
- currentY = lr.maxY + RouteDiagramLayoutEngine.V_GAP;
+ RouteDiagramLayoutEngine.LayoutRoute lr = engine.layoutRoute(r, currentY);
+ currentY = lr.maxY + RouteDiagramLayoutEngine.V_GAP;
- RouteDiagramAsciiRenderer asciiRenderer = new RouteDiagramAsciiRenderer(
- RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH * RouteDiagramLayoutEngine.SCALE, true, metrics);
- String ascii;
- if (highlightNodeIds != null && hlStyle != null) {
- ascii = asciiRenderer.renderDiagram(List.of(lr),
- lr.maxY + RouteDiagramLayoutEngine.V_GAP, highlightNodeIds, hlStyle);
- } else {
- ascii = asciiRenderer.renderDiagram(List.of(lr),
- lr.maxY + RouteDiagramLayoutEngine.V_GAP);
- }
- List<RouteDiagramAsciiRenderer.CounterPos> origPositions = asciiRenderer.getCounterPositions();
-
- String[] rawLines = ascii.split("\n", -1);
- int[] rowMapping = new int[rawLines.length];
- int newRow = result.size();
- for (int i = 0; i < rawLines.length; i++) {
- if (!rawLines[i].isEmpty()) {
- rowMapping[i] = newRow++;
- result.add(rawLines[i]);
- } else {
- rowMapping[i] = -1;
- }
- }
- for (RouteDiagramAsciiRenderer.CounterPos cp : origPositions) {
- if (cp.row() >= 0 && cp.row() < rowMapping.length && rowMapping[cp.row()] >= 0) {
- positions.add(new RouteDiagramAsciiRenderer.CounterPos(
- rowMapping[cp.row()], cp.col(), cp.length(), cp.type()));
- }
- }
- titleRows.add(titleRow);
+ RouteDiagramAsciiRenderer asciiRenderer = new RouteDiagramAsciiRenderer(
+ RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH * RouteDiagramLayoutEngine.SCALE, true, metrics);
+ String ascii;
+ if (highlightNodeIds != null && hlStyle != null) {
+ ascii = asciiRenderer.renderDiagram(List.of(lr),
+ lr.maxY + RouteDiagramLayoutEngine.V_GAP, highlightNodeIds, hlStyle);
+ } else {
+ ascii = asciiRenderer.renderDiagram(List.of(lr),
+ lr.maxY + RouteDiagramLayoutEngine.V_GAP);
}
+ List<RouteDiagramAsciiRenderer.CounterPos> origPositions = asciiRenderer.getCounterPositions();
- applyResult(ctx, result, null, null, null, positions, titleRows);
- } else {
- TerminalImageCapabilities caps = TerminalImageCapabilities.detect();
- if (caps.supportsNativeImages()) {
- RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
- List<RouteDiagramLayoutEngine.LayoutRoute> layoutRoutes = new ArrayList<>();
- int totalHeight = 0;
- for (RouteDiagramLayoutEngine.RouteInfo r : routes) {
- RouteDiagramLayoutEngine.LayoutRoute lr = engine.layoutRoute(r, totalHeight);
- layoutRoutes.add(lr);
- totalHeight = lr.maxY;
- }
- RouteDiagramRenderer renderer = new RouteDiagramRenderer(
- engine.getNodeWidth(),
- RouteDiagramLayoutEngine.DEFAULT_FONT_SIZE * RouteDiagramLayoutEngine.SCALE, metrics);
- RouteDiagramRenderer.DiagramColors colors
- = RouteDiagramRenderer.DiagramColors.parse("transparent");
- java.awt.image.BufferedImage image;
- if (highlightNodeIds != null && hlStyle != null) {
- image = renderer.renderDiagram(layoutRoutes, totalHeight, colors, highlightNodeIds, hlStyle);
+ String[] rawLines = ascii.split("\n", -1);
+ int[] rowMapping = new int[rawLines.length];
+ int newRow = result.size();
+ for (int i = 0; i < rawLines.length; i++) {
+ if (!rawLines[i].isEmpty()) {
+ rowMapping[i] = newRow++;
+ result.add(rawLines[i]);
} else {
- image = renderer.renderDiagram(layoutRoutes, totalHeight, colors);
+ rowMapping[i] = -1;
+ }
+ }
+ for (RouteDiagramAsciiRenderer.CounterPos cp : origPositions) {
+ if (cp.row() >= 0 && cp.row() < rowMapping.length && rowMapping[cp.row()] >= 0) {
+ positions.add(new RouteDiagramAsciiRenderer.CounterPos(
+ rowMapping[cp.row()], cp.col(), cp.length(), cp.type()));
}
- ImageData full = ImageData.fromBufferedImage(image);
- ImageData resized = full.resize(full.width() / 2, full.height() / 2);
- ImageProtocol proto = caps.bestProtocol();
- applyResult(ctx, Collections.emptyList(), resized, resized, proto);
- } else {
- applyResult(ctx, List.of(
- "(Terminal does not support image rendering)",
- "(Press Shift+D for text diagram)"), null, null, null);
}
+ titleRows.add(titleRow);
}
+
+ applyResult(ctx, result, positions, titleRows);
}
private JsonObject requestRouteStructure(MonitorContext ctx, String pid) {
@@ -640,44 +1485,57 @@ class DiagramSupport {
return jo;
}
- private void applyResult(
- MonitorContext ctx,
- List<String> resultLines, ImageData resultImageData, ImageData resultFullImageData,
- ImageProtocol resultProtocol) {
- applyResult(ctx, resultLines, resultImageData, resultFullImageData, resultProtocol,
- Collections.emptyList(), Collections.emptySet());
+ private void applyResult(MonitorContext ctx, List<String> resultLines) {
+ applyResult(ctx, resultLines, Collections.emptyList(), Collections.emptySet(),
+ Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
}
private void applyResult(
- MonitorContext ctx,
- List<String> resultLines, ImageData resultImageData, ImageData resultFullImageData,
- ImageProtocol resultProtocol,
+ MonitorContext ctx, List<String> resultLines,
List<RouteDiagramAsciiRenderer.CounterPos> positions, Set<Integer> titleRows) {
+ applyResult(ctx, resultLines, positions, titleRows,
+ Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
+ }
+
+ private void applyResult(
+ MonitorContext ctx, List<String> resultLines,
+ List<RouteDiagramAsciiRenderer.CounterPos> positions, Set<Integer> titleRows,
+ List<TopologyAsciiRenderer.NodeBox> resultNodeBoxes,
+ List<TopologyLayoutNode> resultTopologyNodes,
+ List<TopologyLayoutEdge> resultTopologyEdges) {
if (ctx.runner == null) {
return;
}
ctx.runner.runOnRenderThread(() -> {
- boolean wasShowing = showDiagram;
+ if (!showDiagram) {
+ return;
+ }
lines = resultLines;
counterPositions = positions;
routeTitleRows = titleRows;
- fullImageData = resultFullImageData;
- protocol = resultProtocol;
- if (!wasShowing) {
- imageData = resultImageData;
- scrollY = 0;
- scrollX = 0;
- cropX = -1;
- cropY = -1;
- cropW = -1;
- cropH = -1;
- } else {
- showDiagram = true;
- // invalidate crop cache so next render re-crops at current scroll position
- cropX = -1;
- cropY = -1;
- cropW = -1;
- cropH = -1;
+ topologyNodes = resultTopologyNodes;
+ topologyEdges = resultTopologyEdges;
+
+ // Preserve selection across refreshes by matching routeId
+ String prevSelectedRouteId = getSelectedRouteId();
+ if (prevSelectedRouteId == null && pendingSelectionRouteId != null) {
+ prevSelectedRouteId = pendingSelectionRouteId;
+ }
+ pendingSelectionRouteId = null;
+ nodeBoxes = resultNodeBoxes;
+ if (prevSelectedRouteId != null && !resultNodeBoxes.isEmpty()) {
+ int newIdx = -1;
+ for (int i = 0; i < resultNodeBoxes.size(); i++) {
+ if (prevSelectedRouteId.equals(resultNodeBoxes.get(i).routeId())) {
+ newIdx = i;
+ break;
+ }
+ }
+ selectedNodeIndex = newIdx >= 0 ? newIdx : 0;
+ } else if (!resultNodeBoxes.isEmpty() && selectedNodeIndex < 0) {
+ selectedNodeIndex = findTopLeftNode(resultNodeBoxes);
+ } else if (resultNodeBoxes.isEmpty()) {
+ selectedNodeIndex = -1;
}
});
}
@@ -689,6 +1547,17 @@ class DiagramSupport {
return Line.from(Span.styled(text, Style.EMPTY.fg(Color.WHITE).bold()));
}
+ // Check if this row is within the selected node
+ int selStartCol = -1;
+ int selEndCol = -1;
+ if (selectedNodeIndex >= 0 && selectedNodeIndex < nodeBoxes.size()) {
+ TopologyAsciiRenderer.NodeBox box = nodeBoxes.get(selectedNodeIndex);
+ if (row >= box.startRow() && row <= box.endRow()) {
+ selStartCol = box.startCol() - hScrollX;
+ selEndCol = box.endCol() - hScrollX;
+ }
+ }
+
List<int[]> counterRanges = new ArrayList<>();
for (RouteDiagramAsciiRenderer.CounterPos cp : counterPositions) {
if (cp.row() == row) {
@@ -735,9 +1604,46 @@ class DiagramSupport {
}
idx = labelEnd;
}
+
+ // Apply selection highlighting as background overlay
+ if (selStartCol >= 0 && selEndCol >= 0) {
+ spans = applySelectionHighlight(spans, selStartCol, selEndCol);
+ }
+
return Line.from(spans);
}
+ private static List<Span> applySelectionHighlight(List<Span> spans, int startCol, int endCol) {
+ List<Span> result = new ArrayList<>();
+ int pos = 0;
+ for (Span span : spans) {
+ String content = span.content();
+ int spanStart = pos;
+ int spanEnd = pos + content.length();
+ pos = spanEnd;
+
+ if (spanEnd <= startCol || spanStart >= endCol + 1) {
+ result.add(span);
+ continue;
+ }
+
+ // Split span at selection boundaries
+ if (spanStart < startCol) {
+ result.add(Span.styled(content.substring(0, startCol - spanStart), span.style()));
+ }
+ int hlStart = Math.max(0, startCol - spanStart);
+ int hlEnd = Math.min(content.length(), endCol + 1 - spanStart);
+ if (hlStart < hlEnd) {
+ Style hlStyle = span.style().bg(Color.DARK_GRAY);
+ result.add(Span.styled(content.substring(hlStart, hlEnd), hlStyle));
+ }
+ if (spanStart + content.length() > endCol + 1) {
+ result.add(Span.styled(content.substring(endCol + 1 - spanStart), span.style()));
+ }
+ }
+ return result;
+ }
+
private static void addStyledSegment(
List<Span> spans, String text, int from, int to, List<int[]> counterRanges, Color defaultColor) {
int pos = from;
@@ -815,4 +1721,42 @@ class DiagramSupport {
}
}
}
+
+ private static void normalizeTopologyLayoutY(TopologyLayoutResult result) {
+ if (result.nodes.isEmpty()) {
+ return;
+ }
+ int minY = Integer.MAX_VALUE;
+ for (TopologyLayoutNode node : result.nodes) {
+ minY = Math.min(minY, node.y);
+ }
+ if (minY <= 0) {
+ return;
+ }
+ int shift = minY - 40;
+ for (TopologyLayoutNode node : result.nodes) {
+ node.y -= shift;
+ }
+ }
+
+ private static void normalizeRouteLayoutY(RouteDiagramLayoutEngine.LayoutRoute lr) {
+ int minY = Integer.MAX_VALUE;
+ for (RouteDiagramLayoutEngine.LayoutNode ln : lr.nodes) {
+ if (!"route".equals(ln.type)) {
+ minY = Math.min(minY, ln.y);
+ }
+ }
+ if (minY <= 0 || minY == Integer.MAX_VALUE) {
+ return;
+ }
+ int shift = minY - 40;
+ for (RouteDiagramLayoutEngine.LayoutNode ln : lr.nodes) {
+ ln.y -= shift;
+ if (ln.connectFromMerge) {
+ ln.mergeY -= shift;
+ }
+ }
+ lr.labelY -= shift;
+ lr.maxY -= shift;
+ }
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
index 034485a3729f..d6d5c984536b 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
@@ -16,9 +16,15 @@
*/
package org.apache.camel.dsl.jbang.core.commands.tui;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
import java.util.List;
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
import dev.tamboui.style.Style;
import dev.tamboui.terminal.Frame;
import dev.tamboui.text.Line;
@@ -28,6 +34,7 @@ import dev.tamboui.tui.event.KeyEvent;
import dev.tamboui.widgets.block.Block;
import dev.tamboui.widgets.block.BorderType;
import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.util.TimeUtils;
import org.apache.camel.util.json.JsonObject;
import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*;
@@ -36,10 +43,12 @@ class DiagramTab implements MonitorTab {
private final MonitorContext ctx;
private final DiagramSupport diagram = new DiagramSupport();
+ private final SourceViewer sourceViewer = new SourceViewer();
private boolean diagramMetrics = true;
private boolean showExternal;
private boolean topologyMode = true;
private String drillDownRouteId;
+ private final Deque<String> routeNavigationStack = new ArrayDeque<>();
DiagramTab(MonitorContext ctx) {
this.ctx = ctx;
@@ -51,6 +60,103 @@ class DiagramTab implements MonitorTab {
@Override
public boolean handleKeyEvent(KeyEvent ke) {
+ // Source view scrolling (takes priority when active)
+ if (sourceViewer.handleKeyEvent(ke)) {
+ return true;
+ }
+
+ // Source viewer toggle
+ if (!topologyMode && diagram.isShowDiagram() && ke.isChar('c')) {
+ loadSourceForSelectedNode();
+ return true;
+ }
+
+ // Node selection navigation in topology mode
+ if (topologyMode && diagram.isShowDiagram() && diagram.hasDiagramData()
+ && !diagram.getNodeBoxes().isEmpty()) {
+ if (ke.isUp()) {
+ diagram.selectNodeUp();
+ diagram.scrollToSelectedNode();
+ return true;
+ }
+ if (ke.isDown()) {
+ diagram.selectNodeDown();
+ diagram.scrollToSelectedNode();
+ return true;
+ }
+ if (ke.isLeft()) {
+ diagram.selectNodeLeft();
+ diagram.scrollToSelectedNode();
+ return true;
+ }
+ if (ke.isRight()) {
+ diagram.selectNodeRight();
+ diagram.scrollToSelectedNode();
+ return true;
+ }
+ if (ke.isHome()) {
+ diagram.selectFirstNode();
+ diagram.scrollToSelectedNode();
+ return true;
+ }
+ if (ke.isEnd()) {
+ diagram.selectLastNode();
+ diagram.scrollToSelectedNode();
+ return true;
+ }
+ }
+
+ // EIP node navigation in route drill-down mode
+ if (!topologyMode && diagram.isShowDiagram() && !diagram.getEipNodeBoxes().isEmpty()) {
+ if (ke.isUp()) {
+ diagram.selectEipNodeUp();
+ diagram.scrollToSelectedEipNode();
+ return true;
+ }
+ if (ke.isDown()) {
+ diagram.selectEipNodeDown();
+ diagram.scrollToSelectedEipNode();
+ return true;
+ }
+ if (ke.isLeft()) {
+ diagram.selectEipNodeLeft();
+ diagram.scrollToSelectedEipNode();
+ return true;
+ }
+ if (ke.isRight()) {
+ diagram.selectEipNodeRight();
+ diagram.scrollToSelectedEipNode();
+ return true;
+ }
+ if (ke.isHome()) {
+ diagram.selectFirstEipNode();
+ diagram.scrollToSelectedEipNode();
+ return true;
+ }
+ if (ke.isEnd()) {
+ diagram.selectLastEipNode();
+ diagram.scrollToSelectedEipNode();
+ return true;
+ }
+ }
+
+ // Jump back to topology from any depth
+ if (!topologyMode && diagram.isShowDiagram() && ke.isChar('t')) {
+ routeNavigationStack.clear();
+ diagram.setPendingSelectionRouteId(drillDownRouteId);
+ drillDownRouteId = null;
+ topologyMode = true;
+ diagram.setTopologyMode(true);
+ diagram.setSelectedEipNodeIndex(-1);
+ diagram.resetScroll();
+ if (diagram.hasNativeLayout()) {
+ return true;
+ }
+ diagram.endLoad();
+ reloadDiagram();
+ return true;
+ }
+
if (diagram.handleScrollKeys(ke)) {
return true;
}
@@ -79,9 +185,50 @@ class DiagramTab implements MonitorTab {
return true;
}
- // Drill down into route diagram (Enter)
+ // Jump to linked route from EIP node (Enter in route mode)
+ if (!topologyMode && ke.isConfirm() && !diagram.getEipNodeBoxes().isEmpty()) {
+ String linkedRouteId = diagram.findLinkedRouteId(drillDownRouteId);
+ if (linkedRouteId != null && diagram.getRouteLayout(linkedRouteId) != null) {
+ if (linkedRouteId.equals(drillDownRouteId)) {
+ return true;
+ }
+ // Collapse breadcrumb if navigating back to a route already in the stack
+ if (routeNavigationStack.contains(linkedRouteId)) {
+ while (!routeNavigationStack.isEmpty() && !linkedRouteId.equals(routeNavigationStack.peek())) {
+ routeNavigationStack.pop();
+ }
+ routeNavigationStack.pop();
+ } else {
+ routeNavigationStack.push(drillDownRouteId);
+ }
+ drillDownRouteId = linkedRouteId;
+ diagram.selectFromNode(linkedRouteId);
+ diagram.resetScroll();
+ return true;
+ }
+ return true;
+ }
+
+ // Drill down into route diagram (Enter from topology)
if (topologyMode && ke.isConfirm()) {
- // For now, drill-down is a future feature
+ String selectedRouteId = diagram.getSelectedRouteId();
+ if (selectedRouteId != null) {
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ if (info != null && info.routes.stream().anyMatch(r -> selectedRouteId.equals(r.routeId))) {
+ routeNavigationStack.clear();
+ drillDownRouteId = selectedRouteId;
+ topologyMode = false;
+ diagram.setTopologyMode(false);
+ diagram.selectFromNode(selectedRouteId);
+ diagram.resetScroll();
+ diagram.endLoad();
+ // Use cached route layout if available (no IPC needed)
+ if (diagram.getRouteLayout(selectedRouteId) != null) {
+ return true;
+ }
+ reloadDiagram();
+ }
+ }
return true;
}
@@ -90,14 +237,33 @@ class DiagramTab implements MonitorTab {
@Override
public boolean handleEscape() {
+ if (sourceViewer.isVisible()) {
+ sourceViewer.hide();
+ return true;
+ }
if (!topologyMode) {
+ if (!routeNavigationStack.isEmpty()) {
+ // Go back to the previous route in the stack
+ drillDownRouteId = routeNavigationStack.pop();
+ diagram.selectFromNode(drillDownRouteId);
+ diagram.resetScroll();
+ return true;
+ }
+ // Go back to topology
+ diagram.setPendingSelectionRouteId(drillDownRouteId);
topologyMode = true;
diagram.setTopologyMode(true);
+ diagram.setSelectedEipNodeIndex(-1);
+ diagram.resetScroll();
+ // If topology layout is cached, just switch view without IPC
+ if (diagram.hasNativeLayout()) {
+ return true;
+ }
diagram.endLoad();
reloadDiagram();
return true;
}
- return diagram.handleEscape();
+ return false;
}
@Override
@@ -113,7 +279,15 @@ class DiagramTab implements MonitorTab {
@Override
public void onTabSelected() {
if (!diagram.isShowDiagram()) {
- diagram.toggleTextDiagram(this::loadDiagram);
+ if (ctx.selectedPid != null && diagram.hasCachedData(ctx.selectedPid)) {
+ diagram.showCached();
+ } else {
+ // Show diagram immediately so preload results are rendered when they arrive
+ diagram.setShowDiagram(true);
+ if (!diagram.isLoading()) {
+ loadDiagram();
+ }
+ }
}
}
@@ -121,10 +295,17 @@ class DiagramTab implements MonitorTab {
public void onIntegrationChanged() {
topologyMode = true;
drillDownRouteId = null;
+ routeNavigationStack.clear();
diagram.reset();
diagram.setTopologyMode(true);
}
+ void preloadDiagram() {
+ if (ctx.selectedPid != null) {
+ diagram.preload(ctx, ctx.selectedPid);
+ }
+ }
+
@Override
public void render(Frame frame, Rect area) {
IntegrationInfo info = ctx.findSelectedIntegration();
@@ -133,15 +314,52 @@ class DiagramTab implements MonitorTab {
return;
}
+ if (sourceViewer.isVisible()) {
+ sourceViewer.render(frame, area);
+ return;
+ }
+
if (diagram.isShowDiagram() && diagram.hasDiagramData()) {
- String title;
- if (topologyMode) {
- title = " Topology ";
- } else {
- title = " Route [" + drillDownRouteId + "] ";
+ String selectedRouteId = topologyMode ? diagram.getSelectedRouteId() : drillDownRouteId;
+
+ if (topologyMode && diagram.hasNativeLayout()) {
+ Line title;
+ if (info.name != null) {
+ title = Line.from(
+ Span.raw(" Topology ["),
+ Span.styled(info.name, Style.EMPTY.fg(Color.YELLOW).bold()),
+ Span.raw("] "));
+ } else {
+ title = Line.from(Span.raw(" Topology "));
+ }
+ if (selectedRouteId != null && area.width() > 60) {
+ int panelWidth = 30;
+ List<Rect> hChunks = Layout.horizontal()
+ .constraints(Constraint.length(panelWidth), Constraint.fill())
+ .split(area);
+ renderInfoPanel(frame, hChunks.get(0), info, selectedRouteId);
+ diagram.renderNativeDiagram(frame, hChunks.get(1), title, diagramMetrics);
+ } else {
+ diagram.renderNativeDiagram(frame, area, title, diagramMetrics);
+ }
+ return;
+ } else if (!topologyMode && drillDownRouteId != null
+ && diagram.getRouteLayout(drillDownRouteId) != null) {
+ Line title = buildBreadcrumbTitle();
+ var routeLayout = diagram.getRouteLayout(drillDownRouteId);
+ if (area.width() > 60) {
+ int panelWidth = 30;
+ List<Rect> hChunks = Layout.horizontal()
+ .constraints(Constraint.length(panelWidth), Constraint.fill())
+ .split(area);
+ renderEipInfoPanel(frame, hChunks.get(0));
+ diagram.renderNativeRouteDiagram(
+ frame, hChunks.get(1), title, diagramMetrics, drillDownRouteId, routeLayout);
+ } else {
+ diagram.renderNativeRouteDiagram(frame, area, title, diagramMetrics, drillDownRouteId, routeLayout);
+ }
+ return;
}
- diagram.renderDiagram(frame, area, title);
- return;
}
// Show placeholder when no diagram is loaded yet
@@ -156,12 +374,272 @@ class DiagramTab implements MonitorTab {
area);
}
+ private void renderInfoPanel(Frame frame, Rect area, IntegrationInfo info, String routeId) {
+ RouteInfo route = null;
+ for (RouteInfo r : info.routes) {
+ if (routeId.equals(r.routeId)) {
+ route = r;
+ break;
+ }
+ }
+
+ List<Line> lines = new ArrayList<>();
+ if (route != null) {
+ lines.add(Line.from(
+ Span.styled(" Route: ", Style.EMPTY.fg(Color.YELLOW).bold()),
+ Span.styled(route.routeId, Style.EMPTY.fg(Color.WHITE).bold())));
+ lines.add(Line.from(
+ Span.styled(" From: ", Style.EMPTY.dim()),
+ Span.raw(route.from != null ? route.from : "")));
+ String stateLabel = route.state != null ? route.state : "";
+ Style stateStyle = "Started".equals(route.state) ? Style.EMPTY.fg(Color.GREEN) : Style.EMPTY.fg(Color.LIGHT_RED);
+ lines.add(Line.from(
+ Span.styled(" State: ", Style.EMPTY.dim()),
+ Span.styled(stateLabel, stateStyle)));
+
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(
+ Span.styled(" Uptime: ", Style.EMPTY.dim()),
+ Span.raw(route.uptime != null ? route.uptime : "")));
+ lines.add(Line.from(
+ Span.styled(" Throughput: ", Style.EMPTY.dim()),
+ Span.raw(route.throughput != null ? route.throughput : "")));
+ if (route.coverage != null) {
+ lines.add(Line.from(
+ Span.styled(" Coverage: ", Style.EMPTY.dim()),
+ Span.raw(route.coverage)));
+ }
+
+ lines.add(Line.from(Span.raw("")));
+ int w = numWidth(route.total, route.failed, route.inflight);
+ lines.add(Line.from(
+ Span.styled(" Total: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + w + "d", route.total))));
+ Style failStyle = route.failed > 0 ? Style.EMPTY.fg(Color.LIGHT_RED).bold() : Style.EMPTY;
+ lines.add(Line.from(
+ Span.styled(" Failed: ", Style.EMPTY.dim()),
+ Span.styled(String.format("%" + w + "d", route.failed), failStyle)));
+ lines.add(Line.from(
+ Span.styled(" Inflight: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + w + "d", route.inflight))));
+
+ lines.add(Line.from(Span.raw("")));
+ if (route.total > 0) {
+ int tw = numWidth(route.meanTime, route.maxTime, route.minTime);
+ lines.add(Line.from(
+ Span.styled(" Mean: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + tw + "d ms", route.meanTime))));
+ lines.add(Line.from(
+ Span.styled(" Max: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + tw + "d ms", route.maxTime))));
+ lines.add(Line.from(
+ Span.styled(" Min: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + tw + "d ms", route.minTime))));
+ }
+
+ if (route.sinceLastCompleted != null || route.sinceLastFailed != null) {
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(
+ Span.styled(" Since last:", Style.EMPTY.dim())));
+ if (route.sinceLastCompleted != null) {
+ lines.add(Line.from(
+ Span.styled(" success: ", Style.EMPTY.dim()),
+ Span.raw(route.sinceLastCompleted)));
+ }
+ if (route.sinceLastFailed != null) {
+ lines.add(Line.from(
+ Span.styled(" fail: ", Style.EMPTY.dim()),
+ Span.styled(route.sinceLastFailed,
+ Style.EMPTY.fg(Color.LIGHT_RED))));
+ }
+ }
+
+ } else {
+ // External endpoint — show topology node data
+ var topoNode = diagram.getSelectedTopologyNode();
+ if (topoNode != null) {
+ boolean isInbound = "external-in".equals(topoNode.nodeType);
+ lines.add(Line.from(
+ Span.styled(isInbound ? " Inbound" : " Outbound",
+ Style.EMPTY.fg(Color.CYAN).bold())));
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(
+ Span.styled(" URI: ", Style.EMPTY.dim()),
+ Span.raw(topoNode.from != null ? topoNode.from : "")));
+ if (topoNode.description != null && !topoNode.description.isBlank()) {
+ lines.add(Line.from(
+ Span.styled(" Path: ", Style.EMPTY.dim()),
+ Span.raw(topoNode.description)));
+ }
+ String connectedRoute = diagram.getConnectedRouteId(routeId);
+ if (connectedRoute != null) {
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(
+ Span.styled(isInbound ? " To route: " : " From route: ", Style.EMPTY.dim()),
+ Span.styled(connectedRoute, Style.EMPTY.fg(Color.WHITE))));
+ }
+ if (topoNode.exchangesTotal > 0 || topoNode.exchangesFailed > 0) {
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(
+ Span.styled(" Total: ", Style.EMPTY.dim()),
+ Span.raw(String.valueOf(topoNode.exchangesTotal))));
+ if (topoNode.exchangesFailed > 0) {
+ lines.add(Line.from(
+ Span.styled(" Failed: ", Style.EMPTY.dim()),
+ Span.styled(String.valueOf(topoNode.exchangesFailed),
+ Style.EMPTY.fg(Color.LIGHT_RED).bold())));
+ }
+ }
+ } else {
+ lines.add(Line.from(
+ Span.styled(" " + routeId, Style.EMPTY.fg(Color.CYAN).bold())));
+ lines.add(Line.from(
+ Span.styled(" (external endpoint)", Style.EMPTY.dim())));
+ }
+ }
+
+ Paragraph paragraph = Paragraph.builder()
+ .text(Text.from(lines))
+ .block(Block.builder().borderType(BorderType.ROUNDED)
+ .title(" Info ").build())
+ .build();
+ frame.renderWidget(paragraph, area);
+ }
+
+ private void renderEipInfoPanel(Frame frame, Rect area) {
+ List<Line> lines = new ArrayList<>();
+ var selected = diagram.getSelectedEipNodeBox();
+ if (selected != null && selected.layoutNode() != null) {
+ var ln = selected.layoutNode();
+
+ String typeLabel = ln.type != null ? ln.type : "unknown";
+ Color eipColor = org.apache.camel.dsl.jbang.core.commands.tui.diagram.DiagramColors.getEipColor(typeLabel);
+ lines.add(Line.from(
+ Span.styled(" [" + typeLabel + "]", Style.EMPTY.fg(eipColor).bold())));
+
+ String label = String.join("", ln.wrappedLines);
+ if (!label.isBlank()) {
+ lines.add(Line.from(
+ Span.styled(" ", Style.EMPTY.dim()),
+ Span.raw(label)));
+ }
+
+ if (ln.id != null) {
+ lines.add(Line.from(
+ Span.styled(" ID: ", Style.EMPTY.dim()),
+ Span.raw(ln.id)));
+ }
+
+ lines.add(Line.from(Span.raw("")));
+ String linkedRoute = diagram.findLinkedRouteId(drillDownRouteId);
+ if (linkedRoute != null && diagram.getRouteLayout(linkedRoute) != null) {
+ lines.add(Line.from(
+ Span.styled(" ↵ ", Style.EMPTY.fg(Color.YELLOW).bold()),
+ Span.styled(linkedRoute, Style.EMPTY.fg(Color.WHITE))));
+ } else if (ln.treeNode != null && ln.treeNode.info.remote) {
+ String arrow = "from".equals(ln.type) ? " external → " : " → external";
+ lines.add(Line.from(
+ Span.styled(arrow, Style.EMPTY.fg(Color.DARK_GRAY))));
+ } else {
+ lines.add(Line.from(Span.raw("")));
+ }
+
+ if (ln.treeNode != null && ln.treeNode.info.stat != null) {
+ var stat = ln.treeNode.info.stat;
+ lines.add(Line.from(Span.raw("")));
+ int w = numWidth(stat.exchangesTotal, stat.exchangesFailed, stat.exchangesInflight);
+ lines.add(Line.from(
+ Span.styled(" Total: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + w + "d", stat.exchangesTotal))));
+ Style failStyle = stat.exchangesFailed > 0
+ ? Style.EMPTY.fg(Color.LIGHT_RED).bold() : Style.EMPTY;
+ lines.add(Line.from(
+ Span.styled(" Failed: ", Style.EMPTY.dim()),
+ Span.styled(String.format("%" + w + "d", stat.exchangesFailed), failStyle)));
+ lines.add(Line.from(
+ Span.styled(" Inflight: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + w + "d", stat.exchangesInflight))));
+
+ if (stat.exchangesTotal > 0) {
+ lines.add(Line.from(Span.raw("")));
+ int tw = numWidth(stat.meanProcessingTime, stat.maxProcessingTime,
+ stat.minProcessingTime, stat.lastProcessingTime);
+ lines.add(Line.from(
+ Span.styled(" Mean: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + tw + "d ms", stat.meanProcessingTime))));
+ lines.add(Line.from(
+ Span.styled(" Max: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + tw + "d ms", stat.maxProcessingTime))));
+ lines.add(Line.from(
+ Span.styled(" Min: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + tw + "d ms", stat.minProcessingTime))));
+ lines.add(Line.from(
+ Span.styled(" Last: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + tw + "d ms", stat.lastProcessingTime))));
+
+ if (stat.lastCompletedExchangeTimestamp > 0 || stat.lastFailedExchangeTimestamp > 0) {
+ long now = System.currentTimeMillis();
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(
+ Span.styled(" Since last:", Style.EMPTY.dim())));
+ if (stat.lastCompletedExchangeTimestamp > 0) {
+ long ago = now - stat.lastCompletedExchangeTimestamp;
+ lines.add(Line.from(
+ Span.styled(" success: ", Style.EMPTY.dim()),
+ Span.raw(TimeUtils.printDuration(ago, false))));
+ }
+ if (stat.lastFailedExchangeTimestamp > 0) {
+ long ago = now - stat.lastFailedExchangeTimestamp;
+ lines.add(Line.from(
+ Span.styled(" fail: ", Style.EMPTY.dim()),
+ Span.styled(TimeUtils.printDuration(ago, false),
+ Style.EMPTY.fg(Color.LIGHT_RED))));
+ }
+ }
+ }
+ }
+ } else {
+ lines.add(Line.from(Span.styled(" (no node selected)", Style.EMPTY.dim())));
+ }
+
+ Paragraph paragraph = Paragraph.builder()
+ .text(Text.from(lines))
+ .block(Block.builder().borderType(BorderType.ROUNDED)
+ .title(" Info ").build())
+ .build();
+ frame.renderWidget(paragraph, area);
+ }
+
@Override
public void renderFooter(List<Span> spans) {
+ if (sourceViewer.isVisible()) {
+ sourceViewer.renderFooter(spans);
+ return;
+ }
if (diagram.isShowDiagram()) {
- diagram.renderFooterHints(spans);
+ if (!topologyMode && !diagram.getEipNodeBoxes().isEmpty()) {
+ hint(spans, "Esc", "back");
+ hint(spans, "t", "topology");
+ hint(spans, "↑↓←→", "navigate");
+ hint(spans, "PgUp/PgDn", "page");
+ hint(spans, "c", "source");
+ } else if (!topologyMode) {
+ hint(spans, "Esc", "back");
+ hint(spans, "t", "topology");
+ hint(spans, "↑↓←→", "scroll");
+ hint(spans, "PgUp/PgDn", "page");
+ } else if (!diagram.getNodeBoxes().isEmpty()) {
+ hint(spans, "Esc", "close");
+ hint(spans, "↑↓←→", "navigate");
+ hint(spans, "Enter", "drill-down");
+ hint(spans, "PgUp/PgDn", "page");
+ } else {
+ diagram.renderFooterHints(spans);
+ }
hint(spans, "m", "metrics" + (diagramMetrics ? " [on]" : " [off]"));
- hint(spans, "e", "external" + (showExternal ? " [on]" : " [off]"));
+ if (topologyMode) {
+ hint(spans, "e", "external" + (showExternal ? " [on]" : " [off]"));
+ }
hint(spans, "n", "description" + (diagram.isShowDescription() ? " [on]" : " [off]"));
}
}
@@ -198,13 +676,8 @@ class DiagramTab implements MonitorTab {
ctx.runner.scheduler().execute(() -> {
try {
- if (topologyMode) {
- diagram.setTopologyMode(true);
- diagram.loadTopologyDiagramInBackground(ctx, pid, true, showMetrics, external);
- } else {
- diagram.setTopologyMode(false);
- diagram.loadRouteDiagramInBackground(ctx, pid, true, drillDownRouteId, showMetrics);
- }
+ diagram.setTopologyMode(topologyMode);
+ diagram.loadAllDiagramsInBackground(ctx, pid, showMetrics, external);
} finally {
diagram.endLoad();
}
@@ -266,15 +739,49 @@ class DiagramTab implements MonitorTab {
External system boxes are drawn with dashed borders to distinguish
them from route boxes. Dashed edges connect routes to external systems.
+ ## Navigation
+
+ In the topology view, use arrow keys to select route boxes:
+ - `↑↓` moves between layers (upstream/downstream routes)
+ - `←→` moves between routes in the same layer
+
+ When a route is selected, an **Info panel** appears on the left
+ showing key metrics: state, uptime, throughput, exchange counts,
+ and processing times.
+
+ Press `Enter` on a selected route to **drill down** into its
+ internal EIP structure (the route diagram). Press `Esc` to
+ return to the topology view.
+
+ ## Route Diagram
+
+ In the route diagram, each EIP node shows its type tag (colored)
+ and endpoint URI or description. Nodes that connect to other routes
+ display a `↵` indicator — press `Enter` to jump directly to the
+ linked route's diagram.
+
+ Navigation history is maintained as a stack: pressing `Esc` goes
+ back to the previous route, and eventually back to the topology view.
+
## Keys
+ **Topology view:**
+ - `↑↓←→` — navigate between route boxes
+ - `Enter` — drill down into selected route
+ - `Esc` — close diagram
+
+ **Route diagram:**
+ - `↑↓←→` — navigate between EIP nodes
+ - `Enter` — jump to linked route (when `↵` indicator shown)
+ - `Esc` — go back (previous route or topology)
+ - `t` — jump back to topology view
+
+ **Common:**
- `m` — toggle metrics on/off (default: on)
- - `e` — toggle external systems on/off (default: off)
- - `n` — toggle description labels on/off (default: off)
- - `↑↓←→` — scroll diagram
+ - `e` — toggle external systems on/off (topology only)
+ - `n` — toggle description labels on/off
- `PgUp/PgDn` — page scroll
- `Home/End` — top/end
- - `Esc` — close diagram
""";
}
@@ -290,4 +797,42 @@ class DiagramTab implements MonitorTab {
result.put("lines", lines.size());
return result;
}
+
+ private Line buildBreadcrumbTitle() {
+ Style nameStyle = Style.EMPTY.fg(Color.YELLOW).bold();
+ List<Span> spans = new ArrayList<>();
+ spans.add(Span.raw(" Route ["));
+ if (routeNavigationStack.isEmpty()) {
+ spans.add(Span.styled(drillDownRouteId, nameStyle));
+ } else {
+ for (var it = routeNavigationStack.descendingIterator(); it.hasNext();) {
+ spans.add(Span.styled(it.next(), nameStyle));
+ spans.add(Span.raw(" → "));
+ }
+ spans.add(Span.styled(drillDownRouteId, nameStyle));
+ }
+ spans.add(Span.raw("] "));
+ return Line.from(spans);
+ }
+
+ private void loadSourceForSelectedNode() {
+ if (drillDownRouteId == null) {
+ return;
+ }
+ int targetLine = 0;
+ var selected = diagram.getSelectedEipNodeBox();
+ if (selected != null && selected.layoutNode() != null
+ && selected.layoutNode().treeNode != null) {
+ targetLine = selected.layoutNode().treeNode.info.line;
+ }
+ sourceViewer.loadSource(ctx, drillDownRouteId, targetLine);
+ }
+
+ private static int numWidth(long... values) {
+ long max = 0;
+ for (long v : values) {
+ max = Math.max(max, Math.abs(v));
+ }
+ return Math.max(1, String.valueOf(max).length());
+ }
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
index b6acc9c6ff05..d2fac7eac9e0 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
@@ -83,12 +83,8 @@ class ErrorsTab implements MonitorTab {
return true;
}
- if (ke.isChar('d')) {
- diagram.toggleImageDiagram(this::loadDiagramForSelectedError);
- return true;
- }
- if (ke.isChar('D')) {
- diagram.toggleTextDiagram(this::loadDiagramForSelectedError);
+ if (ke.isCharIgnoreCase('d')) {
+ diagram.toggleDiagram(this::loadDiagramForSelectedError);
return true;
}
@@ -436,7 +432,6 @@ class ErrorsTab implements MonitorTab {
}
String pid = ctx.selectedPid;
- boolean textMode = diagram.isDiagramTextMode();
String[] messageHistory = selectedError.messageHistory;
diagram.setLoadingPlaceholder();
@@ -444,7 +439,7 @@ class ErrorsTab implements MonitorTab {
ctx.runner.scheduler().execute(() -> {
try {
diagram.loadHighlightedDiagramInBackground(
- ctx, pid, textMode, messageHistory, RouteDiagramHelper.HighlightStyle.FAIL);
+ ctx, pid, messageHistory, RouteDiagramHelper.HighlightStyle.FAIL);
} finally {
diagram.endLoad();
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
index 0a3053303ac2..9952e4b02dd8 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
@@ -111,7 +111,7 @@ class HistoryTab implements MonitorTab {
return true;
}
if (ke.isCharIgnoreCase('d')) {
- diagram.toggleTextDiagram(this::loadDiagramForCurrentView);
+ diagram.toggleDiagram(this::loadDiagramForCurrentView);
return true;
}
@@ -495,7 +495,6 @@ class HistoryTab implements MonitorTab {
}
String pid = ctx.selectedPid;
- boolean textMode = diagram.isDiagramTextMode();
RouteDiagramHelper.HighlightStyle style = failed
? RouteDiagramHelper.HighlightStyle.FAIL
: RouteDiagramHelper.HighlightStyle.SUCCESS;
@@ -504,7 +503,7 @@ class HistoryTab implements MonitorTab {
ctx.runner.scheduler().execute(() -> {
try {
- diagram.loadHighlightedDiagramInBackground(ctx, pid, textMode, messageHistory, style);
+ diagram.loadHighlightedDiagramInBackground(ctx, pid, messageHistory, style);
} finally {
diagram.endLoad();
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
index 0a1c1d437d97..f82c9b442c93 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
@@ -59,20 +59,22 @@ class MonitorContext {
}
IntegrationInfo findSelectedIntegration() {
- if (selectedPid == null) {
+ String pid = selectedPid;
+ if (pid == null) {
return null;
}
return data.get().stream()
- .filter(i -> selectedPid.equals(i.pid) && !i.vanishing)
+ .filter(i -> pid.equals(i.pid) && !i.vanishing)
.findFirst().orElse(null);
}
InfraInfo findSelectedInfra() {
- if (selectedPid == null) {
+ String pid = selectedPid;
+ if (pid == null) {
return null;
}
return infraData.get().stream()
- .filter(i -> selectedPid.equals(i.pid) && !i.vanishing)
+ .filter(i -> pid.equals(i.pid) && !i.vanishing)
.findFirst().orElse(null);
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
index a6411a3c701b..d5d09d4d3a04 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
@@ -20,6 +20,7 @@ import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import dev.tamboui.layout.Constraint;
import dev.tamboui.layout.Layout;
@@ -63,6 +64,7 @@ class OverviewTab implements MonitorTab {
private final Map<String, LinkedList<Long>> throughputHistory;
private final Map<String, LinkedList<Long>> failedHistory;
private final Map<String, LoadAvg> cpuLoadAvg;
+ private final Set<String> stoppingPids;
private final Runnable onPidChanged;
final TableState tableState = new TableState();
@@ -78,11 +80,13 @@ class OverviewTab implements MonitorTab {
Map<String, LinkedList<Long>> throughputHistory,
Map<String, LinkedList<Long>> failedHistory,
Map<String, LoadAvg> cpuLoadAvg,
+ Set<String> stoppingPids,
Runnable onPidChanged) {
this.ctx = ctx;
this.throughputHistory = throughputHistory;
this.failedHistory = failedHistory;
this.cpuLoadAvg = cpuLoadAvg;
+ this.stoppingPids = stoppingPids;
this.onPidChanged = onPidChanged;
}
@@ -193,11 +197,16 @@ class OverviewTab implements MonitorTab {
Cell.from(Span.styled("", dimStyle))));
} else {
String stateText = extractState(info.state);
- if ("Running".equals(stateText) && info.routeStarted == 0 && info.routeTotal > 0) {
+ if (stoppingPids.contains(info.pid) || "Terminating".equals(stateText)) {
+ stateText = "Stopping";
+ } else if ("Terminated".equals(stateText)) {
+ stateText = "Stopped";
+ } else if ("Running".equals(stateText) && info.routeStarted == 0 && info.routeTotal > 0) {
stateText = "Stopped";
}
Style statusStyle = switch (stateText) {
case "Started", "Running" -> Style.EMPTY.fg(Color.GREEN);
+ case "Stopping" -> Style.EMPTY.fg(Color.YELLOW);
case "Stopped" -> Style.EMPTY.fg(Color.LIGHT_RED);
default -> Style.EMPTY.fg(Color.YELLOW);
};
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteInfo.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteInfo.java
index 672dbd6447a5..8f309088d39a 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteInfo.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteInfo.java
@@ -21,6 +21,7 @@ import java.util.List;
class RouteInfo {
String routeId;
+ String description;
String group;
String from;
String state;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
index 8fcf67c03807..12221cf9bd6d 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
@@ -17,12 +17,11 @@
package org.apache.camel.dsl.jbang.core.commands.tui;
import java.nio.file.Path;
+import java.util.ArrayDeque;
import java.util.ArrayList;
-import java.util.Collections;
+import java.util.Deque;
import java.util.List;
-import java.util.concurrent.atomic.AtomicBoolean;
-import dev.tamboui.image.ImageData;
import dev.tamboui.layout.Constraint;
import dev.tamboui.layout.Layout;
import dev.tamboui.layout.Rect;
@@ -32,29 +31,24 @@ import dev.tamboui.terminal.Frame;
import dev.tamboui.text.Line;
import dev.tamboui.text.Span;
import dev.tamboui.text.Text;
-import dev.tamboui.tui.event.KeyCode;
import dev.tamboui.tui.event.KeyEvent;
import dev.tamboui.widgets.block.Block;
import dev.tamboui.widgets.block.BorderType;
import dev.tamboui.widgets.paragraph.Paragraph;
-import dev.tamboui.widgets.scrollbar.Scrollbar;
-import dev.tamboui.widgets.scrollbar.ScrollbarState;
import dev.tamboui.widgets.table.Cell;
import dev.tamboui.widgets.table.Row;
import dev.tamboui.widgets.table.Table;
import dev.tamboui.widgets.table.TableState;
import org.apache.camel.dsl.jbang.core.common.PathUtils;
-import org.apache.camel.support.LoggerHelper;
-import org.apache.camel.util.FileUtil;
+import org.apache.camel.util.TimeUtils;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
-import org.apache.camel.util.json.Jsoner;
import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*;
class RoutesTab implements MonitorTab {
- private static final String[] ROUTE_SORT_COLUMNS = { "name", "group", "from", "status", "total", "failed" };
+ private static final String[] ROUTE_SORT_COLUMNS = { "name", "from", "status", "total", "failed" };
private static final String[] ROUTE_TOP_SORT_COLUMNS = { "mean", "max", "min", "last", "delta" };
private final MonitorContext ctx;
@@ -71,24 +65,16 @@ class RoutesTab implements MonitorTab {
// Table states
private final TableState routeTableState = new TableState();
private final TableState processorTableState = new TableState();
- private final TableState routeHeaderTableState = new TableState();
// Diagram support (shared rendering/loading logic)
private final DiagramSupport diagram = new DiagramSupport();
- private boolean diagramAllRoutes;
+ private final SourceViewer sourceViewer = new SourceViewer();
private boolean diagramMetrics = true;
- private String diagramRouteId;
-
- // Source viewer state
- private boolean showSource;
- private List<String> sourceLines = Collections.emptyList();
- private String sourceTitle;
- private SyntaxHighlighter.Language sourceLanguage = SyntaxHighlighter.Language.PLAIN;
- private int sourceScroll;
- private int sourceScrollX;
- private final ScrollbarState sourceVScrollState = new ScrollbarState();
- private final ScrollbarState sourceHScrollState = new ScrollbarState();
- private final AtomicBoolean sourceLoading = new AtomicBoolean(false);
+ private boolean showDescription;
+ private boolean showExternal;
+ private boolean topologyMode = true;
+ private String drillDownRouteId;
+ private final Deque<String> routeNavigationStack = new ArrayDeque<>();
RoutesTab(MonitorContext ctx) {
this.ctx = ctx;
@@ -102,78 +88,189 @@ class RoutesTab implements MonitorTab {
return diagram.isShowDiagram();
}
- boolean isDiagramTextMode() {
- return diagram.isDiagramTextMode();
- }
-
boolean isDiagramMetrics() {
return diagramMetrics;
}
- boolean isDiagramAllRoutes() {
- return diagramAllRoutes;
- }
-
boolean isShowSource() {
- return showSource;
- }
-
- ImageData getDiagramFullImageData() {
- return diagram.getFullImageData();
- }
-
- boolean hasImageDiagram() {
- return diagram.getFullImageData() != null;
+ return sourceViewer.isVisible();
}
@Override
public boolean handleKeyEvent(KeyEvent ke) {
- // Source view scrolling
- if (showSource) {
- if (ke.isChar('c') || ke.isCancel()) {
- showSource = false;
+ // Source view scrolling (takes priority when active)
+ if (sourceViewer.handleKeyEvent(ke)) {
+ return true;
+ }
+
+ // Source viewer toggle (drill-down mode)
+ if (!topologyMode && diagram.isShowDiagram() && ke.isChar('c')) {
+ loadSourceForSelectedNode();
+ return true;
+ }
+
+ // Topology node navigation
+ if (diagram.isShowDiagram() && topologyMode && diagram.hasDiagramData()
+ && !diagram.getNodeBoxes().isEmpty()) {
+ if (ke.isUp()) {
+ diagram.selectNodeUp();
+ diagram.scrollToSelectedNode();
return true;
}
+ if (ke.isDown()) {
+ diagram.selectNodeDown();
+ diagram.scrollToSelectedNode();
+ return true;
+ }
+ if (ke.isLeft()) {
+ diagram.selectNodeLeft();
+ diagram.scrollToSelectedNode();
+ return true;
+ }
+ if (ke.isRight()) {
+ diagram.selectNodeRight();
+ diagram.scrollToSelectedNode();
+ return true;
+ }
+ if (ke.isHome()) {
+ diagram.selectFirstNode();
+ diagram.scrollToSelectedNode();
+ return true;
+ }
+ if (ke.isEnd()) {
+ diagram.selectLastNode();
+ diagram.scrollToSelectedNode();
+ return true;
+ }
+ }
+
+ // EIP node navigation in route drill-down mode
+ if (diagram.isShowDiagram() && !topologyMode && !diagram.getEipNodeBoxes().isEmpty()) {
if (ke.isUp()) {
- sourceScroll = Math.max(0, sourceScroll - 1);
- } else if (ke.isDown()) {
- sourceScroll++;
- } else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
- sourceScroll = Math.max(0, sourceScroll - 20);
- } else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
- sourceScroll += 20;
- } else if (ke.isLeft()) {
- sourceScrollX = Math.max(0, sourceScrollX - 1);
- } else if (ke.isRight()) {
- sourceScrollX++;
- } else if (ke.isHome()) {
- sourceScroll = 0;
- sourceScrollX = 0;
- } else if (ke.isEnd()) {
- sourceScroll = Integer.MAX_VALUE;
- } else {
- return false;
+ diagram.selectEipNodeUp();
+ diagram.scrollToSelectedEipNode();
+ return true;
+ }
+ if (ke.isDown()) {
+ diagram.selectEipNodeDown();
+ diagram.scrollToSelectedEipNode();
+ return true;
+ }
+ if (ke.isLeft()) {
+ diagram.selectEipNodeLeft();
+ diagram.scrollToSelectedEipNode();
+ return true;
+ }
+ if (ke.isRight()) {
+ diagram.selectEipNodeRight();
+ diagram.scrollToSelectedEipNode();
+ return true;
+ }
+ if (ke.isHome()) {
+ diagram.selectFirstEipNode();
+ diagram.scrollToSelectedEipNode();
+ return true;
+ }
+ if (ke.isEnd()) {
+ diagram.selectLastEipNode();
+ diagram.scrollToSelectedEipNode();
+ return true;
}
+ }
+
+ // Jump back to topology from drill-down
+ if (diagram.isShowDiagram() && !topologyMode && ke.isChar('t')) {
+ routeNavigationStack.clear();
+ diagram.setPendingSelectionRouteId(drillDownRouteId);
+ drillDownRouteId = null;
+ topologyMode = true;
+ diagram.setTopologyMode(true);
+ diagram.setSelectedEipNodeIndex(-1);
+ diagram.resetScroll();
+ if (diagram.hasNativeLayout()) {
+ return true;
+ }
+ diagram.endLoad();
+ reloadDiagram();
return true;
}
- // Diagram scrolling
+ // Diagram scrolling (PgUp/PgDn etc)
if (diagram.handleScrollKeys(ke)) {
return true;
}
- // Diagram-specific controls (only when diagram is showing)
- if (diagram.isShowDiagram()) {
- if (ke.isCharIgnoreCase('m')) {
- diagramMetrics = !diagramMetrics;
- diagram.endLoad();
- reloadDiagramQuietly();
+ // Toggle metrics (diagram mode)
+ if (diagram.isShowDiagram() && ke.isCharIgnoreCase('m')) {
+ diagramMetrics = !diagramMetrics;
+ diagram.endLoad();
+ reloadDiagram();
+ return true;
+ }
+
+ // Toggle external systems (topology mode only)
+ if (diagram.isShowDiagram() && topologyMode && ke.isCharIgnoreCase('e')) {
+ showExternal = !showExternal;
+ diagram.endLoad();
+ reloadDiagram();
+ return true;
+ }
+
+ // Toggle description (diagram mode)
+ if (diagram.isShowDiagram() && ke.isCharIgnoreCase('n')) {
+ diagram.setShowDescription(!diagram.isShowDescription());
+ diagram.endLoad();
+ reloadDiagram();
+ return true;
+ }
+
+ // Jump to linked route from EIP node (Enter in route mode)
+ if (diagram.isShowDiagram() && !topologyMode && ke.isConfirm() && !diagram.getEipNodeBoxes().isEmpty()) {
+ String linkedRouteId = diagram.findLinkedRouteId(drillDownRouteId);
+ if (linkedRouteId != null && diagram.getRouteLayout(linkedRouteId) != null) {
+ if (linkedRouteId.equals(drillDownRouteId)) {
+ return true;
+ }
+ if (routeNavigationStack.contains(linkedRouteId)) {
+ while (!routeNavigationStack.isEmpty() && !linkedRouteId.equals(routeNavigationStack.peek())) {
+ routeNavigationStack.pop();
+ }
+ routeNavigationStack.pop();
+ } else {
+ routeNavigationStack.push(drillDownRouteId);
+ }
+ drillDownRouteId = linkedRouteId;
+ diagram.selectFromNode(linkedRouteId);
+ diagram.resetScroll();
return true;
}
+ return true;
}
- // Sort
- if (ke.isChar('s')) {
+ // Drill down into route diagram (Enter from topology)
+ if (diagram.isShowDiagram() && topologyMode && ke.isConfirm()) {
+ String selectedRouteId = diagram.getSelectedRouteId();
+ if (selectedRouteId != null) {
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ if (info != null && info.routes.stream().anyMatch(r -> selectedRouteId.equals(r.routeId))) {
+ routeNavigationStack.clear();
+ drillDownRouteId = selectedRouteId;
+ topologyMode = false;
+ diagram.setTopologyMode(false);
+ diagram.selectFromNode(selectedRouteId);
+ diagram.resetScroll();
+ diagram.endLoad();
+ if (diagram.getRouteLayout(selectedRouteId) != null) {
+ return true;
+ }
+ reloadDiagram();
+ }
+ }
+ return true;
+ }
+
+ // Sort (only when not in diagram)
+ if (!diagram.isShowDiagram() && ke.isChar('s')) {
if (routeTopMode) {
routeTopSortIndex = (routeTopSortIndex + 1) % ROUTE_TOP_SORT_COLUMNS.length;
routeTopSort = ROUTE_TOP_SORT_COLUMNS[routeTopSortIndex];
@@ -185,7 +282,7 @@ class RoutesTab implements MonitorTab {
}
return true;
}
- if (ke.isChar('S')) {
+ if (!diagram.isShowDiagram() && ke.isChar('S')) {
if (routeTopMode) {
routeTopSortReversed = !routeTopSortReversed;
} else {
@@ -195,35 +292,37 @@ class RoutesTab implements MonitorTab {
}
// Toggle top mode (only when not in source or diagram view)
- if (!showSource && !diagram.isShowDiagram() && ke.isCharIgnoreCase('t')) {
+ if (!sourceViewer.isVisible() && !diagram.isShowDiagram() && ke.isCharIgnoreCase('t')) {
routeTopMode = !routeTopMode;
return true;
}
- // Toggle all routes diagram flag
- if (!showSource && !diagram.isShowDiagram() && ke.isCharIgnoreCase('a')) {
- diagramAllRoutes = !diagramAllRoutes;
+ // Toggle description in route table (only when not in diagram)
+ if (!sourceViewer.isVisible() && !diagram.isShowDiagram() && ke.isCharIgnoreCase('n')) {
+ showDescription = !showDescription;
return true;
}
- // Text diagram toggle
- if (ke.isCharIgnoreCase('d')) {
- diagram.toggleTextDiagram(this::loadDiagramForSelectedRoute);
+ // Enter in table mode opens topology
+ if (!diagram.isShowDiagram() && !sourceViewer.isVisible() && ke.isConfirm()) {
+ openTopology();
return true;
}
- // Toggle description in diagram
- if (diagram.isShowDiagram() && ke.isCharIgnoreCase('n')) {
- diagram.setShowDescription(!diagram.isShowDescription());
- diagram.endLoad();
- loadDiagramForSelectedRoute();
+ // d in table mode opens route diagram, in diagram mode closes it
+ if (ke.isCharIgnoreCase('d')) {
+ if (diagram.isShowDiagram()) {
+ closeDiagram();
+ } else {
+ openRouteDiagram();
+ }
return true;
}
- // Source viewer toggle
- if (ke.isChar('c')) {
- if (showSource) {
- showSource = false;
+ // Source viewer toggle (table mode — route source)
+ if (!diagram.isShowDiagram() && ke.isChar('c')) {
+ if (sourceViewer.isVisible()) {
+ sourceViewer.hide();
} else {
loadSourceForSelectedRoute();
}
@@ -231,13 +330,13 @@ class RoutesTab implements MonitorTab {
}
// Route start/stop
- if (!showSource && !diagram.isShowDiagram() && ke.isChar('p')) {
+ if (!sourceViewer.isVisible() && !diagram.isShowDiagram() && ke.isChar('p')) {
toggleRouteStartStop();
return true;
}
// Route suspend/resume
- if (!showSource && !diagram.isShowDiagram() && ke.isChar('P') && selectedRouteSupportsSuspension()) {
+ if (!sourceViewer.isVisible() && !diagram.isShowDiagram() && ke.isChar('P') && selectedRouteSupportsSuspension()) {
toggleRouteSuspendResume();
return true;
}
@@ -247,35 +346,69 @@ class RoutesTab implements MonitorTab {
@Override
public boolean handleEscape() {
- if (showSource) {
- showSource = false;
+ if (sourceViewer.isVisible()) {
+ sourceViewer.hide();
+ return true;
+ }
+ if (diagram.isShowDiagram()) {
+ if (!topologyMode) {
+ if (!routeNavigationStack.isEmpty()) {
+ drillDownRouteId = routeNavigationStack.pop();
+ diagram.selectFromNode(drillDownRouteId);
+ diagram.resetScroll();
+ return true;
+ }
+ // Go back to topology
+ diagram.setPendingSelectionRouteId(drillDownRouteId);
+ topologyMode = true;
+ diagram.setTopologyMode(true);
+ diagram.setSelectedEipNodeIndex(-1);
+ diagram.resetScroll();
+ if (diagram.hasNativeLayout()) {
+ return true;
+ }
+ diagram.endLoad();
+ reloadDiagram();
+ return true;
+ }
+ // Close diagram entirely from topology
+ closeDiagram();
return true;
}
- return diagram.handleEscape();
+ return false;
}
@Override
public void navigateUp() {
- routeTableState.selectPrevious();
+ if (!diagram.isShowDiagram()) {
+ routeTableState.selectPrevious();
+ }
}
@Override
public void navigateDown() {
- IntegrationInfo info = ctx.findSelectedIntegration();
- routeTableState.selectNext(info != null ? info.routes.size() : 0);
+ if (!diagram.isShowDiagram()) {
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ routeTableState.selectNext(info != null ? info.routes.size() : 0);
+ }
}
@Override
public void onIntegrationChanged() {
- showSource = false;
- sourceLines = Collections.emptyList();
- sourceTitle = null;
- sourceScroll = 0;
- sourceScrollX = 0;
+ sourceViewer.reset();
diagram.reset();
+ topologyMode = true;
+ drillDownRouteId = null;
+ routeNavigationStack.clear();
routeTableState.select(0);
}
+ void preloadDiagram() {
+ if (ctx.selectedPid != null) {
+ diagram.preload(ctx, ctx.selectedPid);
+ }
+ }
+
@Override
public void render(Frame frame, Rect area) {
IntegrationInfo info = ctx.findSelectedIntegration();
@@ -284,36 +417,77 @@ class RoutesTab implements MonitorTab {
return;
}
- // Fullscreen source view
- if (showSource) {
- List<Rect> fullChunks = Layout.vertical()
- .constraints(Constraint.length(4), Constraint.fill())
- .split(area);
- renderRouteHeader(frame, fullChunks.get(0), info);
- renderSource(frame, fullChunks.get(1));
+ // Fullscreen source view (from table mode)
+ if (sourceViewer.isVisible() && !diagram.isShowDiagram()) {
+ sourceViewer.render(frame, area);
+ return;
+ }
+
+ // Fullscreen source view (from drill-down mode)
+ if (sourceViewer.isVisible() && diagram.isShowDiagram()) {
+ sourceViewer.render(frame, area);
return;
}
// Fullscreen diagram mode
if (diagram.isShowDiagram() && diagram.hasDiagramData()) {
- String title = diagram.isDiagramTextMode() ? "" : " Diagram [" + diagramRouteId + "] ";
- if (diagramAllRoutes) {
- diagram.renderDiagram(frame, area, title);
- } else {
- List<Rect> fullChunks = Layout.vertical()
- .constraints(Constraint.length(4), Constraint.fill())
- .split(area);
- renderRouteHeader(frame, fullChunks.get(0), info);
- diagram.renderDiagram(frame, fullChunks.get(1), title);
+ if (topologyMode && diagram.hasNativeLayout()) {
+ String selectedRouteId = diagram.getSelectedRouteId();
+ Line title;
+ if (info.name != null) {
+ title = Line.from(
+ Span.raw(" Topology ["),
+ Span.styled(info.name, Style.EMPTY.fg(Color.YELLOW).bold()),
+ Span.raw("] "));
+ } else {
+ title = Line.from(Span.raw(" Topology "));
+ }
+ if (selectedRouteId != null && area.width() > 60) {
+ int panelWidth = 30;
+ List<Rect> hChunks = Layout.horizontal()
+ .constraints(Constraint.length(panelWidth), Constraint.fill())
+ .split(area);
+ renderInfoPanel(frame, hChunks.get(0), info, selectedRouteId);
+ diagram.renderNativeDiagram(frame, hChunks.get(1), title, diagramMetrics);
+ } else {
+ diagram.renderNativeDiagram(frame, area, title, diagramMetrics);
+ }
+ return;
+ } else if (!topologyMode && drillDownRouteId != null
+ && diagram.getRouteLayout(drillDownRouteId) != null) {
+ Line title = buildBreadcrumbTitle();
+ var routeLayout = diagram.getRouteLayout(drillDownRouteId);
+ if (area.width() > 60) {
+ int panelWidth = 30;
+ List<Rect> hChunks = Layout.horizontal()
+ .constraints(Constraint.length(panelWidth), Constraint.fill())
+ .split(area);
+ renderEipInfoPanel(frame, hChunks.get(0));
+ diagram.renderNativeRouteDiagram(
+ frame, hChunks.get(1), title, diagramMetrics, drillDownRouteId, routeLayout);
+ } else {
+ diagram.renderNativeRouteDiagram(frame, area, title, diagramMetrics, drillDownRouteId, routeLayout);
+ }
+ return;
}
+
+ // Fallback: loading or no native layout yet
+ frame.renderWidget(
+ Paragraph.builder()
+ .text(Text.from(Line.from(Span.styled(
+ "Loading diagram...",
+ Style.EMPTY.dim()))))
+ .block(Block.builder().borderType(BorderType.ROUNDED)
+ .title(" Diagram ").build())
+ .build(),
+ area);
return;
}
- // Sort routes
+ // Normal table view
List<RouteInfo> sortedRoutes = new ArrayList<>(info.routes);
sortedRoutes.sort(this::sortRoute);
- // Split: routes table (top half) + processors table (bottom half)
List<Rect> chunks = Layout.vertical()
.constraints(Constraint.percentage(45), Constraint.percentage(55))
.split(area);
@@ -331,7 +505,7 @@ class RoutesTab implements MonitorTab {
routeRows.add(Row.from(
Cell.from(Span.styled(route.routeId != null ? route.routeId : "", Style.EMPTY.fg(Color.CYAN))),
- Cell.from(route.from != null ? route.from : ""),
+ Cell.from(routeFromLabel(route)),
rightCell(route.total > 0 ? String.valueOf(route.meanTime) : "", 6, topTimeStyle(route.meanTime)),
rightCell(route.total > 0 ? String.valueOf(route.maxTime) : "", 6, topTimeStyle(route.maxTime)),
rightCell(route.total > 0 ? String.valueOf(route.minTime) : "", 6),
@@ -361,7 +535,7 @@ class RoutesTab implements MonitorTab {
rightCell("MSG/S", 8, Style.EMPTY.bold()),
rightCell("LOAD", 12, Style.EMPTY.bold())))
.widths(
- Constraint.length(12),
+ Constraint.length(24),
Constraint.fill(),
Constraint.length(6),
Constraint.length(6),
@@ -393,8 +567,7 @@ class RoutesTab implements MonitorTab {
routeRows.add(Row.from(
Cell.from(Span.styled(route.routeId != null ? route.routeId : "", Style.EMPTY.fg(Color.CYAN))),
- Cell.from(Span.styled(route.group != null ? route.group : "", Style.EMPTY.dim())),
- Cell.from(route.from != null ? route.from : ""),
+ Cell.from(routeFromLabel(route)),
Cell.from(Span.styled(route.state != null ? route.state : "", stateStyle)),
Cell.from(route.uptime != null ? route.uptime : ""),
rightCell(route.coverage != null ? route.coverage : "", 6),
@@ -412,7 +585,6 @@ class RoutesTab implements MonitorTab {
.rows(routeRows)
.header(Row.from(
Cell.from(Span.styled(routeSortLabel("ROUTE", "name"), routeSortStyle("name"))),
- Cell.from(Span.styled(routeSortLabel("GROUP", "group"), routeSortStyle("group"))),
Cell.from(Span.styled(routeSortLabel("FROM", "from"), routeSortStyle("from"))),
Cell.from(Span.styled(routeSortLabel("STATUS", "status"), routeSortStyle("status"))),
Cell.from(Span.styled("AGE", Style.EMPTY.bold())),
@@ -424,8 +596,7 @@ class RoutesTab implements MonitorTab {
rightCell("MIN/MAX/MEAN", 14, Style.EMPTY.bold()),
Cell.from(Span.styled("SINCE-LAST", Style.EMPTY.bold()))))
.widths(
- Constraint.length(12),
- Constraint.length(14),
+ Constraint.length(24),
Constraint.fill(),
Constraint.length(10),
Constraint.length(8),
@@ -445,48 +616,62 @@ class RoutesTab implements MonitorTab {
frame.renderStatefulWidget(routeTable, chunks.get(0), routeTableState);
- // Bottom panel: diagram or processors
- if (diagram.isShowDiagram() && diagram.hasDiagramData()) {
- String title = diagram.isDiagramTextMode() ? "" : " Diagram [" + diagramRouteId + "] ";
- diagram.renderDiagram(frame, chunks.get(1), title);
+ // Bottom panel: processors
+ Integer selectedRoute = routeTableState.selected();
+ if (selectedRoute != null && selectedRoute >= 0 && selectedRoute < sortedRoutes.size()) {
+ RouteInfo route = sortedRoutes.get(selectedRoute);
+ renderProcessors(frame, chunks.get(1), route);
+ } else if (!sortedRoutes.isEmpty()) {
+ renderProcessors(frame, chunks.get(1), sortedRoutes.get(0));
} else {
- Integer selectedRoute = routeTableState.selected();
- if (selectedRoute != null && selectedRoute >= 0 && selectedRoute < sortedRoutes.size()) {
- RouteInfo route = sortedRoutes.get(selectedRoute);
- renderProcessors(frame, chunks.get(1), route);
- } else if (!sortedRoutes.isEmpty()) {
- renderProcessors(frame, chunks.get(1), sortedRoutes.get(0));
- } else {
- frame.renderWidget(
- Paragraph.builder()
- .text(Text.from(Line.from(Span.styled("No routes", Style.EMPTY.dim()))))
- .block(Block.builder().borderType(BorderType.ROUNDED).title(" Processors ").build())
- .build(),
- chunks.get(1));
- }
+ frame.renderWidget(
+ Paragraph.builder()
+ .text(Text.from(Line.from(Span.styled("No routes", Style.EMPTY.dim()))))
+ .block(Block.builder().borderType(BorderType.ROUNDED).title(" Processors ").build())
+ .build(),
+ chunks.get(1));
}
}
@Override
public void renderFooter(List<Span> spans) {
- if (showSource) {
- hint(spans, "c/Esc", "close");
- hint(spans, "↑↓←→", "scroll");
- hint(spans, "PgUp/PgDn", "page");
- hintLast(spans, "Home/End", "top/end");
+ if (sourceViewer.isVisible()) {
+ sourceViewer.renderFooter(spans);
} else if (diagram.isShowDiagram()) {
- diagram.renderFooterHints(spans);
+ if (!topologyMode && !diagram.getEipNodeBoxes().isEmpty()) {
+ hint(spans, "Esc", "back");
+ hint(spans, "t", "topology");
+ hint(spans, "↑↓←→", "navigate");
+ hint(spans, "PgUp/PgDn", "page");
+ hint(spans, "c", "source");
+ } else if (!topologyMode) {
+ hint(spans, "Esc", "back");
+ hint(spans, "t", "topology");
+ hint(spans, "↑↓←→", "scroll");
+ hint(spans, "PgUp/PgDn", "page");
+ } else if (!diagram.getNodeBoxes().isEmpty()) {
+ hint(spans, "Esc", "close");
+ hint(spans, "↑↓←→", "navigate");
+ hint(spans, "Enter", "drill-down");
+ hint(spans, "PgUp/PgDn", "page");
+ } else {
+ diagram.renderFooterHints(spans);
+ }
hint(spans, "m", "metrics" + (diagramMetrics ? " [on]" : " [off]"));
- hintLast(spans, "n", "description" + (diagram.isShowDescription() ? " [on]" : " [off]"));
+ if (topologyMode) {
+ hint(spans, "e", "external" + (showExternal ? " [on]" : " [off]"));
+ }
+ hint(spans, "n", "description" + (diagram.isShowDescription() ? " [on]" : " [off]"));
} else {
hint(spans, "Esc", "back");
hint(spans, "↑↓", "navigate");
+ hint(spans, "Enter", "topology");
+ hint(spans, "d", "diagram");
hint(spans, "s", "sort");
+ hint(spans, "n", "description" + (showDescription ? " [on]" : " [off]"));
hint(spans, "t", routeTopMode ? "top [on]" : "top [off]");
if (!routeTopMode) {
hint(spans, "c", "source");
- hint(spans, "d", "diagram");
- hint(spans, "a", "diagram " + (diagramAllRoutes ? "[all]" : "[single]"));
String routeState = selectedRouteState();
boolean supSus = selectedRouteSupportsSuspension();
if ("Started".equals(routeState)) {
@@ -508,8 +693,316 @@ class RoutesTab implements MonitorTab {
void refreshDiagramIfNeeded() {
if (diagram.isShowDiagram() && diagramMetrics) {
- reloadDiagramQuietly();
+ reloadDiagram();
+ }
+ }
+
+ private String routeFromLabel(RouteInfo route) {
+ if (showDescription && route.description != null && !route.description.isBlank()) {
+ return route.description;
+ }
+ return route.from != null ? route.from : "";
+ }
+
+ // ---- Diagram open/close ----
+
+ private void openTopology() {
+ topologyMode = true;
+ drillDownRouteId = null;
+ routeNavigationStack.clear();
+ diagram.setTopologyMode(true);
+
+ // Pre-select the currently highlighted route from the table
+ String selectedId = selectedRouteId();
+ if (selectedId != null) {
+ diagram.setPendingSelectionRouteId(selectedId);
+ }
+
+ if (diagram.hasCachedData(ctx.selectedPid)) {
+ diagram.showCached();
+ diagram.applyPendingSelection();
+ } else {
+ loadDiagram(true);
+ }
+ }
+
+ private void openRouteDiagram() {
+ String selectedId = selectedRouteId();
+ if (selectedId == null) {
+ return;
+ }
+ topologyMode = false;
+ drillDownRouteId = selectedId;
+ routeNavigationStack.clear();
+ diagram.setTopologyMode(false);
+ diagram.selectFromNode(selectedId);
+
+ if (diagram.hasCachedData(ctx.selectedPid)) {
+ diagram.showCached();
+ } else {
+ loadDiagram(true);
+ }
+ }
+
+ void closeDiagram() {
+ topologyMode = true;
+ drillDownRouteId = null;
+ routeNavigationStack.clear();
+ diagram.close();
+ }
+
+ // ---- Info panels (mirrored from DiagramTab) ----
+
+ private void renderInfoPanel(Frame frame, Rect area, IntegrationInfo info, String routeId) {
+ RouteInfo route = null;
+ for (RouteInfo r : info.routes) {
+ if (routeId.equals(r.routeId)) {
+ route = r;
+ break;
+ }
+ }
+
+ List<Line> lines = new ArrayList<>();
+ if (route != null) {
+ lines.add(Line.from(
+ Span.styled(" Route: ", Style.EMPTY.fg(Color.YELLOW).bold()),
+ Span.styled(route.routeId, Style.EMPTY.fg(Color.WHITE).bold())));
+ lines.add(Line.from(
+ Span.styled(" From: ", Style.EMPTY.dim()),
+ Span.raw(route.from != null ? route.from : "")));
+ String stateLabel = route.state != null ? route.state : "";
+ Style stateStyle = "Started".equals(route.state) ? Style.EMPTY.fg(Color.GREEN) : Style.EMPTY.fg(Color.LIGHT_RED);
+ lines.add(Line.from(
+ Span.styled(" State: ", Style.EMPTY.dim()),
+ Span.styled(stateLabel, stateStyle)));
+
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(
+ Span.styled(" Uptime: ", Style.EMPTY.dim()),
+ Span.raw(route.uptime != null ? route.uptime : "")));
+ lines.add(Line.from(
+ Span.styled(" Throughput: ", Style.EMPTY.dim()),
+ Span.raw(route.throughput != null ? route.throughput : "")));
+ if (route.coverage != null) {
+ lines.add(Line.from(
+ Span.styled(" Coverage: ", Style.EMPTY.dim()),
+ Span.raw(route.coverage)));
+ }
+
+ lines.add(Line.from(Span.raw("")));
+ int w = numWidth(route.total, route.failed, route.inflight);
+ lines.add(Line.from(
+ Span.styled(" Total: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + w + "d", route.total))));
+ Style failStyle = route.failed > 0 ? Style.EMPTY.fg(Color.LIGHT_RED).bold() : Style.EMPTY;
+ lines.add(Line.from(
+ Span.styled(" Failed: ", Style.EMPTY.dim()),
+ Span.styled(String.format("%" + w + "d", route.failed), failStyle)));
+ lines.add(Line.from(
+ Span.styled(" Inflight: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + w + "d", route.inflight))));
+
+ lines.add(Line.from(Span.raw("")));
+ if (route.total > 0) {
+ int tw = numWidth(route.meanTime, route.maxTime, route.minTime);
+ lines.add(Line.from(
+ Span.styled(" Mean: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + tw + "d ms", route.meanTime))));
+ lines.add(Line.from(
+ Span.styled(" Max: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + tw + "d ms", route.maxTime))));
+ lines.add(Line.from(
+ Span.styled(" Min: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + tw + "d ms", route.minTime))));
+ }
+
+ if (route.sinceLastCompleted != null || route.sinceLastFailed != null) {
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(
+ Span.styled(" Since last:", Style.EMPTY.dim())));
+ if (route.sinceLastCompleted != null) {
+ lines.add(Line.from(
+ Span.styled(" success: ", Style.EMPTY.dim()),
+ Span.raw(route.sinceLastCompleted)));
+ }
+ if (route.sinceLastFailed != null) {
+ lines.add(Line.from(
+ Span.styled(" fail: ", Style.EMPTY.dim()),
+ Span.styled(route.sinceLastFailed,
+ Style.EMPTY.fg(Color.LIGHT_RED))));
+ }
+ }
+
+ } else {
+ var topoNode = diagram.getSelectedTopologyNode();
+ if (topoNode != null) {
+ boolean isInbound = "external-in".equals(topoNode.nodeType);
+ lines.add(Line.from(
+ Span.styled(isInbound ? " Inbound" : " Outbound",
+ Style.EMPTY.fg(Color.CYAN).bold())));
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(
+ Span.styled(" URI: ", Style.EMPTY.dim()),
+ Span.raw(topoNode.from != null ? topoNode.from : "")));
+ if (topoNode.description != null && !topoNode.description.isBlank()) {
+ lines.add(Line.from(
+ Span.styled(" Path: ", Style.EMPTY.dim()),
+ Span.raw(topoNode.description)));
+ }
+ String connectedRoute = diagram.getConnectedRouteId(routeId);
+ if (connectedRoute != null) {
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(
+ Span.styled(isInbound ? " To route: " : " From route: ", Style.EMPTY.dim()),
+ Span.styled(connectedRoute, Style.EMPTY.fg(Color.WHITE))));
+ }
+ if (topoNode.exchangesTotal > 0 || topoNode.exchangesFailed > 0) {
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(
+ Span.styled(" Total: ", Style.EMPTY.dim()),
+ Span.raw(String.valueOf(topoNode.exchangesTotal))));
+ if (topoNode.exchangesFailed > 0) {
+ lines.add(Line.from(
+ Span.styled(" Failed: ", Style.EMPTY.dim()),
+ Span.styled(String.valueOf(topoNode.exchangesFailed),
+ Style.EMPTY.fg(Color.LIGHT_RED).bold())));
+ }
+ }
+ } else {
+ lines.add(Line.from(
+ Span.styled(" " + routeId, Style.EMPTY.fg(Color.CYAN).bold())));
+ lines.add(Line.from(
+ Span.styled(" (external endpoint)", Style.EMPTY.dim())));
+ }
}
+
+ Paragraph paragraph = Paragraph.builder()
+ .text(Text.from(lines))
+ .block(Block.builder().borderType(BorderType.ROUNDED)
+ .title(" Info ").build())
+ .build();
+ frame.renderWidget(paragraph, area);
+ }
+
+ private void renderEipInfoPanel(Frame frame, Rect area) {
+ List<Line> lines = new ArrayList<>();
+ var selected = diagram.getSelectedEipNodeBox();
+ if (selected != null && selected.layoutNode() != null) {
+ var ln = selected.layoutNode();
+
+ String typeLabel = ln.type != null ? ln.type : "unknown";
+ Color eipColor = org.apache.camel.dsl.jbang.core.commands.tui.diagram.DiagramColors.getEipColor(typeLabel);
+ lines.add(Line.from(
+ Span.styled(" [" + typeLabel + "]", Style.EMPTY.fg(eipColor).bold())));
+
+ String label = String.join("", ln.wrappedLines);
+ if (!label.isBlank()) {
+ lines.add(Line.from(
+ Span.styled(" ", Style.EMPTY.dim()),
+ Span.raw(label)));
+ }
+
+ if (ln.id != null) {
+ lines.add(Line.from(
+ Span.styled(" ID: ", Style.EMPTY.dim()),
+ Span.raw(ln.id)));
+ }
+
+ lines.add(Line.from(Span.raw("")));
+ String linkedRoute = diagram.findLinkedRouteId(drillDownRouteId);
+ if (linkedRoute != null && diagram.getRouteLayout(linkedRoute) != null) {
+ lines.add(Line.from(
+ Span.styled(" ↵ ", Style.EMPTY.fg(Color.YELLOW).bold()),
+ Span.styled(linkedRoute, Style.EMPTY.fg(Color.WHITE))));
+ } else if (ln.treeNode != null && ln.treeNode.info.remote) {
+ String arrow = "from".equals(ln.type) ? " external → " : " → external";
+ lines.add(Line.from(
+ Span.styled(arrow, Style.EMPTY.fg(Color.DARK_GRAY))));
+ } else {
+ lines.add(Line.from(Span.raw("")));
+ }
+
+ if (ln.treeNode != null && ln.treeNode.info.stat != null) {
+ var stat = ln.treeNode.info.stat;
+ lines.add(Line.from(Span.raw("")));
+ int w = numWidth(stat.exchangesTotal, stat.exchangesFailed, stat.exchangesInflight);
+ lines.add(Line.from(
+ Span.styled(" Total: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + w + "d", stat.exchangesTotal))));
+ Style failStyle = stat.exchangesFailed > 0
+ ? Style.EMPTY.fg(Color.LIGHT_RED).bold() : Style.EMPTY;
+ lines.add(Line.from(
+ Span.styled(" Failed: ", Style.EMPTY.dim()),
+ Span.styled(String.format("%" + w + "d", stat.exchangesFailed), failStyle)));
+ lines.add(Line.from(
+ Span.styled(" Inflight: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + w + "d", stat.exchangesInflight))));
+
+ if (stat.exchangesTotal > 0) {
+ lines.add(Line.from(Span.raw("")));
+ int tw = numWidth(stat.meanProcessingTime, stat.maxProcessingTime,
+ stat.minProcessingTime, stat.lastProcessingTime);
+ lines.add(Line.from(
+ Span.styled(" Mean: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + tw + "d ms", stat.meanProcessingTime))));
+ lines.add(Line.from(
+ Span.styled(" Max: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + tw + "d ms", stat.maxProcessingTime))));
+ lines.add(Line.from(
+ Span.styled(" Min: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + tw + "d ms", stat.minProcessingTime))));
+ lines.add(Line.from(
+ Span.styled(" Last: ", Style.EMPTY.dim()),
+ Span.raw(String.format("%" + tw + "d ms", stat.lastProcessingTime))));
+
+ if (stat.lastCompletedExchangeTimestamp > 0 || stat.lastFailedExchangeTimestamp > 0) {
+ long now = System.currentTimeMillis();
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(
+ Span.styled(" Since last:", Style.EMPTY.dim())));
+ if (stat.lastCompletedExchangeTimestamp > 0) {
+ long ago = now - stat.lastCompletedExchangeTimestamp;
+ lines.add(Line.from(
+ Span.styled(" success: ", Style.EMPTY.dim()),
+ Span.raw(TimeUtils.printDuration(ago, false))));
+ }
+ if (stat.lastFailedExchangeTimestamp > 0) {
+ long ago = now - stat.lastFailedExchangeTimestamp;
+ lines.add(Line.from(
+ Span.styled(" fail: ", Style.EMPTY.dim()),
+ Span.styled(TimeUtils.printDuration(ago, false),
+ Style.EMPTY.fg(Color.LIGHT_RED))));
+ }
+ }
+ }
+ }
+ } else {
+ lines.add(Line.from(Span.styled(" (no node selected)", Style.EMPTY.dim())));
+ }
+
+ Paragraph paragraph = Paragraph.builder()
+ .text(Text.from(lines))
+ .block(Block.builder().borderType(BorderType.ROUNDED)
+ .title(" Info ").build())
+ .build();
+ frame.renderWidget(paragraph, area);
+ }
+
+ private Line buildBreadcrumbTitle() {
+ Style nameStyle = Style.EMPTY.fg(Color.YELLOW).bold();
+ List<Span> spans = new ArrayList<>();
+ spans.add(Span.raw(" Route ["));
+ if (routeNavigationStack.isEmpty()) {
+ spans.add(Span.styled(drillDownRouteId, nameStyle));
+ } else {
+ for (var it = routeNavigationStack.descendingIterator(); it.hasNext();) {
+ spans.add(Span.styled(it.next(), nameStyle));
+ spans.add(Span.raw(" → "));
+ }
+ spans.add(Span.styled(drillDownRouteId, nameStyle));
+ }
+ spans.add(Span.raw("] "));
+ return Line.from(spans);
}
// ---- Rendering helpers ----
@@ -656,146 +1149,6 @@ class RoutesTab implements MonitorTab {
frame.renderStatefulWidget(table, area, processorTableState);
}
- private void renderRouteHeader(Frame frame, Rect area, IntegrationInfo info) {
- RouteInfo route = null;
- if (diagramRouteId != null) {
- for (RouteInfo r : info.routes) {
- if (diagramRouteId.equals(r.routeId)) {
- route = r;
- break;
- }
- }
- }
-
- List<Row> rows = new ArrayList<>();
- if (route != null) {
- Style stateStyle = "Started".equals(route.state)
- ? Style.EMPTY.fg(Color.GREEN)
- : Style.EMPTY.fg(Color.LIGHT_RED);
- Style failStyle = route.failed > 0
- ? Style.EMPTY.fg(Color.LIGHT_RED).bold()
- : Style.EMPTY;
- rows.add(Row.from(
- Cell.from(Span.styled(route.routeId != null ? route.routeId : "", Style.EMPTY.fg(Color.CYAN))),
- Cell.from(route.from != null ? route.from : ""),
- Cell.from(Span.styled(route.state != null ? route.state : "", stateStyle)),
- Cell.from(route.uptime != null ? route.uptime : ""),
- rightCell(route.throughput != null ? route.throughput : "", 8),
- rightCell(String.valueOf(route.total), 8),
- rightCell(String.valueOf(route.failed), 6, failStyle),
- rightCell(String.valueOf(route.inflight), 8)));
- }
-
- Table table = Table.builder()
- .rows(rows)
- .header(Row.from(
- Cell.from(Span.styled("ROUTE", Style.EMPTY.bold())),
- Cell.from(Span.styled("FROM", Style.EMPTY.bold())),
- Cell.from(Span.styled("STATUS", Style.EMPTY.bold())),
- Cell.from(Span.styled("AGE", Style.EMPTY.bold())),
- rightCell("MSG/S", 8, Style.EMPTY.bold()),
- rightCell("TOTAL", 8, Style.EMPTY.bold()),
- rightCell("FAIL", 6, Style.EMPTY.bold()),
- rightCell("INFLIGHT", 8, Style.EMPTY.bold())))
- .widths(
- Constraint.length(12),
- Constraint.fill(),
- Constraint.length(10),
- Constraint.length(8),
- Constraint.length(8),
- Constraint.length(8),
- Constraint.length(6),
- Constraint.length(8))
- .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue())
- .block(Block.builder().borderType(BorderType.ROUNDED)
- .title(" Route ").build())
- .build();
-
- frame.renderStatefulWidget(table, area, routeHeaderTableState);
- }
-
- private void renderSource(Frame frame, Rect area) {
- Block block = Block.builder()
- .borderType(BorderType.ROUNDED)
- .title(" Source [" + (sourceTitle != null ? sourceTitle : "") + "] ")
- .build();
- Rect inner = block.inner(area);
- frame.renderWidget(block, area);
-
- if (sourceLines.isEmpty()) {
- return;
- }
-
- int visibleLines = inner.height();
- int maxScroll = Math.max(0, sourceLines.size() - visibleLines);
- sourceScroll = Math.min(sourceScroll, maxScroll);
-
- int maxLineWidth = sourceLines.stream().mapToInt(String::length).max().orElse(0);
- int maxHScroll = Math.max(0, maxLineWidth - inner.width());
- sourceScrollX = Math.min(sourceScrollX, maxHScroll);
-
- int end = Math.min(sourceScroll + visibleLines, sourceLines.size());
- List<Line> visible = new ArrayList<>();
- for (int i = sourceScroll; i < end; i++) {
- String raw = sourceLines.get(i);
- visible.add(highlightSourceLine(raw, sourceScrollX));
- }
- frame.renderWidget(Paragraph.builder().text(Text.from(visible)).build(), inner);
-
- // Vertical scrollbar
- if (sourceLines.size() > visibleLines) {
- sourceVScrollState.contentLength(sourceLines.size()).viewportContentLength(visibleLines).position(sourceScroll);
- frame.renderStatefulWidget(Scrollbar.builder().build(), inner, sourceVScrollState);
- }
- // Horizontal scrollbar
- if (maxHScroll > 0) {
- sourceHScrollState.contentLength(maxLineWidth).viewportContentLength(inner.width()).position(sourceScrollX);
- frame.renderStatefulWidget(Scrollbar.horizontal(), inner, sourceHScrollState);
- }
- }
-
- private Line highlightSourceLine(String raw, int hSkip) {
- // Split line number prefix from code content
- int prefixEnd = 0;
- while (prefixEnd < raw.length() && (raw.charAt(prefixEnd) == ' ' || Character.isDigit(raw.charAt(prefixEnd)))) {
- prefixEnd++;
- }
-
- String prefix = raw.substring(0, prefixEnd);
- String code = raw.substring(prefixEnd);
-
- Line highlighted = SyntaxHighlighter.highlightLine(code, sourceLanguage);
-
- // Prepend dim line-number prefix
- List<Span> spans = new ArrayList<>();
- if (!prefix.isEmpty()) {
- spans.add(Span.styled(prefix, Style.EMPTY.dim()));
- }
- spans.addAll(highlighted.spans());
-
- Line full = Line.from(spans);
-
- // Apply horizontal scroll by skipping characters from spans
- if (hSkip <= 0) {
- return full;
- }
- List<Span> scrolled = new ArrayList<>();
- int skipped = 0;
- for (Span span : full.spans()) {
- String content = span.content();
- if (skipped >= hSkip) {
- scrolled.add(span);
- } else if (skipped + content.length() > hSkip) {
- int offset = hSkip - skipped;
- scrolled.add(Span.styled(content.substring(offset), span.style()));
- skipped = hSkip;
- } else {
- skipped += content.length();
- }
- }
- return scrolled.isEmpty() ? Line.from(List.of(Span.raw(""))) : Line.from(scrolled);
- }
-
// ---- Sorting ----
private int sortRoute(RouteInfo a, RouteInfo b) {
@@ -812,11 +1165,6 @@ class RoutesTab implements MonitorTab {
String sb2 = b.state != null ? b.state : "";
yield sa.compareToIgnoreCase(sb2);
}
- case "group" -> {
- String ga = a.group != null ? a.group : "";
- String gb = b.group != null ? b.group : "";
- yield ga.compareToIgnoreCase(gb);
- }
case "from" -> {
String fa = a.from != null ? a.from : "";
String fb = b.from != null ? b.from : "";
@@ -968,15 +1316,7 @@ class RoutesTab implements MonitorTab {
// ---- Async loading ----
- private void loadDiagramForSelectedRoute() {
- loadDiagramForSelectedRoute(true);
- }
-
- private void reloadDiagramQuietly() {
- loadDiagramForSelectedRoute(false);
- }
-
- private void loadDiagramForSelectedRoute(boolean showPlaceholder) {
+ private void loadDiagram(boolean showPlaceholder) {
if (ctx.selectedPid == null || ctx.runner == null) {
return;
}
@@ -984,52 +1324,31 @@ class RoutesTab implements MonitorTab {
return;
}
- IntegrationInfo info = ctx.findSelectedIntegration();
- if (info == null || info.routes.isEmpty()) {
- diagram.endLoad();
- return;
- }
-
- List<RouteInfo> sortedRoutes = new ArrayList<>(info.routes);
- sortedRoutes.sort(this::sortRoute);
-
- Integer sel = routeTableState.selected();
- RouteInfo selectedRoute;
- if (sel != null && sel >= 0 && sel < sortedRoutes.size()) {
- selectedRoute = sortedRoutes.get(sel);
- } else {
- selectedRoute = sortedRoutes.get(0);
- }
-
String pid = ctx.selectedPid;
- boolean textMode = diagram.isDiagramTextMode();
boolean showMetrics = diagramMetrics;
- String routeId = diagramAllRoutes ? null : selectedRoute.routeId;
+ boolean external = showExternal;
- diagramRouteId = routeId != null ? routeId : "all";
if (showPlaceholder) {
diagram.setLoadingPlaceholder();
}
ctx.runner.scheduler().execute(() -> {
try {
- diagram.loadRouteDiagramInBackground(ctx, pid, textMode, routeId, showMetrics);
+ diagram.setTopologyMode(topologyMode);
+ diagram.loadAllDiagramsInBackground(ctx, pid, showMetrics, external);
} finally {
diagram.endLoad();
}
});
}
+ private void reloadDiagram() {
+ loadDiagram(false);
+ }
+
private void loadSourceForSelectedRoute() {
- if (ctx.selectedPid == null || ctx.runner == null) {
- return;
- }
- if (!sourceLoading.compareAndSet(false, true)) {
- return;
- }
IntegrationInfo info = ctx.findSelectedIntegration();
if (info == null || info.routes.isEmpty()) {
- sourceLoading.set(false);
return;
}
List<RouteInfo> sortedRoutes = new ArrayList<>(info.routes);
@@ -1037,147 +1356,20 @@ class RoutesTab implements MonitorTab {
Integer sel = routeTableState.selected();
RouteInfo selectedRoute = (sel != null && sel >= 0 && sel < sortedRoutes.size())
? sortedRoutes.get(sel) : sortedRoutes.get(0);
-
- sourceLines = List.of("(Loading source...)");
- sourceTitle = selectedRoute.routeId;
- sourceScroll = 0;
- sourceScrollX = 0;
- showSource = true;
-
- String pid = ctx.selectedPid;
- String routeId = selectedRoute.routeId;
- ctx.runner.scheduler().execute(() -> {
- try {
- loadSourceInBackground(pid, routeId);
- } finally {
- sourceLoading.set(false);
- }
- });
+ sourceViewer.loadSource(ctx, selectedRoute.routeId, 0);
}
- private void loadSourceInBackground(String pid, String routeId) {
- Path outputFile = ctx.getOutputFile(pid);
- PathUtils.deleteFile(outputFile);
-
- JsonObject root = new JsonObject();
- root.put("action", "source");
- root.put("filter", routeId);
-
- Path actionFile = ctx.getActionFile(pid);
- PathUtils.writeTextSafely(root.toJson(), actionFile);
-
- JsonObject jo = pollJsonResponse(outputFile, 5000);
- PathUtils.deleteFile(outputFile);
-
- if (jo == null) {
- applySourceResult(routeId, null, List.of("(No response from integration)"));
+ private void loadSourceForSelectedNode() {
+ if (drillDownRouteId == null) {
return;
}
-
- JsonArray routes = (JsonArray) jo.get("routes");
- if (routes == null || routes.isEmpty()) {
- applySourceResult(routeId, null, List.of("(No source available for route: " + routeId + ")"));
- return;
- }
-
- JsonObject routeObj = (JsonObject) routes.get(0);
- String sourceLocation = objToString(routeObj.get("source"));
- List<JsonObject> codeLines = routeObj.getCollection("code");
- if (codeLines == null || codeLines.isEmpty()) {
- applySourceResult(routeId, sourceLocation, List.of("(No source code available)"));
- return;
+ int targetLine = 0;
+ var selected = diagram.getSelectedEipNodeBox();
+ if (selected != null && selected.layoutNode() != null
+ && selected.layoutNode().treeNode != null) {
+ targetLine = selected.layoutNode().treeNode.info.line;
}
-
- List<String> lines = new ArrayList<>();
- int maxLineNum = 0;
- for (JsonObject codeLine : codeLines) {
- Integer lineNum = codeLine.getInteger("line");
- if (lineNum != null && lineNum > maxLineNum) {
- maxLineNum = lineNum;
- }
- }
- int lineNumWidth = String.valueOf(maxLineNum).length();
- int matchLine = -1;
- int idx = 0;
- for (JsonObject codeLine : codeLines) {
- Integer lineNum = codeLine.getInteger("line");
- String code = Jsoner.unescape(objToString(codeLine.get("code")));
- Boolean match = codeLine.getBoolean("match");
- String prefix = lineNum != null
- ? String.format("%" + lineNumWidth + "d ", lineNum)
- : String.format("%" + lineNumWidth + "s ", "");
- lines.add(prefix + code);
- if (Boolean.TRUE.equals(match) && matchLine < 0) {
- matchLine = idx;
- }
- idx++;
- }
-
- int scrollTo;
- if (matchLine > 0) {
- scrollTo = Math.max(0, matchLine - 2);
- } else {
- scrollTo = findLicenseHeaderEnd(codeLines);
- }
- applySourceResult(routeId, sourceLocation, lines, scrollTo);
- }
-
- private void applySourceResult(String routeId, String location, List<String> lines) {
- applySourceResult(routeId, location, lines, 0);
- }
-
- private void applySourceResult(String routeId, String location, List<String> lines, int scrollTo) {
- if (ctx.runner == null) {
- return;
- }
- ctx.runner.runOnRenderThread(() -> {
- if (!showSource) {
- return;
- }
- String displayLoc = location != null ? FileUtil.stripPath(LoggerHelper.sourceNameOnly(location)) : null;
- sourceTitle = displayLoc != null ? routeId + " " + displayLoc : routeId;
- sourceLanguage = SyntaxHighlighter.detectLanguage(location);
- sourceLines = lines;
- sourceScroll = scrollTo;
- });
- }
-
- private static int findLicenseHeaderEnd(List<JsonObject> codeLines) {
- // Auto-scroll past leading license/comment headers
- boolean inBlock = false;
- int lastCommentLine = -1;
- for (int i = 0; i < codeLines.size(); i++) {
- String code = objToString(codeLines.get(i).get("code")).trim();
- if (i == 0 && code.isEmpty()) {
- continue;
- }
- if (!inBlock && code.startsWith("/*")) {
- inBlock = true;
- }
- if (inBlock) {
- lastCommentLine = i;
- if (code.contains("*/")) {
- inBlock = false;
- }
- continue;
- }
- // YAML/shell comment lines or XML comment lines at the top
- if (code.startsWith("#") || code.startsWith("##") || code.startsWith("<!--")) {
- lastCommentLine = i;
- continue;
- }
- // Empty line right after comment block
- if (lastCommentLine >= 0 && code.isEmpty()) {
- lastCommentLine = i;
- continue;
- }
- break;
- }
- return lastCommentLine >= 0 ? lastCommentLine + 1 : 0;
- }
-
- private static String objToString(Object o) {
- return o != null ? o.toString() : "";
+ sourceViewer.loadSource(ctx, drillDownRouteId, targetLine);
}
@Override
@@ -1236,51 +1428,68 @@ class RoutesTab implements MonitorTab {
## Route Diagram
- Press `d` to see a visual flow chart of the selected route. The diagram
- shows every EIP node and how messages flow between them. Numbers on
- each node show how many exchanges passed through it.
+ Press `d` to see a topology diagram showing how all routes connect to each
+ other. This is the same view as the Diagram tab. Use arrow keys to navigate
+ between route boxes and press `Enter` to drill down into a route's internal
+ EIP structure.
- Scroll down to view the diagram example:
+ ## Navigation
- ```
- ┌──────────────────────┐
- │ from[timer:hello?..] │
- └──────────────────────┘
- │
- ▼ 29
- ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
- ╎ ┌────────────────────────┐ ╎
- ╎ │ choice │ ╎
- ╎ └────────────────────────┘ ╎
- ╎ │ │ ╎
- ╎ ▼ 9 ▼ 20 ╎
- ╎ ┌──────────┐ ┌──────────┐ ╎
- ╎ │ log[HI] │ │ log[LO] │ ╎
- ╎ └──────────┘ └──────────┘ ╎
- ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
- ```
+ In the topology view, use arrow keys to select route boxes:
+ - `↑↓` moves between layers (upstream/downstream routes)
+ - `←→` moves between routes in the same layer
+
+ When a route is selected, an **Info panel** appears on the left
+ showing key metrics: state, uptime, throughput, exchange counts,
+ and processing times.
+
+ Press `Enter` on a selected route to **drill down** into its
+ internal EIP structure (the route diagram). Press `Esc` to
+ return to the topology view.
+
+ ## Route Diagram (drill-down)
+
+ In the route diagram, each EIP node shows its type tag (colored)
+ and endpoint URI or description. Nodes that connect to other routes
+ display a `↵` indicator — press `Enter` to jump directly to the
+ linked route's diagram.
- The dotted border groups nodes that belong to the same EIP block
- (like `choice`, `split`, `multicast`). The numbers show how many
- exchanges took each branch — useful for verifying routing logic.
+ Navigation history is maintained as a stack: pressing `Esc` goes
+ back to the previous route, and eventually back to the topology view.
## Source View
- Press `s` to see the original route source code (YAML, XML, or Java).
+ Press `c` to see the original route source code (YAML, XML, or Java).
## Keys
+ **Route table:**
- `Up/Down` — select route
- `p` — start/stop selected route
- `P` — suspend/resume selected route
- - `d` — show route diagram
- - `a` — toggle diagram scope (single route or all routes)
+ - `d` — show topology diagram
- `c` — show route source code
- - `m` — toggle metrics in diagram
+ - `n` — toggle description labels
- `s` — cycle sort column
- `S` — reverse sort order
- `t` — toggle Top mode
- - `Esc` — back to route list
+
+ **Topology view:**
+ - `↑↓←→` — navigate between route boxes
+ - `Enter` — drill down into selected route
+ - `Esc` — close diagram (back to route table)
+ - `m` — toggle metrics on/off
+ - `e` — toggle external systems on/off
+ - `n` — toggle description labels
+
+ **Route diagram (drill-down):**
+ - `↑↓←→` — navigate between EIP nodes
+ - `Enter` — jump to linked route (when `↵` indicator shown)
+ - `Esc` — go back (previous route or topology)
+ - `t` — jump back to topology view
+ - `c` — show source code
+ - `m` — toggle metrics
+ - `n` — toggle description labels
""";
}
@@ -1318,4 +1527,12 @@ class RoutesTab implements MonitorTab {
result.put("selectedIndex", sel != null ? sel : -1);
return result;
}
+
+ private static int numWidth(long... values) {
+ long max = 0;
+ for (long v : values) {
+ max = Math.max(max, Math.abs(v));
+ }
+ return Math.max(1, String.valueOf(max).length());
+ }
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
index cbfc5e124b25..3e4f6b0ae5af 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
@@ -378,7 +378,7 @@ class RunOptionsForm {
int popupW = Math.min(56, area.width() - 4);
int popupH = 11;
int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
- int y = area.top() + Math.max(0, (area.height() - popupH) / 2);
+ int y = area.top() + Math.max(0, (area.height() - popupH) / 4);
Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height()));
frame.renderWidget(Clear.INSTANCE, popup);
@@ -448,7 +448,7 @@ class RunOptionsForm {
int propCount = properties != null ? properties.size() : 0;
int popupH = Math.min(propCount + 2, Math.min(20, area.height() - 4));
int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
- int y = area.top() + Math.max(0, (area.height() - popupH) / 2);
+ int y = area.top() + Math.max(0, (area.height() - popupH) / 4);
Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height()));
frame.renderWidget(Clear.INSTANCE, popup);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
new file mode 100644
index 000000000000..d048e82de5fd
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
@@ -0,0 +1,341 @@
+/*
+ * 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.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import dev.tamboui.widgets.scrollbar.Scrollbar;
+import dev.tamboui.widgets.scrollbar.ScrollbarState;
+import org.apache.camel.dsl.jbang.core.common.PathUtils;
+import org.apache.camel.support.LoggerHelper;
+import org.apache.camel.util.FileUtil;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.pollJsonResponse;
+
+/**
+ * Reusable source code viewer with syntax highlighting, scrolling, and line-number display. Can be used by any tab that
+ * needs to show route source code.
+ */
+class SourceViewer {
+
+ private boolean visible;
+ private List<String> lines = Collections.emptyList();
+ private String title;
+ private SyntaxHighlighter.Language language = SyntaxHighlighter.Language.PLAIN;
+ private int scrollY;
+ private int scrollX;
+ private final ScrollbarState vScrollState = new ScrollbarState();
+ private final ScrollbarState hScrollState = new ScrollbarState();
+ private final AtomicBoolean loading = new AtomicBoolean(false);
+
+ boolean isVisible() {
+ return visible;
+ }
+
+ void hide() {
+ visible = false;
+ }
+
+ void reset() {
+ visible = false;
+ lines = Collections.emptyList();
+ title = null;
+ scrollY = 0;
+ scrollX = 0;
+ }
+
+ boolean handleKeyEvent(KeyEvent ke) {
+ if (!visible) {
+ return false;
+ }
+ if (ke.isChar('c') || ke.isCancel()) {
+ visible = false;
+ return true;
+ }
+ if (ke.isUp()) {
+ scrollY = Math.max(0, scrollY - 1);
+ } else if (ke.isDown()) {
+ scrollY++;
+ } else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
+ scrollY = Math.max(0, scrollY - 20);
+ } else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
+ scrollY += 20;
+ } else if (ke.isLeft()) {
+ scrollX = Math.max(0, scrollX - 1);
+ } else if (ke.isRight()) {
+ scrollX++;
+ } else if (ke.isHome()) {
+ scrollY = 0;
+ scrollX = 0;
+ } else if (ke.isEnd()) {
+ scrollY = Integer.MAX_VALUE;
+ } else {
+ return false;
+ }
+ return true;
+ }
+
+ void render(Frame frame, Rect area) {
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" Source [" + (title != null ? title : "") + "] ")
+ .build();
+ Rect inner = block.inner(area);
+ frame.renderWidget(block, area);
+
+ if (lines.isEmpty()) {
+ return;
+ }
+
+ int visibleLines = inner.height();
+ int maxScroll = Math.max(0, lines.size() - visibleLines);
+ scrollY = Math.min(scrollY, maxScroll);
+
+ int maxLineWidth = lines.stream().mapToInt(String::length).max().orElse(0);
+ int maxHScroll = Math.max(0, maxLineWidth - inner.width());
+ scrollX = Math.min(scrollX, maxHScroll);
+
+ int end = Math.min(scrollY + visibleLines, lines.size());
+ List<Line> visible = new ArrayList<>();
+ for (int i = scrollY; i < end; i++) {
+ String raw = lines.get(i);
+ visible.add(highlightSourceLine(raw, scrollX));
+ }
+ frame.renderWidget(Paragraph.builder().text(Text.from(visible)).build(), inner);
+
+ if (lines.size() > visibleLines) {
+ vScrollState.contentLength(lines.size()).viewportContentLength(visibleLines).position(scrollY);
+ frame.renderStatefulWidget(Scrollbar.builder().build(), inner, vScrollState);
+ }
+ if (maxHScroll > 0) {
+ hScrollState.contentLength(maxLineWidth).viewportContentLength(inner.width()).position(scrollX);
+ frame.renderStatefulWidget(Scrollbar.horizontal(), inner, hScrollState);
+ }
+ }
+
+ void renderFooter(List<Span> spans) {
+ MonitorContext.hint(spans, "Esc/c", "close");
+ MonitorContext.hint(spans, "↑↓", "scroll");
+ MonitorContext.hint(spans, "←→", "horizontal");
+ MonitorContext.hint(spans, "PgUp/PgDn", "page");
+ }
+
+ /**
+ * Load source for a route, scrolling to the given source line number.
+ */
+ void loadSource(MonitorContext ctx, String routeId, int targetLine) {
+ if (ctx.selectedPid == null || ctx.runner == null) {
+ return;
+ }
+ if (!loading.compareAndSet(false, true)) {
+ return;
+ }
+
+ lines = List.of("(Loading source...)");
+ title = routeId;
+ scrollY = 0;
+ scrollX = 0;
+ visible = true;
+
+ String pid = ctx.selectedPid;
+ ctx.runner.scheduler().execute(() -> {
+ try {
+ loadInBackground(ctx, pid, routeId, targetLine);
+ } finally {
+ loading.set(false);
+ }
+ });
+ }
+
+ private void loadInBackground(MonitorContext ctx, String pid, String routeId, int targetLine) {
+ Path outputFile = ctx.getOutputFile(pid);
+ PathUtils.deleteFile(outputFile);
+
+ JsonObject root = new JsonObject();
+ root.put("action", "source");
+ root.put("filter", routeId);
+
+ Path actionFile = ctx.getActionFile(pid);
+ PathUtils.writeTextSafely(root.toJson(), actionFile);
+
+ JsonObject jo = pollJsonResponse(outputFile, 5000);
+ PathUtils.deleteFile(outputFile);
+
+ if (jo == null) {
+ applyResult(ctx, routeId, null, List.of("(No response from integration)"), 0);
+ return;
+ }
+
+ JsonArray routes = (JsonArray) jo.get("routes");
+ if (routes == null || routes.isEmpty()) {
+ applyResult(ctx, routeId, null, List.of("(No source available for route: " + routeId + ")"), 0);
+ return;
+ }
+
+ JsonObject routeObj = (JsonObject) routes.get(0);
+ String sourceLocation = objToString(routeObj.get("source"));
+ List<JsonObject> codeLines = routeObj.getCollection("code");
+ if (codeLines == null || codeLines.isEmpty()) {
+ applyResult(ctx, routeId, sourceLocation, List.of("(No source code available)"), 0);
+ return;
+ }
+
+ List<String> result = new ArrayList<>();
+ int maxLineNum = 0;
+ for (JsonObject codeLine : codeLines) {
+ Integer lineNum = codeLine.getInteger("line");
+ if (lineNum != null && lineNum > maxLineNum) {
+ maxLineNum = lineNum;
+ }
+ }
+ int lineNumWidth = String.valueOf(maxLineNum).length();
+ int matchIdx = -1;
+ int idx = 0;
+ for (JsonObject codeLine : codeLines) {
+ Integer lineNum = codeLine.getInteger("line");
+ String code = Jsoner.unescape(objToString(codeLine.get("code")));
+ String prefix = lineNum != null
+ ? String.format("%" + lineNumWidth + "d ", lineNum)
+ : String.format("%" + lineNumWidth + "s ", "");
+ result.add(prefix + code);
+ if (targetLine > 0 && lineNum != null && lineNum == targetLine && matchIdx < 0) {
+ matchIdx = idx;
+ }
+ Boolean match = codeLine.getBoolean("match");
+ if (targetLine <= 0 && Boolean.TRUE.equals(match) && matchIdx < 0) {
+ matchIdx = idx;
+ }
+ idx++;
+ }
+
+ int scrollTo;
+ if (matchIdx >= 0) {
+ scrollTo = Math.max(0, matchIdx - 2);
+ } else {
+ scrollTo = findLicenseHeaderEnd(codeLines);
+ }
+ applyResult(ctx, routeId, sourceLocation, result, scrollTo);
+ }
+
+ private void applyResult(MonitorContext ctx, String routeId, String location, List<String> resultLines, int scrollTo) {
+ if (ctx.runner == null) {
+ return;
+ }
+ ctx.runner.runOnRenderThread(() -> {
+ if (!visible) {
+ return;
+ }
+ String displayLoc = location != null ? FileUtil.stripPath(LoggerHelper.sourceNameOnly(location)) : null;
+ title = displayLoc != null ? routeId + " " + displayLoc : routeId;
+ language = SyntaxHighlighter.detectLanguage(location);
+ lines = resultLines;
+ scrollY = scrollTo;
+ });
+ }
+
+ private Line highlightSourceLine(String raw, int hSkip) {
+ int prefixEnd = 0;
+ while (prefixEnd < raw.length() && (raw.charAt(prefixEnd) == ' ' || Character.isDigit(raw.charAt(prefixEnd)))) {
+ prefixEnd++;
+ }
+
+ String prefix = raw.substring(0, prefixEnd);
+ String code = raw.substring(prefixEnd);
+
+ Line highlighted = SyntaxHighlighter.highlightLine(code, language);
+
+ List<Span> spans = new ArrayList<>();
+ if (!prefix.isEmpty()) {
+ spans.add(Span.styled(prefix, Style.EMPTY.dim()));
+ }
+ spans.addAll(highlighted.spans());
+
+ Line full = Line.from(spans);
+
+ if (hSkip <= 0) {
+ return full;
+ }
+ List<Span> scrolled = new ArrayList<>();
+ int skipped = 0;
+ for (Span span : full.spans()) {
+ String content = span.content();
+ if (skipped >= hSkip) {
+ scrolled.add(span);
+ } else if (skipped + content.length() > hSkip) {
+ int offset = hSkip - skipped;
+ scrolled.add(Span.styled(content.substring(offset), span.style()));
+ skipped = hSkip;
+ } else {
+ skipped += content.length();
+ }
+ }
+ return scrolled.isEmpty() ? Line.from(List.of(Span.raw(""))) : Line.from(scrolled);
+ }
+
+ static int findLicenseHeaderEnd(List<JsonObject> codeLines) {
+ boolean inBlock = false;
+ int lastCommentLine = -1;
+ for (int i = 0; i < codeLines.size(); i++) {
+ String code = objToString(codeLines.get(i).get("code")).trim();
+ if (i == 0 && code.isEmpty()) {
+ continue;
+ }
+ if (!inBlock && code.startsWith("/*")) {
+ inBlock = true;
+ }
+ if (inBlock) {
+ lastCommentLine = i;
+ if (code.contains("*/")) {
+ inBlock = false;
+ }
+ continue;
+ }
+ if (code.startsWith("#") || code.startsWith("##") || code.startsWith("<!--")) {
+ lastCommentLine = i;
+ continue;
+ }
+ if (lastCommentLine >= 0 && code.isEmpty()) {
+ lastCommentLine = i;
+ continue;
+ }
+ break;
+ }
+ return lastCommentLine >= 0 ? lastCommentLine + 1 : 0;
+ }
+
+ private static String objToString(Object o) {
+ return o != null ? o.toString() : "";
+ }
+}
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 c3a37e17e602..4932e49c4e74 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
@@ -165,6 +165,7 @@ final class StatusParser {
JsonObject rj = (JsonObject) r;
RouteInfo ri = new RouteInfo();
ri.routeId = rj.getString("routeId");
+ ri.description = rj.getString("description");
ri.group = rj.getString("group");
ri.from = rj.getString("from");
ri.state = rj.getString("state");
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StopAllPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StopAllPopup.java
index bbf7d69750de..377723feedff 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StopAllPopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StopAllPopup.java
@@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
import java.nio.file.Path;
import java.util.List;
+import java.util.Set;
import java.util.function.Supplier;
import dev.tamboui.layout.Rect;
@@ -42,6 +43,8 @@ class StopAllPopup {
private final Supplier<List<IntegrationInfo>> integrations;
private final Supplier<List<InfraInfo>> infraServices;
+ private final Runnable burstCallback;
+ private final Set<String> stoppingPids;
private boolean visible;
private boolean checkIntegrations = true;
@@ -52,9 +55,12 @@ class StopAllPopup {
private String notification;
- StopAllPopup(Supplier<List<IntegrationInfo>> integrations, Supplier<List<InfraInfo>> infraServices) {
+ StopAllPopup(Supplier<List<IntegrationInfo>> integrations, Supplier<List<InfraInfo>> infraServices,
+ Runnable burstCallback, Set<String> stoppingPids) {
this.integrations = integrations;
this.infraServices = infraServices;
+ this.burstCallback = burstCallback;
+ this.stoppingPids = stoppingPids;
}
boolean isVisible() {
@@ -82,10 +88,12 @@ class StopAllPopup {
if (integrationCount > 0 && infraCount == 0) {
stopIntegrations();
+ burstCallback.run();
return;
}
if (infraCount > 0 && integrationCount == 0) {
stopInfraServices();
+ burstCallback.run();
return;
}
@@ -175,6 +183,9 @@ class StopAllPopup {
if (checkInfra) {
stoppedInfra = stopInfraServices();
}
+ if (stoppedInt > 0 || stoppedInfra > 0) {
+ burstCallback.run();
+ }
if (stoppedInt == 0 && stoppedInfra == 0 && notification == null) {
notification = "Nothing selected to stop";
}
@@ -189,7 +200,10 @@ class StopAllPopup {
}
try {
long pid = Long.parseLong(info.pid);
- ProcessHandle.of(pid).ifPresent(ProcessHandle::destroy);
+ ProcessHandle.of(pid).ifPresent(ph -> {
+ stoppingPids.add(info.pid);
+ ph.destroy();
+ });
count++;
} catch (NumberFormatException e) {
// skip
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java
new file mode 100644
index 000000000000..2c24681749f0
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/DiagramColors.java
@@ -0,0 +1,74 @@
+/*
+ * 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.diagram;
+
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+
+public final class DiagramColors {
+
+ static final Color OK_COLOR = Color.GREEN;
+ static final Color FAIL_COLOR = Color.LIGHT_RED;
+ static final Color EXTERNAL_COLOR = Color.CYAN;
+
+ static final Style BORDER_STYLE = Style.EMPTY.fg(Color.WHITE);
+ static final Style DASHED_BORDER_STYLE = Style.EMPTY.fg(Color.CYAN);
+ static final Style SELECTION_STYLE = Style.EMPTY.bg(Color.DARK_GRAY);
+ static final Style ROUTE_ID_STYLE = Style.EMPTY.fg(Color.WHITE).bold();
+ static final Style FROM_LABEL_STYLE = Style.EMPTY.fg(Color.GRAY);
+ static final Style METRICS_OK_STYLE = Style.EMPTY.fg(Color.GREEN);
+ static final Style METRICS_FAIL_STYLE = Style.EMPTY.fg(Color.LIGHT_RED).bold();
+
+ // Unicode box-drawing characters
+ static final char H = '─';
+ static final char V = '│';
+ static final char TL = '┌';
+ static final char TR = '┐';
+ static final char BL = '└';
+ static final char BR = '┘';
+ static final char T_DOWN = '┬';
+ static final char T_UP = '┴';
+ static final char CROSS = '┼';
+ static final char ARROW = '▼';
+ static final char DASH_V = '┆';
+ static final char DASH_H = '┄';
+
+ private DiagramColors() {
+ }
+
+ public static Color getEipColor(String type) {
+ if (type == null) {
+ return Color.GRAY;
+ }
+ return switch (type) {
+ case "from" -> Color.GREEN;
+ case "to", "toD", "wireTap", "enrich", "pollEnrich" -> Color.CYAN;
+ case "choice", "when", "otherwise" -> Color.YELLOW;
+ case "marshal", "unmarshal", "transform", "setBody", "setHeader", "setProperty",
+ "convertBodyTo", "removeHeader", "removeHeaders", "removeProperty", "removeProperties" ->
+ Color.CYAN;
+ case "bean", "process", "log", "script", "delay" -> Color.MAGENTA;
+ case "filter", "split", "aggregate", "multicast", "recipientList",
+ "routingSlip", "dynamicRouter", "loadBalance",
+ "circuitBreaker", "saga", "doTry", "doCatch", "doFinally",
+ "onException", "onCompletion", "intercept",
+ "loop", "resequence", "throttle", "kamelet", "pipeline", "threads" ->
+ Color.rgb(0x89, 0x57, 0xE5);
+ default -> Color.GRAY;
+ };
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
new file mode 100644
index 000000000000..ae2adf1d1c4a
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
@@ -0,0 +1,509 @@
+/*
+ * 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.diagram;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.widget.Widget;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.Bounds;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutNode;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.StatInfo;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.TreeNode;
+
+import static org.apache.camel.diagram.RouteDiagramLayoutEngine.BRANCH_CHILD_TYPES;
+import static org.apache.camel.diagram.RouteDiagramLayoutEngine.PADDING;
+import static org.apache.camel.diagram.RouteDiagramLayoutEngine.SCOPE_BOX_PAD;
+import static org.apache.camel.dsl.jbang.core.commands.tui.diagram.DiagramColors.*;
+
+public class RouteDiagramWidget implements Widget {
+
+ private static final int Y_SCALE = 20;
+ private static final int MIN_BOX_WIDTH = 16;
+ private static final int X_DIVISOR = 15;
+ private static final int MAX_WRAP_LINES = 3;
+
+ // Dashed scope box characters
+ private static final char SCOPE_H = '╌';
+ private static final char SCOPE_V = '╎';
+
+ private final LayoutRoute layoutRoute;
+ private final int nodeWidth;
+ private final int boxWidth;
+ private final int selectedNodeIndex;
+ private final int scrollX;
+ private final int scrollY;
+ private final boolean showMetrics;
+ private final Map<String, String> linkableEndpoints;
+
+ private final boolean showDescription;
+ private final Map<String, String> routeDescriptions;
+ private final String currentRouteLabel;
+
+ private final List<EipNodeBox> nodeBoxes = new ArrayList<>();
+
+ public record EipNodeBox(String nodeId, String type, int startRow, int endRow, int startCol, int endCol,
+ LayoutNode layoutNode) {
+ }
+
+ public RouteDiagramWidget(
+ LayoutRoute layoutRoute, int nodeWidth,
+ int selectedNodeIndex, int scrollX, int scrollY,
+ boolean showMetrics) {
+ this(layoutRoute, nodeWidth, selectedNodeIndex, scrollX, scrollY, showMetrics,
+ Collections.emptyMap(), false, Collections.emptyMap());
+ }
+
+ public RouteDiagramWidget(
+ LayoutRoute layoutRoute, int nodeWidth,
+ int selectedNodeIndex, int scrollX, int scrollY,
+ boolean showMetrics, Map<String, String> linkableEndpoints) {
+ this(layoutRoute, nodeWidth, selectedNodeIndex, scrollX, scrollY, showMetrics,
+ linkableEndpoints, false, Collections.emptyMap());
+ }
+
+ public RouteDiagramWidget(
+ LayoutRoute layoutRoute, int nodeWidth,
+ int selectedNodeIndex, int scrollX, int scrollY,
+ boolean showMetrics, Map<String, String> linkableEndpoints,
+ boolean showDescription, Map<String, String> routeDescriptions) {
+ this.layoutRoute = layoutRoute;
+ this.nodeWidth = nodeWidth;
+ this.boxWidth = Math.max(MIN_BOX_WIDTH, nodeWidth / X_DIVISOR);
+ this.selectedNodeIndex = selectedNodeIndex;
+ this.scrollX = scrollX;
+ this.scrollY = scrollY;
+ this.showMetrics = showMetrics;
+ this.linkableEndpoints = linkableEndpoints;
+ this.showDescription = showDescription;
+ this.routeDescriptions = routeDescriptions;
+ if (showDescription) {
+ String desc = null;
+ for (LayoutNode ln : layoutRoute.nodes) {
+ if ("route".equals(ln.type) && ln.treeNode != null) {
+ desc = ln.treeNode.info.description;
+ break;
+ }
+ }
+ this.currentRouteLabel = (desc != null && !desc.isBlank()) ? desc : layoutRoute.routeId;
+ } else {
+ this.currentRouteLabel = null;
+ }
+ }
+
+ public List<EipNodeBox> getNodeBoxes() {
+ return nodeBoxes;
+ }
+
+ @Override
+ public void render(Rect area, Buffer buffer) {
+ nodeBoxes.clear();
+
+ // Scope boxes (behind everything)
+ for (LayoutNode ln : layoutRoute.nodes) {
+ if (!"route".equals(ln.type)
+ && ln.treeNode != null && RouteDiagramLayoutEngine.hasScope(ln.treeNode)) {
+ drawScopeBox(buffer, area, ln);
+ }
+ }
+
+ // Edges
+ for (LayoutNode ln : layoutRoute.nodes) {
+ if (!"route".equals(ln.type) && ln.parentNode != null
+ && !"route".equals(ln.parentNode.type)) {
+ if (ln.connectFromMerge) {
+ drawMergeArrow(buffer, area, ln);
+ } else {
+ drawArrow(buffer, area, ln.parentNode, ln);
+ }
+ }
+ }
+
+ // Nodes (on top, skip the structural "route" node)
+ for (LayoutNode ln : layoutRoute.nodes) {
+ if (!"route".equals(ln.type)) {
+ drawNode(buffer, area, ln);
+ }
+ }
+ }
+
+ public int getTotalRows() {
+ return toRow(layoutRoute.maxY) + 10;
+ }
+
+ public int getTotalCols() {
+ return toCol(layoutRoute.maxX + PADDING) + boxWidth + 4;
+ }
+
+ private void drawNode(Buffer buffer, Rect area, LayoutNode node) {
+ int col = toCol(node.x);
+ int row = toRow(node.y);
+ int innerWidth = boxWidth - 4;
+ List<String> lines = rewrapText(node, innerWidth);
+ int height = 2 + lines.size();
+
+ int nodeIdx = nodeBoxes.size();
+ boolean selected = nodeIdx == selectedNodeIndex;
+ boolean external = isExternalEndpoint(node);
+
+ Color eipColor = getEipColor(node.type);
+ Style borderStyle = Style.EMPTY.fg(eipColor);
+ if (selected) {
+ borderStyle = borderStyle.patch(SELECTION_STYLE);
+ }
+
+ char hChar = external ? DASH_H : H;
+ char vChar = external ? DASH_V : V;
+
+ // Top border
+ setChar(buffer, area, row, col, TL, borderStyle);
+ for (int c = col + 1; c < col + boxWidth - 1; c++) {
+ setChar(buffer, area, row, c, hChar, borderStyle);
+ }
+ setChar(buffer, area, row, col + boxWidth - 1, TR, borderStyle);
+
+ // Bottom border
+ int bottom = row + height - 1;
+ setChar(buffer, area, bottom, col, BL, borderStyle);
+ for (int c = col + 1; c < col + boxWidth - 1; c++) {
+ setChar(buffer, area, bottom, c, hChar, borderStyle);
+ }
+ setChar(buffer, area, bottom, col + boxWidth - 1, BR, borderStyle);
+
+ // Content rows
+ for (int i = 0; i < lines.size(); i++) {
+ int r = row + 1 + i;
+ setChar(buffer, area, r, col, vChar, borderStyle);
+ setChar(buffer, area, r, col + boxWidth - 1, vChar, borderStyle);
+
+ Style bgStyle = selected ? SELECTION_STYLE : Style.EMPTY;
+ for (int c = col + 1; c < col + boxWidth - 1; c++) {
+ setChar(buffer, area, r, c, ' ', bgStyle);
+ }
+
+ String text = lines.get(i);
+ if (text.length() > innerWidth) {
+ text = text.substring(0, Math.max(1, innerWidth - 3)) + "...";
+ }
+ int textCol = col + 2 + Math.max(0, (innerWidth - text.length()) / 2);
+
+ // First line: [type] tag with EIP color, rest: label text
+ if (i == 0) {
+ writeText(buffer, area, r, textCol, text, style(Style.EMPTY.fg(eipColor).bold(), selected));
+ } else {
+ writeText(buffer, area, r, textCol, text, style(FROM_LABEL_STYLE, selected));
+ }
+ }
+
+ // Link indicator for nodes that connect to other routes
+ String linkedRouteId = findLinkedRouteId(node);
+ if (linkedRouteId != null) {
+ Style linkStyle = Style.EMPTY.fg(Color.YELLOW).bold();
+ writeText(buffer, area, bottom, col + boxWidth, " ↵ " + linkedRouteId, linkStyle);
+ }
+
+ nodeBoxes.add(new EipNodeBox(node.id, node.type, row, row + height - 1, col, col + boxWidth - 1, node));
+ }
+
+ private void drawArrow(Buffer buffer, Rect area, LayoutNode from, LayoutNode to) {
+ int fromCx = centerCol(from);
+ int fromBottom = toRow(from.y) + boxHeight(from);
+ int toCx = centerCol(to);
+ int toTop = getTopRow(to);
+
+ StatInfo stat = resolveStatInfo(to);
+ long total = stat != null ? stat.exchangesTotal : 0;
+ boolean external = isExternalEndpoint(to);
+ boolean dashed = (showMetrics && total == 0) || external;
+
+ drawArrowPath(buffer, area, fromCx, fromBottom, toCx, toTop, dashed, external);
+ drawCounters(buffer, area, toCx, toTop, stat);
+ }
+
+ private void drawMergeArrow(Buffer buffer, Rect area, LayoutNode to) {
+ int fromCx = toCol(to.mergeCx);
+ int fromRow = toRow(to.mergeY);
+ int toCx = centerCol(to);
+ int toTop = getTopRow(to);
+
+ StatInfo stat = showMetrics && to.treeNode != null ? to.treeNode.info.stat : null;
+ long total = stat != null ? stat.exchangesTotal : 0;
+ boolean dashed = showMetrics && total == 0;
+
+ drawArrowPath(buffer, area, fromCx, fromRow, toCx, toTop, dashed, false);
+ drawCounters(buffer, area, toCx, toTop, stat);
+ }
+
+ private void drawArrowPath(
+ Buffer buffer, Rect area, int fromCx, int fromRow, int toCx, int toRow,
+ boolean dashed, boolean external) {
+ if (fromRow >= toRow) {
+ return;
+ }
+
+ char vChar = dashed ? DASH_V : V;
+ char hChar = dashed ? DASH_H : H;
+ Style edgeStyle;
+ if (external) {
+ edgeStyle = Style.EMPTY.fg(EXTERNAL_COLOR);
+ } else if (dashed) {
+ edgeStyle = Style.EMPTY.fg(Color.DARK_GRAY);
+ } else {
+ edgeStyle = Style.EMPTY.fg(Color.GRAY);
+ }
+
+ if (fromCx == toCx) {
+ for (int r = fromRow; r < toRow - 1; r++) {
+ setChar(buffer, area, r, fromCx, vChar, edgeStyle);
+ }
+ setChar(buffer, area, toRow - 1, toCx, ARROW, edgeStyle);
+ } else {
+ int midRow = fromRow + (toRow - fromRow) / 2;
+
+ for (int r = fromRow; r < midRow; r++) {
+ setChar(buffer, area, r, fromCx, vChar, edgeStyle);
+ }
+
+ int minC = Math.min(fromCx, toCx);
+ int maxC = Math.max(fromCx, toCx);
+ for (int c = minC; c <= maxC; c++) {
+ setChar(buffer, area, midRow, c, hChar, edgeStyle);
+ }
+
+ setChar(buffer, area, midRow, fromCx, T_UP, edgeStyle);
+ setChar(buffer, area, midRow, toCx, T_DOWN, edgeStyle);
+
+ for (int r = midRow + 1; r < toRow - 1; r++) {
+ setChar(buffer, area, r, toCx, vChar, edgeStyle);
+ }
+ setChar(buffer, area, toRow - 1, toCx, ARROW, edgeStyle);
+ }
+ }
+
+ private void drawCounters(Buffer buffer, Rect area, int toCx, int toTop, StatInfo stat) {
+ if (!showMetrics || stat == null) {
+ return;
+ }
+ long total = stat.exchangesTotal;
+ long failed = stat.exchangesFailed;
+ long ok = total - failed;
+ if (ok > 0) {
+ String okStr = String.valueOf(ok);
+ writeText(buffer, area, toTop - 1, toCx + 2, okStr, METRICS_OK_STYLE);
+ }
+ if (failed > 0) {
+ String failStr = String.valueOf(failed);
+ int col = toCx - 1 - failStr.length();
+ writeText(buffer, area, toTop - 1, col, failStr, METRICS_FAIL_STYLE);
+ }
+ }
+
+ private void drawScopeBox(Buffer buffer, Rect area, LayoutNode scopeNode) {
+ TreeNode tn = scopeNode.treeNode;
+ Bounds bounds = new Bounds(
+ scopeNode.x, scopeNode.y,
+ scopeNode.x + nodeWidth, scopeNode.y + scopeNode.height);
+ for (TreeNode child : tn.children) {
+ RouteDiagramLayoutEngine.expandBoundsForBox(child, bounds, nodeWidth);
+ }
+
+ int col1 = toCol(bounds.minX - SCOPE_BOX_PAD);
+ int row1 = toRow(bounds.minY - SCOPE_BOX_PAD);
+ int col2 = toCol(bounds.maxX + SCOPE_BOX_PAD);
+ int row2 = toRow(bounds.maxY + SCOPE_BOX_PAD);
+
+ Color scopeColor = getEipColor(scopeNode.type);
+ Style scopeStyle = Style.EMPTY.fg(scopeColor).dim();
+
+ for (int c = col1; c <= col2; c++) {
+ setChar(buffer, area, row1, c, SCOPE_H, scopeStyle);
+ setChar(buffer, area, row2, c, SCOPE_H, scopeStyle);
+ }
+ for (int r = row1 + 1; r < row2; r++) {
+ setChar(buffer, area, r, col1, SCOPE_V, scopeStyle);
+ setChar(buffer, area, r, col2, SCOPE_V, scopeStyle);
+ }
+ }
+
+ private StatInfo resolveStatInfo(LayoutNode to) {
+ if (!showMetrics || to.treeNode == null) {
+ return null;
+ }
+ StatInfo stat = to.treeNode.info.stat;
+ if (BRANCH_CHILD_TYPES.contains(to.type) && !to.treeNode.children.isEmpty()) {
+ stat = to.treeNode.children.get(0).info.stat;
+ }
+ return stat;
+ }
+
+ private int getTopRow(LayoutNode node) {
+ if (node.treeNode != null && RouteDiagramLayoutEngine.hasScope(node.treeNode)) {
+ return toRow(node.y - SCOPE_BOX_PAD);
+ }
+ return toRow(node.y);
+ }
+
+ private int centerCol(LayoutNode node) {
+ return toCol(node.x + nodeWidth / 2);
+ }
+
+ private int boxHeight(LayoutNode node) {
+ return 2 + rewrapText(node, boxWidth - 4).size();
+ }
+
+ private List<String> rewrapText(LayoutNode node, int maxWidth) {
+ String label = String.join("", node.wrappedLines);
+ if (showDescription) {
+ if ("from".equals(node.type) && currentRouteLabel != null) {
+ label = currentRouteLabel;
+ } else {
+ String linkedRouteId = findLinkedRouteId(node);
+ if (linkedRouteId != null) {
+ String desc = routeDescriptions.get(linkedRouteId);
+ if (desc != null && !desc.isBlank()) {
+ label = desc;
+ }
+ }
+ }
+ }
+ return wrapText(label, maxWidth);
+ }
+
+ static List<String> wrapText(String text, int maxWidth) {
+ if (maxWidth <= 0 || text.length() <= maxWidth) {
+ return new ArrayList<>(List.of(text));
+ }
+
+ List<String> lines = new ArrayList<>();
+ String remaining = text;
+
+ while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) {
+ if (remaining.length() <= maxWidth) {
+ lines.add(remaining);
+ remaining = "";
+ break;
+ }
+
+ int breakAt = -1;
+ for (int i = 0; i < maxWidth && i < remaining.length(); i++) {
+ char c = remaining.charAt(i);
+ if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ',' || c == '&' || c == '?') {
+ breakAt = i + 1;
+ }
+ }
+ if (breakAt <= 0) {
+ breakAt = maxWidth;
+ }
+
+ lines.add(remaining.substring(0, breakAt).stripTrailing());
+ remaining = remaining.substring(breakAt).stripLeading();
+ }
+
+ if (!remaining.isEmpty()) {
+ int lastIdx = lines.size() - 1;
+ String lastLine = lines.get(lastIdx);
+ String combined = lastLine + remaining;
+ lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth - 3)) + "...");
+ }
+
+ return lines;
+ }
+
+ private void setChar(Buffer buffer, Rect area, int gridRow, int gridCol, char ch, Style style) {
+ int x = area.x() + gridCol - scrollX;
+ int y = area.y() + gridRow - scrollY;
+ if (x >= area.left() && x < area.right() && y >= area.top() && y < area.bottom()) {
+ buffer.setString(x, y, String.valueOf(ch), style);
+ }
+ }
+
+ private void writeText(Buffer buffer, Rect area, int gridRow, int gridCol, String text, Style style) {
+ int x = area.x() + gridCol - scrollX;
+ int y = area.y() + gridRow - scrollY;
+ if (y >= area.top() && y < area.bottom() && x < area.right()) {
+ int startIdx = 0;
+ if (x < area.left()) {
+ startIdx = area.left() - x;
+ x = area.left();
+ }
+ if (startIdx < text.length()) {
+ int maxLen = area.right() - x;
+ String visible = text.substring(startIdx, Math.min(text.length(), startIdx + maxLen));
+ buffer.setString(x, y, visible, style);
+ }
+ }
+ }
+
+ private Style style(Style base, boolean selected) {
+ return selected ? base.patch(SELECTION_STYLE) : base;
+ }
+
+ private int toCol(int pixelX) {
+ if (nodeWidth == 0) {
+ return 0;
+ }
+ return pixelX * boxWidth / nodeWidth;
+ }
+
+ private boolean isExternalEndpoint(LayoutNode node) {
+ if (node.treeNode == null) {
+ return false;
+ }
+ return node.treeNode.info.remote;
+ }
+
+ private String findLinkedRouteId(LayoutNode node) {
+ if (linkableEndpoints.isEmpty() || node.treeNode == null) {
+ return null;
+ }
+ String type = node.type;
+ if (!"to".equals(type) && !"toD".equals(type) && !"wireTap".equals(type)
+ && !"enrich".equals(type) && !"pollEnrich".equals(type)
+ && !"from".equals(type)) {
+ return null;
+ }
+ String baseUri = getBaseUri(node.treeNode.info);
+ return baseUri != null ? linkableEndpoints.get(baseUri) : null;
+ }
+
+ private static String getBaseUri(RouteDiagramLayoutEngine.NodeInfo info) {
+ String uri = info.uri;
+ if (uri == null) {
+ String code = info.code;
+ if (code == null) {
+ return null;
+ }
+ int open = code.indexOf('[');
+ int close = code.lastIndexOf(']');
+ uri = (open >= 0 && close > open) ? code.substring(open + 1, close) : code;
+ }
+ int q = uri.indexOf('?');
+ return q >= 0 ? uri.substring(0, q) : uri;
+ }
+
+ private int toRow(int pixelY) {
+ return pixelY / Y_SCALE;
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
new file mode 100644
index 000000000000..7b4030d1e02a
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
@@ -0,0 +1,399 @@
+/*
+ * 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.diagram;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.widget.Widget;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutEdge;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.diagram.DiagramColors.*;
+
+public class TopologyDiagramWidget implements Widget {
+
+ private static final int Y_SCALE = 20;
+ private static final int MIN_BOX_WIDTH = 16;
+ private static final int X_DIVISOR = 15;
+ private static final int MAX_WRAP_LINES = 3;
+
+ private final TopologyLayoutResult layout;
+ private final int nodeWidth;
+ private final int boxWidth;
+ private final int selectedNodeIndex;
+ private final int scrollX;
+ private final int scrollY;
+ private final boolean showMetrics;
+ private final boolean showDescription;
+
+ private final List<NodeBox> nodeBoxes = new ArrayList<>();
+
+ public record NodeBox(String routeId, int startRow, int endRow, int startCol, int endCol, int layer) {
+ }
+
+ public TopologyDiagramWidget(
+ TopologyLayoutResult layout, int nodeWidth,
+ int selectedNodeIndex, int scrollX, int scrollY,
+ boolean showMetrics, boolean showDescription) {
+ this.layout = layout;
+ this.nodeWidth = nodeWidth;
+ this.boxWidth = Math.max(MIN_BOX_WIDTH, nodeWidth / X_DIVISOR);
+ this.selectedNodeIndex = selectedNodeIndex;
+ this.scrollX = scrollX;
+ this.scrollY = scrollY;
+ this.showMetrics = showMetrics;
+ this.showDescription = showDescription;
+ }
+
+ public List<NodeBox> getNodeBoxes() {
+ return nodeBoxes;
+ }
+
+ @Override
+ public void render(Rect area, Buffer buffer) {
+ nodeBoxes.clear();
+
+ for (TopologyLayoutEdge edge : layout.edges) {
+ if (!edge.selfLoop) {
+ drawEdge(buffer, area, edge);
+ }
+ }
+
+ for (TopologyLayoutEdge edge : layout.edges) {
+ if (edge.selfLoop) {
+ drawSelfLoop(buffer, area, edge);
+ }
+ }
+
+ for (TopologyLayoutNode node : layout.nodes) {
+ drawNode(buffer, area, node);
+ }
+ }
+
+ public int getTotalRows() {
+ return toRow(layout.totalHeight) + 10;
+ }
+
+ public int getTotalCols() {
+ return toCol(layout.totalWidth) + boxWidth + 4;
+ }
+
+ private void drawNode(Buffer buffer, Rect area, TopologyLayoutNode node) {
+ int col = toCol(node.x);
+ int row = toRow(node.y);
+
+ boolean ext = isExternal(node);
+
+ String line1;
+ if (ext) {
+ line1 = node.from;
+ } else if (showDescription && node.description != null && !node.description.isBlank()) {
+ line1 = node.description;
+ } else {
+ line1 = node.routeId;
+ }
+
+ List<String> lines = new ArrayList<>(wrapText(line1, boxWidth - 4));
+ if (!ext && !showDescription) {
+ String line2 = "(" + node.from + ")";
+ List<String> fromLines = wrapText(line2, boxWidth - 4);
+ lines.addAll(fromLines);
+ if (fromLines.size() < 2) {
+ lines.add("");
+ }
+ }
+
+ if (showMetrics) {
+ if (node.exchangesTotal > 0 || node.exchangesFailed > 0) {
+ long ok = node.exchangesTotal - node.exchangesFailed;
+ StringBuilder sb = new StringBuilder();
+ if (ok > 0) {
+ sb.append(ok);
+ }
+ if (node.exchangesFailed > 0) {
+ if (!sb.isEmpty()) {
+ sb.append("/");
+ }
+ sb.append(node.exchangesFailed).append("!");
+ }
+ lines.add(sb.toString());
+ } else if (!ext) {
+ lines.add("");
+ }
+ }
+
+ while (lines.size() > MAX_WRAP_LINES + 1) {
+ lines.remove(lines.size() - 1);
+ }
+
+ int height = 2 + lines.size();
+ int nodeIdx = nodeBoxes.size();
+ boolean selected = nodeIdx == selectedNodeIndex;
+
+ char hChar = ext ? DASH_H : H;
+ char vChar = ext ? DASH_V : V;
+ Style borderStyle = ext ? DASHED_BORDER_STYLE : BORDER_STYLE;
+ if (selected) {
+ borderStyle = borderStyle.patch(SELECTION_STYLE);
+ }
+
+ // Top border
+ setChar(buffer, area, row, col, TL, borderStyle);
+ for (int c = col + 1; c < col + boxWidth - 1; c++) {
+ setChar(buffer, area, row, c, hChar, borderStyle);
+ }
+ setChar(buffer, area, row, col + boxWidth - 1, TR, borderStyle);
+
+ // Bottom border
+ int bottom = row + height - 1;
+ setChar(buffer, area, bottom, col, BL, borderStyle);
+ for (int c = col + 1; c < col + boxWidth - 1; c++) {
+ setChar(buffer, area, bottom, c, hChar, borderStyle);
+ }
+ setChar(buffer, area, bottom, col + boxWidth - 1, BR, borderStyle);
+
+ // Content rows
+ int innerWidth = boxWidth - 4;
+ for (int i = 0; i < lines.size(); i++) {
+ int r = row + 1 + i;
+ setChar(buffer, area, r, col, vChar, borderStyle);
+ setChar(buffer, area, r, col + boxWidth - 1, vChar, borderStyle);
+
+ // Clear interior
+ Style bgStyle = selected ? SELECTION_STYLE : Style.EMPTY;
+ for (int c = col + 1; c < col + boxWidth - 1; c++) {
+ setChar(buffer, area, r, c, ' ', bgStyle);
+ }
+
+ String text = lines.get(i);
+ if (text.length() > innerWidth) {
+ text = text.substring(0, Math.max(1, innerWidth - 3)) + "...";
+ }
+ int textCol = col + 2 + Math.max(0, (innerWidth - text.length()) / 2);
+
+ // Choose style based on content type
+ if (ext && i == 0) {
+ writeText(buffer, area, r, textCol, text, style(DASHED_BORDER_STYLE, selected));
+ } else if (showMetrics && i == lines.size() - 1 && node.exchangesTotal > 0) {
+ drawMetricsLine(buffer, area, r, textCol, text, node, selected);
+ } else if (i == 0 && !ext) {
+ writeText(buffer, area, r, textCol, text, style(ROUTE_ID_STYLE, selected));
+ } else {
+ writeText(buffer, area, r, textCol, text, style(FROM_LABEL_STYLE, selected));
+ }
+ }
+
+ nodeBoxes.add(new NodeBox(node.routeId, row, row + height - 1, col, col + boxWidth - 1, node.layer));
+ }
+
+ private void drawMetricsLine(
+ Buffer buffer, Rect area, int row, int col, String text,
+ TopologyLayoutNode node, boolean selected) {
+ long ok = node.exchangesTotal - node.exchangesFailed;
+ if (ok > 0 && node.exchangesFailed > 0) {
+ String okStr = String.valueOf(ok);
+ String failStr = node.exchangesFailed + "!";
+ writeText(buffer, area, row, col, okStr, style(METRICS_OK_STYLE, selected));
+ int slashCol = col + okStr.length();
+ writeText(buffer, area, row, slashCol, "/", style(Style.EMPTY.fg(Color.GRAY), selected));
+ writeText(buffer, area, row, slashCol + 1, failStr, style(METRICS_FAIL_STYLE, selected));
+ } else if (ok > 0) {
+ writeText(buffer, area, row, col, text, style(METRICS_OK_STYLE, selected));
+ } else if (node.exchangesFailed > 0) {
+ writeText(buffer, area, row, col, text, style(METRICS_FAIL_STYLE, selected));
+ }
+ }
+
+ private void drawEdge(Buffer buffer, Rect area, TopologyLayoutEdge edge) {
+ int fromCx = toCol(edge.from.x + edge.from.width / 2);
+ int fromBottom = toRow(edge.from.y) + boxHeight(edge.from);
+ int toCx = toCol(edge.to.x + edge.to.width / 2);
+ int toTop = toRow(edge.to.y);
+
+ if (fromBottom >= toTop) {
+ return;
+ }
+
+ boolean dashed = isExternal(edge.from) || isExternal(edge.to);
+ char vChar = dashed ? DASH_V : V;
+ char hChar = dashed ? DASH_H : H;
+ Style edgeStyle = dashed ? DASHED_BORDER_STYLE : Style.EMPTY.fg(Color.GRAY);
+
+ if (fromCx == toCx) {
+ for (int r = fromBottom; r < toTop - 1; r++) {
+ plotLine(buffer, area, r, fromCx, vChar, edgeStyle);
+ }
+ setChar(buffer, area, toTop - 1, toCx, ARROW, edgeStyle);
+ } else {
+ int midRow = fromBottom + (toTop - fromBottom) / 2;
+
+ for (int r = fromBottom; r < midRow; r++) {
+ plotLine(buffer, area, r, fromCx, vChar, edgeStyle);
+ }
+
+ int minC = Math.min(fromCx, toCx);
+ int maxC = Math.max(fromCx, toCx);
+ for (int c = minC; c <= maxC; c++) {
+ plotLine(buffer, area, midRow, c, hChar, edgeStyle);
+ }
+
+ setChar(buffer, area, midRow, fromCx, T_UP, edgeStyle);
+ setChar(buffer, area, midRow, toCx, T_DOWN, edgeStyle);
+
+ for (int r = midRow + 1; r < toTop - 1; r++) {
+ plotLine(buffer, area, r, toCx, vChar, edgeStyle);
+ }
+ setChar(buffer, area, toTop - 1, toCx, ARROW, edgeStyle);
+ }
+ }
+
+ private void drawSelfLoop(Buffer buffer, Rect area, TopologyLayoutEdge edge) {
+ int col = toCol(edge.from.x) + boxWidth;
+ int topRow = toRow(edge.from.y) + 1;
+ int botRow = topRow + 2;
+
+ Style s = Style.EMPTY.fg(Color.GRAY);
+ for (int c = col; c < col + 3; c++) {
+ setChar(buffer, area, topRow, c, H, s);
+ setChar(buffer, area, botRow, c, H, s);
+ }
+ setChar(buffer, area, topRow + 1, col + 2, V, s);
+ setChar(buffer, area, topRow, col + 2, TR, s);
+ setChar(buffer, area, botRow, col + 2, BR, s);
+ }
+
+ private int boxHeight(TopologyLayoutNode node) {
+ if (isExternal(node)) {
+ int lines = 1;
+ if (showMetrics && node.exchangesTotal > 0) {
+ lines++;
+ }
+ return 2 + lines;
+ }
+ String label = showDescription && node.description != null && !node.description.isBlank()
+ ? node.description : node.routeId;
+ int lines = wrapText(label, boxWidth - 4).size();
+ if (!showDescription) {
+ List<String> fromLines = wrapText("(" + node.from + ")", boxWidth - 4);
+ lines += fromLines.size();
+ if (fromLines.size() < 2) {
+ lines++;
+ }
+ }
+ if (showMetrics) {
+ lines++;
+ }
+ while (lines > MAX_WRAP_LINES + 1) {
+ lines--;
+ }
+ return 2 + lines;
+ }
+
+ private void setChar(Buffer buffer, Rect area, int gridRow, int gridCol, char ch, Style style) {
+ int x = area.x() + gridCol - scrollX;
+ int y = area.y() + gridRow - scrollY;
+ if (x >= area.left() && x < area.right() && y >= area.top() && y < area.bottom()) {
+ buffer.setString(x, y, String.valueOf(ch), style);
+ }
+ }
+
+ private void plotLine(Buffer buffer, Rect area, int gridRow, int gridCol, char ch, Style style) {
+ setChar(buffer, area, gridRow, gridCol, ch, style);
+ }
+
+ private void writeText(Buffer buffer, Rect area, int gridRow, int gridCol, String text, Style style) {
+ int x = area.x() + gridCol - scrollX;
+ int y = area.y() + gridRow - scrollY;
+ if (y >= area.top() && y < area.bottom() && x < area.right()) {
+ int startIdx = 0;
+ if (x < area.left()) {
+ startIdx = area.left() - x;
+ x = area.left();
+ }
+ if (startIdx < text.length()) {
+ int maxLen = area.right() - x;
+ String visible = text.substring(startIdx, Math.min(text.length(), startIdx + maxLen));
+ buffer.setString(x, y, visible, style);
+ }
+ }
+ }
+
+ private Style style(Style base, boolean selected) {
+ return selected ? base.patch(SELECTION_STYLE) : base;
+ }
+
+ private int toCol(int pixelX) {
+ if (nodeWidth == 0) {
+ return 0;
+ }
+ return pixelX * boxWidth / nodeWidth;
+ }
+
+ private int toRow(int pixelY) {
+ return pixelY / Y_SCALE;
+ }
+
+ private static boolean isExternal(TopologyLayoutNode node) {
+ return "external-in".equals(node.nodeType) || "external-out".equals(node.nodeType);
+ }
+
+ static List<String> wrapText(String text, int maxWidth) {
+ if (maxWidth <= 0 || text.length() <= maxWidth) {
+ return new ArrayList<>(List.of(text));
+ }
+
+ List<String> lines = new ArrayList<>();
+ String remaining = text;
+
+ while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) {
+ if (remaining.length() <= maxWidth) {
+ lines.add(remaining);
+ remaining = "";
+ break;
+ }
+
+ int breakAt = -1;
+ for (int i = 0; i < maxWidth && i < remaining.length(); i++) {
+ char c = remaining.charAt(i);
+ if (c == ' ' || c == ':' || c == '/' || c == '.' || c == ',' || c == '&' || c == '?') {
+ breakAt = i + 1;
+ }
+ }
+ if (breakAt <= 0) {
+ breakAt = maxWidth;
+ }
+
+ lines.add(remaining.substring(0, breakAt).stripTrailing());
+ remaining = remaining.substring(breakAt).stripLeading();
+ }
+
+ if (!remaining.isEmpty()) {
+ int lastIdx = lines.size() - 1;
+ String lastLine = lines.get(lastIdx);
+ String combined = lastLine + remaining;
+ lines.set(lastIdx, combined.substring(0, Math.max(1, maxWidth - 3)) + "...");
+ }
+
+ return lines;
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyMinimapWidget.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyMinimapWidget.java
new file mode 100644
index 000000000000..f18ae2803244
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyMinimapWidget.java
@@ -0,0 +1,103 @@
+/*
+ * 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.diagram;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.widget.Widget;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
+
+public class TopologyMinimapWidget implements Widget {
+
+ private static final Style CURRENT_STYLE = Style.EMPTY.fg(Color.YELLOW).bold();
+ private static final Style OTHER_STYLE = Style.EMPTY.fg(Color.DARK_GRAY);
+ private static final Style EXTERNAL_STYLE = Style.EMPTY.fg(Color.CYAN).dim();
+
+ private final TopologyLayoutResult layout;
+ private final String currentRouteId;
+
+ public TopologyMinimapWidget(TopologyLayoutResult layout, String currentRouteId) {
+ this.layout = layout;
+ this.currentRouteId = currentRouteId;
+ }
+
+ @Override
+ public void render(Rect area, Buffer buffer) {
+ if (layout == null || layout.nodes.isEmpty() || area.width() < 2 || area.height() < 1) {
+ return;
+ }
+
+ int mapW = area.width();
+ int mapH = area.height();
+ int totalW = layout.totalWidth;
+ int totalH = layout.totalHeight;
+
+ if (totalW <= 0 || totalH <= 0) {
+ return;
+ }
+
+ for (TopologyLayoutNode node : layout.nodes) {
+ boolean isCurrent = node.routeId != null && node.routeId.equals(currentRouteId);
+ boolean isExternal = "external-in".equals(node.nodeType) || "external-out".equals(node.nodeType);
+
+ int col = node.x * mapW / totalW;
+ int row = node.y * mapH / totalH;
+ int nodeW = Math.min(3, Math.max(1, node.width * mapW / totalW));
+ int nodeH = Math.max(1, node.height * mapH / totalH);
+
+ col = Math.min(col, mapW - nodeW);
+ row = Math.min(row, mapH - nodeH);
+ col = Math.max(0, col);
+ row = Math.max(0, row);
+
+ Style style;
+ if (isCurrent) {
+ style = CURRENT_STYLE;
+ } else if (isExternal) {
+ style = EXTERNAL_STYLE;
+ } else {
+ style = OTHER_STYLE;
+ }
+
+ drawMiniBox(buffer, area, col, row, nodeW, nodeH, style, isCurrent);
+ }
+ }
+
+ private void drawMiniBox(Buffer buffer, Rect area, int col, int row, int w, int h, Style style, boolean current) {
+ int sx = area.x() + col;
+ int sy = area.y() + row;
+
+ String fill = current ? "█" : "▪";
+
+ for (int r = 0; r < h; r++) {
+ int y = sy + r;
+ if (y >= area.y() + area.height()) {
+ break;
+ }
+ for (int c = 0; c < w; c++) {
+ int x = sx + c;
+ if (x >= area.x() + area.width()) {
+ break;
+ }
+ buffer.setString(x, y, fill, style);
+ }
+ }
+ }
+}