This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch feature/CAMEL-23672-tui-diagram in repository https://gitbox.apache.org/repos/asf/camel.git
commit 34cb0c5c61d36fea94e877b8a82dfd948c418f4a Author: Claus Ibsen <[email protected]> AuthorDate: Wed Jun 3 18:05:20 2026 +0200 CAMEL-23672: camel-tui - Interactive topology navigation with info panel and route drill-down Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../camel/diagram/TopologyAsciiRenderer.java | 11 + .../jbang/core/commands/tui/DiagramSupport.java | 343 ++++++++++++++++++++- .../dsl/jbang/core/commands/tui/DiagramTab.java | 205 +++++++++++- 3 files changed, 550 insertions(+), 9 deletions(-) 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/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..861b6c91d477 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 @@ -55,6 +55,8 @@ 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; @@ -83,6 +85,13 @@ class DiagramSupport { 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; List<String> getLines() { return lines; @@ -112,6 +121,54 @@ 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; } @@ -205,6 +262,10 @@ class DiagramSupport { void reset() { close(); lines = Collections.emptyList(); + nodeBoxes = Collections.emptyList(); + topologyNodes = Collections.emptyList(); + topologyEdges = Collections.emptyList(); + selectedNodeIndex = -1; scrollY = 0; scrollX = 0; } @@ -217,6 +278,181 @@ class DiagramSupport { hint(spans, "Home/End", "top/end"); } + // ---- Node selection ---- + + 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 ---- + + 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; + } + } + } + + 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 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; + } + } + } + // ---- Rendering ---- void renderDiagram(Frame frame, Rect area, String title) { @@ -238,6 +474,8 @@ class DiagramSupport { Rect inner = block.inner(area); 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); @@ -487,7 +725,20 @@ class DiagramSupport { } } - applyResult(ctx, resultLines, null, null, null, positions, Collections.emptySet()); + 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, null, null, null, positions, Collections.emptySet(), boxes, + result.nodes, result.edges); } else { TerminalImageCapabilities caps = TerminalImageCapabilities.detect(); if (caps.supportsNativeImages()) { @@ -645,7 +896,8 @@ class DiagramSupport { List<String> resultLines, ImageData resultImageData, ImageData resultFullImageData, ImageProtocol resultProtocol) { applyResult(ctx, resultLines, resultImageData, resultFullImageData, resultProtocol, - Collections.emptyList(), Collections.emptySet()); + Collections.emptyList(), Collections.emptySet(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()); } private void applyResult( @@ -653,6 +905,19 @@ class DiagramSupport { List<String> resultLines, ImageData resultImageData, ImageData resultFullImageData, ImageProtocol resultProtocol, List<RouteDiagramAsciiRenderer.CounterPos> positions, Set<Integer> titleRows) { + applyResult(ctx, resultLines, resultImageData, resultFullImageData, resultProtocol, + positions, titleRows, Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()); + } + + private void applyResult( + MonitorContext ctx, + List<String> resultLines, ImageData resultImageData, ImageData resultFullImageData, + ImageProtocol resultProtocol, + List<RouteDiagramAsciiRenderer.CounterPos> positions, Set<Integer> titleRows, + List<TopologyAsciiRenderer.NodeBox> resultNodeBoxes, + List<TopologyLayoutNode> resultTopologyNodes, + List<TopologyLayoutEdge> resultTopologyEdges) { if (ctx.runner == null) { return; } @@ -663,6 +928,31 @@ class DiagramSupport { routeTitleRows = titleRows; fullImageData = resultFullImageData; protocol = resultProtocol; + 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; + } + if (!wasShowing) { imageData = resultImageData; scrollY = 0; @@ -673,7 +963,6 @@ class DiagramSupport { cropH = -1; } else { showDiagram = true; - // invalidate crop cache so next render re-crops at current scroll position cropX = -1; cropY = -1; cropW = -1; @@ -689,6 +978,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 +1035,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; 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..9949b560ff43 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,13 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; +import java.util.ArrayList; import java.util.List; +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.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; @@ -51,6 +55,31 @@ class DiagramTab implements MonitorTab { @Override public boolean handleKeyEvent(KeyEvent ke) { + // 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 (diagram.handleScrollKeys(ke)) { return true; } @@ -81,7 +110,17 @@ class DiagramTab implements MonitorTab { // Drill down into route diagram (Enter) 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))) { + drillDownRouteId = selectedRouteId; + topologyMode = false; + diagram.setTopologyMode(false); + diagram.endLoad(); + reloadDiagram(); + } + } return true; } @@ -91,6 +130,7 @@ class DiagramTab implements MonitorTab { @Override public boolean handleEscape() { if (!topologyMode) { + diagram.setPendingSelectionRouteId(drillDownRouteId); topologyMode = true; diagram.setTopologyMode(true); diagram.endLoad(); @@ -140,7 +180,18 @@ class DiagramTab implements MonitorTab { } else { title = " Route [" + drillDownRouteId + "] "; } - diagram.renderDiagram(frame, area, title); + + String selectedRouteId = topologyMode ? diagram.getSelectedRouteId() : drillDownRouteId; + 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.renderDiagram(frame, hChunks.get(1), title); + } else { + diagram.renderDiagram(frame, area, title); + } return; } @@ -156,12 +207,139 @@ 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 : ""))); + + lines.add(Line.from(Span.raw(""))); + lines.add(Line.from( + Span.styled(" Total: ", Style.EMPTY.dim()), + Span.raw(String.valueOf(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.valueOf(route.failed), failStyle))); + lines.add(Line.from( + Span.styled(" Inflight: ", Style.EMPTY.dim()), + Span.raw(String.valueOf(route.inflight)))); + + lines.add(Line.from(Span.raw(""))); + if (route.total > 0) { + lines.add(Line.from( + Span.styled(" Mean: ", Style.EMPTY.dim()), + Span.raw(route.meanTime + " ms"))); + lines.add(Line.from( + Span.styled(" Max: ", Style.EMPTY.dim()), + Span.raw(route.maxTime + " ms"))); + lines.add(Line.from( + Span.styled(" Min: ", Style.EMPTY.dim()), + Span.raw(route.minTime + " ms"))); + } + + if (route.coverage != null) { + lines.add(Line.from(Span.raw(""))); + lines.add(Line.from( + Span.styled(" Coverage: ", Style.EMPTY.dim()), + Span.raw(route.coverage))); + } + } 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); + } + @Override public void renderFooter(List<Span> spans) { if (diagram.isShowDiagram()) { - diagram.renderFooterHints(spans); + if (!topologyMode) { + hint(spans, "Esc", "back"); + 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]")); } } @@ -266,15 +444,30 @@ 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. + ## Keys + - `↑↓←→` — navigate between route boxes + - `Enter` — drill down into selected route + - `Esc` — return to topology / close diagram - `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 - `PgUp/PgDn` — page scroll - `Home/End` — top/end - - `Esc` — close diagram """; }
