diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml index d74aa99b07ee0..64f490af9b893 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml @@ -49,6 +49,10 @@ org.apache.camel camel-catalog + + org.apache.camel + camel-diagram + dev.tamboui tamboui-tui @@ -64,6 +68,11 @@ tamboui-jline3-backend ${tamboui-version} + + dev.tamboui + tamboui-image + ${tamboui-version} + 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 3a5f9b5dde320..1d0c8ddbde593 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 @@ -37,6 +37,11 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; +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; @@ -51,6 +56,8 @@ import dev.tamboui.tui.event.Event; import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.MouseEvent; +import dev.tamboui.tui.event.MouseEventKind; import dev.tamboui.tui.event.TickEvent; import dev.tamboui.widgets.barchart.Bar; import dev.tamboui.widgets.barchart.BarChart; @@ -67,7 +74,10 @@ import dev.tamboui.widgets.tabs.TabsState; import org.apache.camel.dsl.jbang.core.commands.CamelCommand; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.diagram.RouteDiagramLayoutEngine; +import org.apache.camel.diagram.RouteDiagramRenderer; import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; +import org.apache.camel.dsl.jbang.core.common.PathUtils; import org.apache.camel.dsl.jbang.core.common.ProcessHelper; import org.apache.camel.dsl.jbang.core.common.VersionHelper; import org.apache.camel.util.TimeUtils; @@ -156,6 +166,14 @@ public class CamelMonitor extends CamelCommand { // Selected integration for detail views private String selectedPid; + // Diagram state + private boolean showDiagram; + private List diagramLines = Collections.emptyList(); + private int diagramScroll; + private String diagramRouteId; + private ImageData diagramImageData; + private ImageProtocol diagramProtocol; + private volatile long lastRefresh; private ClassLoader classLoader; @@ -188,9 +206,26 @@ public Integer doCall() throws Exception { // ---- Event Handling ---- private boolean handleEvent(Event event, TuiRunner runner) { + if (event instanceof MouseEvent me) { + if (showDiagram && diagramImageData == null && tabsState.selected() == TAB_ROUTES) { + if (me.kind() == MouseEventKind.SCROLL_UP) { + diagramScroll = Math.max(0, diagramScroll - 3); + return true; + } + if (me.kind() == MouseEventKind.SCROLL_DOWN) { + diagramScroll = Math.min(Math.max(0, diagramLines.size() - 1), diagramScroll + 3); + return true; + } + } + } if (event instanceof KeyEvent ke) { // Global keys if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) { + if (showDiagram) { + showDiagram = false; + diagramImageData = null; + return true; + } // If in a detail tab, go back to overview first if (tabsState.selected() != TAB_OVERVIEW) { tabsState.select(TAB_OVERVIEW); @@ -240,15 +275,25 @@ private boolean handleEvent(Event event, TuiRunner runner) { // Navigation (all tabs) if (ke.isUp()) { - navigateUp(); + if (showDiagram && diagramImageData == null && tab == TAB_ROUTES) { + diagramScroll = Math.max(0, diagramScroll - 1); + } else { + navigateUp(); + } return true; } if (ke.isDown()) { - navigateDown(); + if (showDiagram && diagramImageData == null && tab == TAB_ROUTES) { + diagramScroll = Math.min(Math.max(0, diagramLines.size() - 1), diagramScroll + 1); + } else { + navigateDown(); + } return true; } if (ke.isKey(KeyCode.PAGE_UP)) { - if (tab == TAB_LOG) { + if (showDiagram && diagramImageData == null && tab == TAB_ROUTES) { + diagramScroll = Math.max(0, diagramScroll - 20); + } else if (tab == TAB_LOG) { logFollowMode = false; for (int i = 0; i < 20; i++) { logTableState.selectPrevious(); @@ -257,7 +302,9 @@ private boolean handleEvent(Event event, TuiRunner runner) { return true; } if (ke.isKey(KeyCode.PAGE_DOWN)) { - if (tab == TAB_LOG) { + if (showDiagram && diagramImageData == null && tab == TAB_ROUTES) { + diagramScroll = Math.min(Math.max(0, diagramLines.size() - 1), diagramScroll + 20); + } else if (tab == TAB_LOG) { for (int i = 0; i < 20; i++) { logTableState.selectNext(filteredLogEntries.size()); } @@ -274,12 +321,21 @@ private boolean handleEvent(Event event, TuiRunner runner) { return true; } - // Routes tab: sort + // Routes tab: sort and diagram if (tab == TAB_ROUTES && ke.isCharIgnoreCase('s')) { routeSortIndex = (routeSortIndex + 1) % ROUTE_SORT_COLUMNS.length; routeSort = ROUTE_SORT_COLUMNS[routeSortIndex]; return true; } + if (tab == TAB_ROUTES && ke.isCharIgnoreCase('d')) { + if (showDiagram) { + showDiagram = false; + diagramImageData = null; + } else { + loadDiagramForSelectedRoute(); + } + return true; + } // Health tab: DOWN filter if (tab == TAB_HEALTH && ke.isCharIgnoreCase('d')) { @@ -682,20 +738,24 @@ private void renderRoutes(Frame frame, Rect area) { frame.renderStatefulWidget(routeTable, chunks.get(0), routeTableState); - // Processors for selected route - 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)); + // Bottom panel: diagram or processors + if (showDiagram && (diagramImageData != null || !diagramLines.isEmpty())) { + renderDiagram(frame, chunks.get(1)); } else { - frame.renderWidget( - Paragraph.builder() - .text(Text.from(Line.from(Span.styled("No routes", Style.create().dim())))) - .block(Block.builder().borderType(BorderType.ROUNDED).title(" Processors ").build()) - .build(), - chunks.get(1)); + 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.create().dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED).title(" Processors ").build()) + .build(), + chunks.get(1)); + } } } @@ -767,6 +827,253 @@ private void renderProcessors(Frame frame, Rect area, RouteInfo route) { frame.renderStatefulWidget(table, area, new TableState()); } + private void renderDiagram(Frame frame, Rect area) { + Block block = Block.builder() + .borderType(BorderType.ROUNDED) + .title(" Diagram [" + diagramRouteId + "] ") + .build(); + + if (diagramImageData != null) { + Image img = Image.builder() + .data(diagramImageData) + .protocol(diagramProtocol) + .scaling(ImageScaling.FIT) + .block(block) + .build(); + frame.renderWidget(img, area); + return; + } + + Rect inner = block.inner(area); + int visibleLines = inner.height(); + int maxScroll = Math.max(0, diagramLines.size() - visibleLines); + diagramScroll = Math.min(diagramScroll, maxScroll); + + List lines = new ArrayList<>(); + int end = Math.min(diagramScroll + visibleLines, diagramLines.size()); + for (int i = diagramScroll; i < end; i++) { + lines.add(styleDiagramLine(diagramLines.get(i))); + } + + Paragraph paragraph = Paragraph.builder() + .text(Text.from(lines)) + .block(block) + .build(); + + frame.renderWidget(paragraph, area); + } + + private Line styleDiagramLine(String text) { + List spans = new ArrayList<>(); + int idx = 0; + while (idx < text.length()) { + int open = text.indexOf('[', idx); + if (open < 0) { + spans.add(Span.styled(text.substring(idx), Style.create().fg(Color.WHITE))); + break; + } + int close = text.indexOf(']', open); + if (close < 0) { + spans.add(Span.styled(text.substring(idx), Style.create().fg(Color.WHITE))); + break; + } + if (open > idx) { + spans.add(Span.styled(text.substring(idx, open), Style.create().fg(Color.GRAY))); + } + String tag = text.substring(open + 1, close); + Color tagColor = getDiagramNodeColor(tag); + spans.add(Span.styled("[" + tag + "]", Style.create().fg(tagColor).bold())); + + int afterTag = close + 1; + int nextOpen = text.indexOf('[', afterTag); + int nextNewline = text.length(); + int labelEnd = nextOpen >= 0 ? nextOpen : nextNewline; + if (afterTag < labelEnd) { + spans.add(Span.styled(text.substring(afterTag, labelEnd), Style.create().fg(Color.WHITE))); + } + idx = labelEnd; + } + return Line.from(spans); + } + + private Color getDiagramNodeColor(String type) { + if (type == null) { + return Color.GRAY; + } + return switch (type) { + case "from" -> Color.GREEN; + case "to", "toD", "wireTap", "enrich", "pollEnrich" -> Color.BLUE; + 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; + }; + } + + private void loadDiagramForSelectedRoute() { + if (selectedPid == null) { + return; + } + + IntegrationInfo info = findSelectedIntegration(); + if (info == null || info.routes.isEmpty()) { + return; + } + + List 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); + } + + Path outputFile = getOutputFile(selectedPid); + PathUtils.deleteFile(outputFile); + + JsonObject root = new JsonObject(); + root.put("action", "route-structure"); + root.put("filter", "*"); + root.put("brief", false); + + Path actionFile = getActionFile(selectedPid); + org.apache.camel.dsl.jbang.core.common.PathUtils.writeTextSafely(root.toJson(), actionFile); + + JsonObject jo = pollJsonResponse(outputFile, 2000); + PathUtils.deleteFile(outputFile); + + if (jo == null) { + diagramLines = List.of("(No response from integration)"); + diagramRouteId = selectedRoute.routeId; + showDiagram = true; + diagramScroll = 0; + return; + } + + JsonArray arr = (JsonArray) jo.get("routes"); + if (arr == null) { + diagramLines = List.of("(No routes in response)"); + diagramRouteId = selectedRoute.routeId; + showDiagram = true; + diagramScroll = 0; + return; + } + + List diagramRoutes = new ArrayList<>(); + for (int i = 0; i < arr.size(); i++) { + JsonObject o = (JsonObject) arr.get(i); + String routeId = objToString(o.get("routeId")); + if (selectedRoute.routeId != null && !selectedRoute.routeId.equals(routeId)) { + continue; + } + RouteDiagramLayoutEngine.RouteInfo route = new RouteDiagramLayoutEngine.RouteInfo(); + route.routeId = routeId; + List lines = o.getCollection("code"); + if (lines != null) { + for (JsonObject line : lines) { + RouteDiagramLayoutEngine.NodeInfo node = new RouteDiagramLayoutEngine.NodeInfo(); + node.type = objToString(line.get("type")); + node.code = Jsoner.unescape(objToString(line.get("code"))); + Integer level = line.getInteger("level"); + node.level = level != null ? level : 0; + route.nodes.add(node); + } + } + diagramRoutes.add(route); + } + + diagramRouteId = selectedRoute.routeId; + diagramScroll = 0; + + TerminalImageCapabilities caps = TerminalImageCapabilities.detect(); + if (caps.supportsNativeImages()) { + RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + List layoutRoutes = new ArrayList<>(); + int totalHeight = 0; + for (RouteDiagramLayoutEngine.RouteInfo r : diagramRoutes) { + RouteDiagramLayoutEngine.LayoutRoute lr = engine.layoutRoute(r, totalHeight); + layoutRoutes.add(lr); + totalHeight = lr.maxY; + } + RouteDiagramRenderer renderer = new RouteDiagramRenderer(); + RouteDiagramRenderer.DiagramColors colors = RouteDiagramRenderer.DiagramColors.parse("transparent"); + java.awt.image.BufferedImage image = renderer.renderDiagram(layoutRoutes, totalHeight, colors); + diagramImageData = ImageData.fromBufferedImage(image); + diagramProtocol = caps.bestProtocol(); + diagramLines = Collections.emptyList(); + } else { + diagramImageData = null; + diagramProtocol = null; + + StringBuilder sb = new StringBuilder(); + org.apache.camel.dsl.jbang.core.common.Printer capturingPrinter + = new org.apache.camel.dsl.jbang.core.common.Printer() { + @Override + public void println() { + sb.append('\n'); + } + + @Override + public void println(String line) { + sb.append(line).append('\n'); + } + + @Override + public void print(String output) { + sb.append(output); + } + + @Override + public void printf(String format, Object... args) { + sb.append(String.format(format, args)); + } + }; + + RouteDiagramRenderer renderer = new RouteDiagramRenderer(); + renderer.printTextDiagram(diagramRoutes, capturingPrinter); + + List result = new ArrayList<>(); + for (String line : sb.toString().split("\n", -1)) { + if (!line.isEmpty()) { + result.add(line); + } + } + diagramLines = result; + } + + showDiagram = true; + } + + private static JsonObject pollJsonResponse(Path outputFile, long timeout) { + long start = System.currentTimeMillis(); + while (System.currentTimeMillis() - start < timeout) { + try { + Thread.sleep(100); + if (Files.exists(outputFile) && outputFile.toFile().length() > 0) { + String text = Files.readString(outputFile); + return (JsonObject) Jsoner.deserialize(text); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } catch (Exception e) { + // ignore + } + } + return null; + } + // ---- Tab 3: Health ---- private void renderHealth(Frame frame, Rect area) { @@ -1349,16 +1656,30 @@ private void renderFooter(Frame frame, Rect area) { Span.raw(" tabs "), Span.styled("Refresh: " + refreshLabel, Style.create().dim())); } else if (tab == TAB_ROUTES) { - footer = Line.from( - Span.styled(" Esc", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" back "), - Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" navigate "), - Span.styled("s", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" sort "), - Span.styled("1-6", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" tabs "), - Span.styled("Refresh: " + refreshLabel, Style.create().dim())); + if (showDiagram) { + footer = Line.from( + Span.styled(" d", Style.create().fg(Color.YELLOW).bold()), + Span.raw("/"), + Span.styled("Esc", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" close "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" scroll "), + Span.styled("PgUp/PgDn", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" page")); + } else { + footer = Line.from( + Span.styled(" Esc", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" back "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" navigate "), + Span.styled("s", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" sort "), + Span.styled("d", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" diagram "), + Span.styled("1-6", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" tabs "), + Span.styled("Refresh: " + refreshLabel, Style.create().dim())); + } } else if (tab == TAB_HEALTH) { footer = Line.from( Span.styled(" Esc", Style.create().fg(Color.YELLOW).bold()),