This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch tui-live-flow in repository https://gitbox.apache.org/repos/asf/camel.git
commit fb74529d286d0e295104a9e8458c6fa0292d879f Author: Claus Ibsen <[email protected]> AuthorDate: Sat Nov 14 18:57:53 2026 +0100 chore: camel-jbang - the route diagram shows the messages moving through the route With the metrics on, dots move along the arrows of a route diagram while messages reach the steps: faster and closer together the more messages a second, red when most of them fail. In compact the head of an arrow lights up as a dot comes in, in bare and overview the lines light up, and in the overview a light passes over the steps. A step where exchanges wait with none finishing has a spinner in its top border, with how many wait and when the step last finished one. The rates come from the counts the diagram reloads about once a second; frames are drawn in between, about 12 a second, only while something moves. w turns it off and on. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../images/jbang/camel-tui-diagram-live-flow.gif | Bin 0 -> 174109 bytes .../ROOT/pages/camel-jbang-tui-diagram.adoc | 23 ++ .../core/commands/tui/AbstractDiagramTab.java | 11 + .../dsl/jbang/core/commands/tui/CamelMonitor.java | 2 +- .../jbang/core/commands/tui/DiagramSupport.java | 2 +- .../dsl/jbang/core/commands/tui/DiagramTab.java | 1 + .../jbang/core/commands/tui/RouteDiagramZoom.java | 14 + .../dsl/jbang/core/commands/tui/RoutesTab.java | 1 + .../dsl/jbang/core/commands/tui/TabRegistry.java | 5 + .../core/commands/tui/diagram/FlowAnimation.java | 222 +++++++++++++++ .../commands/tui/diagram/RouteDiagramWidget.java | 233 +++++++++++++++- .../src/main/resources/tui/help/diagram.md | 28 +- .../src/main/resources/tui/help/routes.md | 1 + .../commands/tui/diagram/FlowAnimationTest.java | 169 ++++++++++++ .../core/commands/tui/diagram/FlowDotsTest.java | 306 +++++++++++++++++++++ 15 files changed, 1013 insertions(+), 5 deletions(-) diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-diagram-live-flow.gif b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-diagram-live-flow.gif new file mode 100644 index 000000000000..71ff0e53fdad Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-diagram-live-flow.gif differ diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc index 493061bef0cd..bd1821b21308 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc @@ -16,6 +16,7 @@ See xref:camel-jbang-tui.adoc[Camel TUI] for getting started and the other pages topology and in a route, and in a box of its own in the architecture * <<_external_endpoints,*External endpoints*>> -- what the integration consumes from and sends to outside * <<_metrics_and_source_code,*Metrics and source*>> -- throughput and timing on the boxes, and the source a box comes from +* <<_live_flow,*Live flow*>> -- dots move along the arrows of a route where the messages go, and a stuck step is marked * <<_body_types,*Body types*>> -- the types of the bodies each step has been reached with, on the arrows into the steps == Route Topology Diagram @@ -241,6 +242,27 @@ Press *e* to cycle through three modes for external endpoints: * Press *c* to view the source code of the selected route, with syntax highlighting * Press *b* (or *n*) to switch between the business view (labels) and the technical view (route ids and endpoints) +=== Live Flow + +With the metrics on, a route diagram shows where the messages go while the integration runs, so the busy part of a +large route stands out at a glance, not only as counts that go up. Press *w* to turn it off and on; the choice is kept +for the next start. + +* *Dots on the arrows* move along an arrow while messages reach the step below it: the more messages a second, the + faster and closer together they are. They are red when most of the messages fail there. +* At the compact level the head of an arrow lights up as a dot comes in. At the bare and overview levels the lines light + up instead, as a dot would look like the mark of a step there, and in the overview, which has hardly any lines between + its steps, the light passes over the steps themselves. The steps keep the colours of their kind at every level. +* *A stuck step*, where exchanges wait and none has finished for a few seconds, has a spinner in its top border, with how + many wait and how long ago the step last finished one (`⠹ 3 inflight · 12s`), or `none done` when it never did, such + as a call to a system that does not answer. + +The rates come from the counts the diagram loads about once a second, so the flow costs the integration nothing more. +The dots move in between, about 12 frames a second, and only while something moves: an idle integration draws no +frames. + +image::jbang/camel-tui-diagram-live-flow.gif[The orders route with the live flow: dots go along the arrows and split at the choice, most of them to the warehouse branch; then the sync route, whose delay step is stuck with 219 exchanges and none done] + === Body Types When the integration runs with the message history on, such as with `camel run --dev`, Camel keeps the body types @@ -305,6 +327,7 @@ camel-xml-io, while a Spring Boot or Quarkus application may not. | *Esc* | Go back (previous route or topology) | *t* | Jump to topology view | *m* | Toggle metrics overlay +| *w* | In a route: the live flow on or off (with the metrics on): dots on the arrows the messages go along, and the stuck steps marked | *y* | In a route: show the body types each step has been reached with, beside the arrows into the steps | *+* / *-* | In a route, the topology and the architecture: zoom in or out (detailed, compact, bare, and in a route overview) | *z* | In a route, the topology and the architecture: fit, the most detail in which it fits the screen diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java index ad941094a4b1..6d40c1ecfef5 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AbstractDiagramTab.java @@ -81,6 +81,11 @@ abstract class AbstractDiagramTab extends AbstractTab { return diagram.isShowDiagram(); } + /** The route diagram on screen shows messages moving, and its next frame is due. */ + boolean isFlowFrameDue() { + return diagram.isShowDiagram() && !topologyMode && diagramMetrics && diagram.zoom.flow().frameDue(); + } + /** * The source shown over the diagram while its search is typed, or the go to node popup, which filters by typing. */ @@ -171,6 +176,12 @@ abstract class AbstractDiagramTab extends AbstractTab { ctx.backgroundExecutor.execute(diagram.zoom::persist); return true; } + if (!topologyMode && ke.isCharIgnoreCase('w')) { + // the messages moving through the route: dots on the arrows, the busy steps warmer + diagram.zoom.flow().setEnabled(!diagram.zoom.flow().isEnabled()); + ctx.backgroundExecutor.execute(diagram.zoom::persist); + return true; + } if (!topologyMode && detailMode) { if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) { detail.scrollBy(-10); 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 89f6d0790875..cfc5907bfae9 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 @@ -1702,7 +1702,7 @@ public class CamelMonitor extends CamelCommand { // refreshed by the next data-refresh redraw, which is bounded by {@code interval}. // Without this gate the tick loop forced a full redraw on every tick (~25/s at the // default 40ms tick rate) even while idle. - return dataRefreshed || needsAnimationRedraw( + return dataRefreshed || tabRegistry.isFlowFrameDue() || needsAnimationRedraw( shellPanel.isOpen(), aiPanel.isOpen(), logPinAnim.isAnimating(), 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 e77f407025ed..d8bc5687aed3 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 @@ -1569,7 +1569,7 @@ class DiagramSupport { .withDensity(zoom.densityOf(routeLayout)).withFoldedSteps(zoom.foldedStepsOf(routeLayout)) .withErrorFrame(layoutShowErrorPaths ? routeErrorFrames.get(currentRouteId) : null) .withErrorMark(errorMarkRouteIds().contains(currentRouteId)) - .withBodyTypes(showBodyTypes).withBodyKindMarks(true); + .withBodyTypes(showBodyTypes).withBodyKindMarks(true).withFlow(zoom.flow()); var widget = widgets.get(); int totalRows = widget.getTotalRows(); 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 30800a8d4a40..ebc9c86ee086 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 @@ -562,6 +562,7 @@ class DiagramTab extends AbstractDiagramTab { case ROUTE -> List.of(view, new SubViewBar.Toggle("x", "errors", errorsToggle()), new SubViewBar.Toggle("+/-", "", diagram.zoom.label(diagram.getRouteLayout(drillDownRouteId))), new SubViewBar.Toggle("m", "metrics", metrics), + new SubViewBar.Toggle("w", "flow", diagram.zoom.flow().isEnabled() ? "on" : "off"), new SubViewBar.Toggle("y", "types", diagram.isShowBodyTypes() ? "on" : "off"), new SubViewBar.Toggle("d", "detail", detailMode ? "on" : "off"), panelsToggle()); }; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoom.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoom.java index acfca22415fa..c778ef4feea9 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoom.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteDiagramZoom.java @@ -40,6 +40,7 @@ import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo; import org.apache.camel.diagram.RouteDiagramLayoutEngine.TreeNode; import org.apache.camel.diagram.RouteErrorFrame; import org.apache.camel.diagram.TopologyErrorFrame; +import org.apache.camel.dsl.jbang.core.commands.tui.diagram.FlowAnimation; import org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDensity; import org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget; @@ -62,6 +63,8 @@ final class RouteDiagramZoom { static final String PROP_GROUP_FOLDS = "camel.tui.groupFolds"; /** Whether the mini panels over the diagram (the map and the route tree) are shown: false when hidden. */ static final String PROP_DIAGRAM_PANELS = "camel.tui.diagramPanels"; + /** Whether the messages move through the route diagram (w): false when turned off. */ + static final String PROP_DIAGRAM_FLOW = "camel.tui.diagramFlow"; /** The selected step to select again once the route is laid out anew, and its row on the screen. */ record Restore(int index, int row, int screenRow) { @@ -77,6 +80,8 @@ final class RouteDiagramZoom { private volatile RouteDensity density = DEFAULT; private volatile boolean fit; private volatile boolean panels = true; + // the messages moving through the route on screen: drawn and moved on the UI thread only + private final FlowAnimation flow = new FlowAnimation(); // the routes the History tab laid out whole, to lay them out again when the zoom changes private volatile Whole whole; @@ -105,6 +110,7 @@ final class RouteDiagramZoom { try { zoom.setting(TuiUserConfig.read(PROP_ROUTE_ZOOM)); zoom.panels = !"false".equals(TuiUserConfig.read(PROP_DIAGRAM_PANELS)); + zoom.flow.setEnabled(!"false".equals(TuiUserConfig.read(PROP_DIAGRAM_FLOW))); zoom.foldSetting(TuiUserConfig.read(PROP_ROUTE_FOLDS)); String groups = TuiUserConfig.read(PROP_GROUP_FOLDS); if (groups != null && !groups.isBlank()) { @@ -125,6 +131,7 @@ final class RouteDiagramZoom { try { TuiUserConfig.write(PROP_ROUTE_ZOOM, setting()); TuiUserConfig.write(PROP_DIAGRAM_PANELS, panels ? null : "false"); + TuiUserConfig.write(PROP_DIAGRAM_FLOW, flow.isEnabled() ? null : "false"); TuiUserConfig.write(PROP_ROUTE_FOLDS, foldSetting()); TuiUserConfig.write(PROP_GROUP_FOLDS, foldedGroups.isEmpty() ? null : String.join(",", foldedGroups)); } catch (RuntimeException e) { @@ -188,6 +195,13 @@ final class RouteDiagramZoom { panels = !panels; } + /** + * The messages moving through the route diagram, with the metrics on: dots on the arrows, the busy steps warmer. + */ + FlowAnimation flow() { + return flow; + } + boolean isFit() { return fit; } 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 c510691eb068..42545dfb7b2e 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 @@ -744,6 +744,7 @@ class RoutesTab extends AbstractDiagramTab { } else if (!diagram.getEipNodeBoxes().isEmpty()) { toggles.add(new SubViewBar.Toggle( "+/-", "", diagram.zoom.label(diagram.getRouteLayout(drillDownRouteId)))); + toggles.add(new SubViewBar.Toggle("w", "flow", diagram.zoom.flow().isEnabled() ? "on" : "off")); toggles.add(new SubViewBar.Toggle("y", "types", diagram.isShowBodyTypes() ? "on" : "off")); toggles.add(new SubViewBar.Toggle("d", "detail", detailMode ? "on" : "off")); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java index 36cac9bc77e0..b39f8028bb87 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java @@ -265,6 +265,11 @@ class TabRegistry { // ---- Tab access ---- + /** The tab on screen is a route diagram with messages moving through it: it is time for its next frame. */ + boolean isFlowFrameDue() { + return activeTab() instanceof AbstractDiagramTab tab && tab.isFlowFrameDue(); + } + MonitorTab activeTab() { int selected = tabsState.selected(); return selected == TAB_MORE ? activeMoreTab : primaryTab(selected); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/FlowAnimation.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/FlowAnimation.java new file mode 100644 index 000000000000..f99f86fa9078 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/FlowAnimation.java @@ -0,0 +1,222 @@ +/* + * 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.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.function.LongSupplier; + +import org.apache.camel.diagram.RouteDiagramLayoutEngine.StatInfo; + +/** + * The messages moving through a route diagram while it is on screen: how many a second reach each step, and the steps + * where exchanges wait without any finishing. + * <p> + * The numbers come from the stats of the steps, which the diagram loads again about once a second: a reload brings new + * {@link StatInfo} objects, and the change of the counts since the last one is the rate. The frames in between (every + * {@link #FRAME_MS} ms) move the dots on the arrows, so the diagram shows where the messages go without any more calls + * to the integration. + */ +public final class FlowAnimation { + + /** How often a frame is drawn while something moves: about 12 a second. */ + public static final long FRAME_MS = 80; + /** With no new numbers for this long (the diagram is hidden, or the integration stopped) nothing moves. */ + static final long STALE_MS = 3000; + /** Exchanges that wait at a step this long without one finishing make it a stuck step. */ + static final long STUCK_MS = 2000; + /** A track not seen for this long (another route, a step gone after a reload) is forgotten. */ + private static final long FORGET_MS = 60_000; + /** How much of the rate is from the readings before the last one. */ + private static final double SMOOTHING = 0.6; + + /** What the diagram knows of one step, or of the arrow into it. */ + public static final class Track { + private StatInfo last; + private long total; + private long failed; + private long sampleAt; + private long progressAt; + private long seenAt; + private double rate; + private double failRate; + private long inflight; + private long stuckSince = -1; + private double phase; + + /** Exchanges a second that reached the step, from the last two reloads. */ + public double rate() { + return rate; + } + + /** Exchanges a second that failed at the step. */ + public double failRate() { + return failRate; + } + + /** The exchanges at the step now. */ + public long inflight() { + return inflight; + } + + /** The distance the dots on the arrow have moved, in cells. */ + public double phase() { + return phase; + } + } + + private final Map<String, Track> tracks = new HashMap<>(); + private final LongSupplier clock; + private boolean enabled = true; + private long lastUpdate = -1; + + public FlowAnimation() { + this(() -> System.nanoTime() / 1_000_000); + } + + FlowAnimation(LongSupplier clock) { + this.clock = clock; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + if (!enabled) { + tracks.clear(); + lastUpdate = -1; + } + } + + /** + * Brings the tracks up to date with the stats of the steps on screen, by their key: a stats object not seen before + * is a new reading of the counts, the same one again is the frame between two reloads. Then the dots move on by the + * time since the last frame. + */ + public void update(Map<String, StatInfo> stats) { + long now = clock.getAsLong(); + double dt = lastUpdate < 0 ? 0 : Math.min(0.25, (now - lastUpdate) / 1000.0); + lastUpdate = now; + for (Map.Entry<String, StatInfo> e : stats.entrySet()) { + Track t = tracks.computeIfAbsent(e.getKey(), k -> new Track()); + t.seenAt = now; + read(t, e.getValue(), now); + } + for (Iterator<Track> it = tracks.values().iterator(); it.hasNext();) { + Track t = it.next(); + if (now - t.seenAt > FORGET_MS) { + it.remove(); + continue; + } + t.phase += speed(t.rate) * dt; + } + } + + private static void read(Track t, StatInfo stat, long now) { + if (stat == t.last) { + if (now - t.sampleAt > STALE_MS) { + // no new numbers: nothing is known to move + t.rate = 0; + t.failRate = 0; + } + return; + } + if (t.last != null && now > t.sampleAt) { + double seconds = (now - t.sampleAt) / 1000.0; + t.rate = smooth(t.rate, Math.max(0, stat.exchangesTotal - t.total) / seconds); + t.failRate = smooth(t.failRate, Math.max(0, stat.exchangesFailed - t.failed) / seconds); + } + boolean progressed = t.last == null || stat.exchangesTotal > t.total; + if (progressed) { + t.progressAt = now; + } + t.inflight = stat.exchangesInflight; + if (t.inflight > 0 && !progressed) { + if (t.stuckSince < 0) { + t.stuckSince = t.progressAt; + } + } else { + t.stuckSince = -1; + } + t.last = stat; + t.total = stat.exchangesTotal; + t.failed = stat.exchangesFailed; + t.sampleAt = now; + } + + /** + * The rate with the readings before it: a few messages a second come in fits and starts, and the dots would jump + * between fast and slow. No messages stops it at once, so the dots stop when the messages do. + */ + private static double smooth(double rate, double reading) { + return reading == 0 || rate == 0 ? reading : rate * SMOOTHING + reading * (1 - SMOOTHING); + } + + /** The cells a second the dots move: faster the more messages there are. */ + static double speed(double rate) { + return rate <= 0 ? 0 : 3 + 2.2 * log2(1 + rate); + } + + /** The cells between two dots on an arrow: closer the more messages there are. */ + public static int spacing(double rate) { + return (int) Math.max(4, Math.min(14, Math.round(14 - 2 * log2(1 + rate)))); + } + + private static double log2(double v) { + return Math.log(v) / Math.log(2); + } + + /** The track of a step or arrow, or null when it is not known. */ + public Track get(String key) { + return tracks.get(key); + } + + /** How long exchanges have waited at the step without one finishing, or -1 when it is not stuck. */ + public long stuckMillis(Track t) { + if (t == null || t.stuckSince < 0 || t.inflight <= 0) { + return -1; + } + long waited = clock.getAsLong() - t.stuckSince; + return waited >= STUCK_MS ? waited : -1; + } + + /** The time on the clock of the animation, for the frames of a spinner. */ + public long now() { + return clock.getAsLong(); + } + + /** Something on screen moves: dots on an arrow, or the spinner of a stuck step. */ + public boolean isAnimating() { + if (!enabled || lastUpdate < 0 || clock.getAsLong() - lastUpdate > STALE_MS) { + return false; + } + for (Track t : tracks.values()) { + if (t.rate > 0 || stuckMillis(t) >= 0) { + return true; + } + } + return false; + } + + /** The next frame of the animation is due: it is time to draw the diagram again. */ + public boolean frameDue() { + return isAnimating() && clock.getAsLong() - lastUpdate >= FRAME_MS; + } +} 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 index b6f20ac2733d..5525186eb9da 100644 --- 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 @@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui.diagram; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -162,6 +163,10 @@ public class RouteDiagramWidget implements Widget { foldHits.clear(); arrowCells.clear(); arrowEnds.clear(); + flowArrows.clear(); + if (flow != null) { + updateFlow(); + } if (errorFrame != null) { drawErrorFrame(buffer, area); @@ -186,6 +191,10 @@ public class RouteDiagramWidget implements Widget { } } } + // the dots over the lines, before the boxes + for (FlowArrow arrow : flowArrows) { + drawFlowDots(buffer, area, arrow); + } // Nodes (on top, skip the structural "route" node) markers.clear(); @@ -298,6 +307,24 @@ public class RouteDiagramWidget implements Widget { return this; } + private FlowAnimation flow; + /** The arrows the dots of the flow move on, in the order the messages go along their cells. */ + private final List<FlowArrow> flowArrows = new ArrayList<>(); + + /** The cells of an arrow before its head, and its head (boxed steps only), which lights up as a dot comes in. */ + private record FlowArrow(String key, int fromRow, List<int[]> cells, int[] head) { + } + + /** + * The messages moving through the route (with the metrics on): dots on the arrows that move faster and closer + * together the more messages there are (at the overview, a light over the steps), and the steps where exchanges + * wait with none finishing marked as stuck. + */ + public RouteDiagramWidget withFlow(FlowAnimation flow) { + this.flow = flow != null && flow.isEnabled() && showMetrics ? flow : null; + return this; + } + private boolean bodyTypes; private boolean bodyKindMarks; /** The cells of the arrows and their counts, which the body types beside an arrow stop at. */ @@ -492,7 +519,8 @@ public class RouteDiagramWidget implements Widget { // a remote endpoint is an edge of the integration: its own color, as in the topology eipColor = external ? externalColor() : getEipColor(node.type); } - Style borderStyle = Style.EMPTY.fg(eipColor); + String stuck = highlighted ? null : stuckText(node); + Style borderStyle = stuck != null ? Theme.warning().bold() : Style.EMPTY.fg(eipColor); if (selected) { borderStyle = borderStyle.patch(selectionStyle()); } @@ -547,6 +575,10 @@ public class RouteDiagramWidget implements Widget { // bodies of more than one kind reach the step: it needs attention writeText(buffer, area, row, col + boxWidth - 3, BodyTypes.MARK, Theme.warning()); } + if (stuck != null) { + // exchanges wait here with none finishing: how many, and for how long, in the top border + writeText(buffer, area, row, col + 1, stuck, borderStyle); + } // Link indicator for nodes that connect to other routes String linkedRouteId = findLinkedRouteId(node); @@ -591,6 +623,7 @@ public class RouteDiagramWidget implements Widget { && to.id != null && highlightNodeIds.contains(to.id); drawArrowPath(buffer, area, fromCx, fromBottom, toCx, toTop, dashed, external, bothHighlighted); + addFlowArrow(to, fromCx, fromBottom, toCx, toTop); if (density != RouteDensity.OVERVIEW) { // the steps of the overview are next to each other: no room for a count addArrowEnd(to, fromCx, fromBottom, toCx, toTop, drawCounters(buffer, area, toCx, toTop, stat)); @@ -611,6 +644,7 @@ public class RouteDiagramWidget implements Widget { && to.id != null && highlightNodeIds.contains(to.id); drawArrowPath(buffer, area, fromCx, fromRow, toCx, toTop, dashed, false, highlighted); + addFlowArrow(to, fromCx, fromRow, toCx, toTop); if (density != RouteDensity.OVERVIEW) { addArrowEnd(to, fromCx, fromRow, toCx, toTop, drawCounters(buffer, area, toCx, toTop, stat)); } @@ -1090,7 +1124,8 @@ public class RouteDiagramWidget implements Widget { } else if (stat != null && stat.exchangesFailed > 0) { color = highlightFailColor(); } else { - color = isExternalEndpoint(node) ? externalColor() : getEipColor(node.type); + // no lines between the steps: the light of the messages passes over the steps themselves + color = lit(isExternalEndpoint(node) ? externalColor() : getEipColor(node.type), node, row); } String cell; if ("from".equals(node.type)) { @@ -1150,6 +1185,200 @@ public class RouteDiagramWidget implements Widget { return DiagramText.wrap(text, maxWidth, MAX_WRAP_LINES); } + // ---- the flow of the messages ---- + + private static final String SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"; + /** The cell of a line lit up as it is: no dot on it. */ + private static final char LIT = 0; + + private String flowKey(LayoutNode node) { + return layoutRoute.routeId + "/" + node.id; + } + + /** + * The counts of the steps to the flow: of each step for when it is stuck, and of the arrow into it for its dots. + */ + private void updateFlow() { + Map<String, StatInfo> stats = new HashMap<>(); + for (LayoutNode ln : layoutRoute.nodes) { + if (ln.id == null || ln.treeNode == null || "route".equals(ln.type)) { + continue; + } + if (ln.treeNode.info.stat != null) { + stats.put(flowKey(ln), ln.treeNode.info.stat); + } + StatInfo in = resolveStatInfo(ln); + if (in != null) { + stats.put(flowKey(ln) + ">", in); + } + } + flow.update(stats); + } + + /** The cells of an arrow as drawn by {@link #drawArrowPath}, in the order a message goes along them. */ + private void addFlowArrow(LayoutNode to, int fromCx, int fromRow, int toCx, int toRow) { + if (flow == null || to.id == null || fromRow >= toRow) { + return; + } + // a boxed step has the head of the arrow above it; without boxes the line goes on to the mark of the step + int end = density.boxed() ? toRow - 1 : toRow; + List<int[]> cells = new ArrayList<>(); + if (fromCx == toCx) { + for (int r = fromRow; r < end; r++) { + cells.add(new int[] { r, fromCx }); + } + } else { + int midRow = fromRow + (toRow - fromRow) / 2; + for (int r = fromRow; r < midRow; r++) { + cells.add(new int[] { r, fromCx }); + } + int step = toCx > fromCx ? 1 : -1; + for (int c = fromCx; c != toCx + step; c += step) { + cells.add(new int[] { midRow, c }); + } + for (int r = midRow + 1; r < end; r++) { + cells.add(new int[] { r, toCx }); + } + } + int[] head = density.boxed() ? new int[] { toRow - 1, toCx } : null; + flowArrows.add(new FlowArrow(flowKey(to) + ">", fromRow, cells, head)); + } + + /** The dots on an arrow that messages go along, red when most of them fail. */ + private void drawFlowDots(Buffer buffer, Rect area, FlowArrow arrow) { + FlowAnimation.Track t = flow.get(arrow.key()); + if (t == null || t.rate() <= 0) { + return; + } + int spacing = FlowAnimation.spacing(t.rate()); + long moved = (long) Math.floor(t.phase()); + // the foreground of the theme: the dots are lights on the lines, whatever the colours of the steps are + Color head = t.failRate() * 2 > t.rate() ? failColor() : Theme.baseFg(); + Color tail = mix(head, Theme.muted().fg().orElse(Color.GRAY), 0.5); + List<int[]> cells = arrow.cells(); + // without boxes a dot would look like the mark of a step: the line itself lights up there + char dot = density.boxed() ? '●' : LIT; + char trail = density.boxed() ? '•' : LIT; + // the head last: an arrow of a compact route is only its head, which then lights up as the dots come by + int last = arrow.head() != null ? cells.size() : cells.size() - 1; + for (int i = 0; i <= last; i++) { + // counted from the top of the diagram: below a box the dots go on at the same pace + if (Math.floorMod(arrow.fromRow() + i - moved, spacing) == 0) { + if (i == cells.size()) { + flowDot(buffer, area, arrow.head(), ARROW, Style.EMPTY.fg(head).bold(), true); + } else { + flowDot(buffer, area, cells.get(i), dot, + density.boxed() ? Style.EMPTY.fg(head) : Style.EMPTY.fg(head).bold(), false); + } + if (i > 0) { + flowDot(buffer, area, cells.get(i - 1), trail, Style.EMPTY.fg(tail), false); + } + } + } + } + + /** + * A dot on a line of an arrow, or its head lit up: a count, a label or the frame of a block on the way stays. + */ + private void flowDot(Buffer buffer, Rect area, int[] cell, char ch, Style style, boolean onHead) { + int x = area.x() + cell[1] - scrollX; + int y = area.y() + cell[0] - scrollY; + if (x < area.left() || x >= area.right() || y < area.top() || y >= area.bottom()) { + return; + } + String existing = buffer.get(x, y).symbol(); + // a head on the line of a branch (compact) is the head of that arrow, not a cell of its line + if (onHead + ? !existing.equals(String.valueOf(ARROW)) + : !existing.isEmpty() && (Character.isLetterOrDigit(existing.codePointAt(0)) + || existing.charAt(0) == SCOPE_H || existing.charAt(0) == SCOPE_V + || existing.charAt(0) == ARROW)) { + return; + } + if (ch == LIT && existing.isBlank()) { + return; + } + buffer.setString(x, y, ch == LIT ? existing : String.valueOf(ch), style); + } + + /** + * The colour of a step of the overview as the light of the messages into it passes over it, at the pace of the dots + * on the lines of the other zoom levels: the steps of a busy path light up one after another. + */ + private Color lit(Color base, LayoutNode node, int row) { + FlowAnimation.Track t = flow != null && node.id != null ? flow.get(flowKey(node) + ">") : null; + if (t == null || t.rate() <= 0) { + return base; + } + int spacing = FlowAnimation.spacing(t.rate()); + int at = Math.floorMod(row - (long) Math.floor(t.phase()), spacing); + Color light = t.failRate() * 2 > t.rate() ? failColor() : Theme.baseFg(); + if (at == 0) { + return mix(base, light, 0.7); + } + return at == spacing - 1 ? mix(base, light, 0.35) : base; + } + + /** The colour between two colours, {@code t} of the way from the first. */ + static Color mix(Color from, Color to, double t) { + if (t <= 0) { + return from; + } + try { + Color.Rgb a = from.toRgb(); + Color.Rgb b = to.toRgb(); + return Color.rgb( + a.r() + (int) Math.round((b.r() - a.r()) * t), + a.g() + (int) Math.round((b.g() - a.g()) * t), + a.b() + (int) Math.round((b.b() - a.b()) * t)); + } catch (RuntimeException e) { + // a colour without RGB (the default of the terminal) + return t < 0.5 ? from : to; + } + } + + /** + * The text in the top border of a step where exchanges wait with none finishing: a spinner, how many, and how long; + * or null. A step with steps inside it (a choice, a split) waits for them, so only the step itself has it. + */ + private String stuckText(LayoutNode node) { + if (flow == null || node.id == null || node.treeNode == null || "from".equals(node.type) + || !node.treeNode.children.isEmpty()) { + return null; + } + FlowAnimation.Track t = flow.get(flowKey(node)); + long waited = flow.stuckMillis(t); + if (waited < 0) { + return null; + } + char spin = SPINNER.charAt((int) (flow.now() / 100 % SPINNER.length())); + // since the step last finished an exchange, as the integration says; the time the diagram has watched it else + StatInfo stat = node.treeNode.info.stat; + String age; + if (stat != null && stat.lastCompletedExchangeTimestamp > 0) { + age = duration(Math.max(waited, System.currentTimeMillis() - stat.lastCompletedExchangeTimestamp)); + } else if (stat != null && stat.exchangesTotal == 0) { + age = "none done"; + } else { + age = duration(waited); + } + String head = " " + spin + " "; + for (String text : List.of(head + t.inflight() + " inflight · " + age + " ", + head + t.inflight() + " · " + age + " ", head + age + " ")) { + if (CharWidth.of(text) <= boxWidth - 5) { + return text; + } + } + return null; + } + + private static String duration(long millis) { + if (millis < 60_000) { + return millis / 1000 + "s"; + } + return millis < 3_600_000 ? millis / 60_000 + "m" : millis / 3_600_000 + "h"; + } + 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; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md index 74e696d1fdb4..9587ca40b213 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/diagram.md @@ -98,7 +98,8 @@ state: `a` ai (off shows only what the sources and the runtime say: no AI labels, notes or capabilities; it does not change the AI overview setting; with no summary yet it has the AI write one, as `/overview` does), `b` view (business: labels and what capabilities achieve; -technical: route ids and endpoints), `m` metrics, `e` external, `y` types +technical: route ids and endpoints), `m` metrics, `e` external, `w` flow +(the messages moving through a route), `y` types (the body types on the arrows of a route), `+/-` zoom (how much room a step of a route takes; fit when `z` chose it for the screen), `d` detail, `g` group, `u` utility (one setting for the architecture and the topology, so both show the same groups), `x` errors (the @@ -196,6 +197,29 @@ lists them all with their kind, and in the topology the Info panel of a route says how many of its steps were reached with more than one type (`⚠ 1 mixed · 2 varied`). +## Live Flow + +With the metrics on, a route diagram shows where the messages go while +the integration runs, so the busy part of a large route stands out at a +glance. `w` turns it off and on (kept for the next start). + +- **Dots on the arrows** move along an arrow while messages reach the + step below it: the more messages a second, the faster and closer + together they are. They are red when most of the messages fail there. + In compact the head of an arrow lights up as a dot comes in. In bare + and overview the lines light up instead, as a dot would look like the + mark of a step there, and in the overview, which has hardly any lines + between its steps, the light passes over the steps themselves. +- **Stuck**: a step where exchanges wait and none has finished for a + few seconds has a spinner in its top border, with how many wait and + how long ago the step last finished one (`⠹ 3 inflight · 12s`), or + `none done` when it never did, such as a call to a system that does + not answer. + +The rates come from the counts the diagram reloads about once a second; +the dots move in between, about 12 frames a second, only +while something moves. + ## Route Structure Preview A compact tree structure preview appears in the bottom-right corner @@ -259,6 +283,8 @@ you an at-a-glance view of where you are in the route. - `x` — show or hide the route's error handling, in a frame below it - `y` — show the body types each step has been reached with, beside the arrows into the steps +- `w` — live flow on or off: dots on the arrows, the stuck steps marked + (with the metrics on) - `g` — go to node (fuzzy search popup) - `+` / `-` — zoom in or out: detailed (boxes with up to three lines), compact (the default: boxes with one line, the steps closer together) and bare (a diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/routes.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/routes.md index 4bbdefa53e28..70af3cdda111 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/routes.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/routes.md @@ -190,6 +190,7 @@ in the diagram. - `Esc` — go back (previous route or topology) - `t` — jump back to topology view - `m` — toggle metrics +- `w` — toggle the live flow: dots on the arrows, the stuck steps marked - `y` — toggle the body types each step has been reached with, beside the arrows - `n` — toggle description labels - `PgUp/PgDn` — scroll detail panel (when detail is on) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/FlowAnimationTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/FlowAnimationTest.java new file mode 100644 index 000000000000..bc324c120100 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/FlowAnimationTest.java @@ -0,0 +1,169 @@ +/* + * 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.Map; +import java.util.concurrent.atomic.AtomicLong; + +import org.apache.camel.diagram.RouteDiagramLayoutEngine.StatInfo; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * The messages moving through a route diagram: the rates from the counts the diagram reloads, the heat of the steps, + * the stuck steps, and the frames drawn only while something moves. + */ +class FlowAnimationTest { + + private final AtomicLong clock = new AtomicLong(10_000); + private final FlowAnimation flow = new FlowAnimation(clock::get); + + @Test + void theRateIsTheChangeOfTheCountsBetweenTwoReloads() { + flow.update(Map.of("a", stat(100, 0, 0))); + assertThat(flow.get("a").rate()).isZero(); + + clock.addAndGet(1000); + flow.update(Map.of("a", stat(150, 5, 0))); + + assertThat(flow.get("a").rate()).isCloseTo(50, within(0.01)); + assertThat(flow.get("a").failRate()).isCloseTo(5, within(0.01)); + } + + @Test + void aRateInFitsAndStartsIsSmoothedButStopsAtOnce() { + flow.update(Map.of("a", stat(0, 0, 0))); + clock.addAndGet(1000); + flow.update(Map.of("a", stat(10, 0, 0))); + clock.addAndGet(1000); + flow.update(Map.of("a", stat(10 + 2, 0, 0))); + assertThat(flow.get("a").rate()).as("between the two readings").isBetween(2.0, 10.0); + + clock.addAndGet(1000); + flow.update(Map.of("a", stat(12, 0, 0))); + assertThat(flow.get("a").rate()).as("no messages").isZero(); + } + + @Test + void theFramesBetweenTwoReloadsKeepTheRate() { + StatInfo first = stat(0, 0, 0); + flow.update(Map.of("a", first)); + clock.addAndGet(1000); + StatInfo second = stat(20, 0, 0); + flow.update(Map.of("a", second)); + double phase = flow.get("a").phase(); + + clock.addAndGet(FlowAnimation.FRAME_MS); + flow.update(Map.of("a", second)); + + assertThat(flow.get("a").rate()).isCloseTo(20, within(0.01)); + assertThat(flow.get("a").phase()).as("the dots moved on").isGreaterThan(phase); + } + + @Test + void withNoNewCountsNothingMoves() { + StatInfo stat = stat(0, 0, 0); + flow.update(Map.of("a", stat)); + clock.addAndGet(1000); + StatInfo busy = stat(30, 0, 0); + flow.update(Map.of("a", busy)); + + clock.addAndGet(FlowAnimation.STALE_MS + 1); + flow.update(Map.of("a", busy)); + + assertThat(flow.get("a").rate()).isZero(); + } + + @Test + void nothingMovesWhenTheMessagesStop() { + flow.update(Map.of("a", stat(0, 0, 0))); + for (int i = 1; i <= 5; i++) { + clock.addAndGet(1000); + flow.update(Map.of("a", stat(50L * i, 0, 0))); + } + assertThat(flow.isAnimating()).isTrue(); + + // the counts stay the same: no messages + clock.addAndGet(1000); + flow.update(Map.of("a", stat(250, 0, 0))); + + assertThat(flow.get("a").rate()).isZero(); + assertThat(flow.isAnimating()).isFalse(); + } + + @Test + void aStepWhereExchangesWaitWithNoneFinishingIsStuck() { + flow.update(Map.of("a", stat(10, 0, 0))); + clock.addAndGet(1000); + flow.update(Map.of("a", stat(10, 0, 3))); + FlowAnimation.Track t = flow.get("a"); + assertThat(flow.stuckMillis(t)).as("not yet: waiting a second can be a slow call").isEqualTo(-1); + + clock.addAndGet(1000); + flow.update(Map.of("a", stat(10, 0, 4))); + clock.addAndGet(1000); + flow.update(Map.of("a", stat(10, 0, 4))); + + assertThat(flow.stuckMillis(t)).isGreaterThanOrEqualTo(FlowAnimation.STUCK_MS); + assertThat(t.inflight()).isEqualTo(4); + assertThat(flow.isAnimating()).as("the spinner turns").isTrue(); + + // one finishes: no longer stuck + clock.addAndGet(1000); + flow.update(Map.of("a", stat(11, 0, 3))); + assertThat(flow.stuckMillis(t)).isEqualTo(-1); + } + + @Test + void aFrameIsDueOnlyWhileSomethingMoves() { + StatInfo quiet = stat(5, 0, 0); + flow.update(Map.of("a", quiet)); + clock.addAndGet(1000); + flow.update(Map.of("a", stat(5, 0, 0))); + clock.addAndGet(FlowAnimation.FRAME_MS); + assertThat(flow.frameDue()).as("no messages: nothing moves").isFalse(); + + clock.addAndGet(1000); + flow.update(Map.of("a", stat(25, 0, 0))); + assertThat(flow.frameDue()).as("just drawn").isFalse(); + clock.addAndGet(FlowAnimation.FRAME_MS); + assertThat(flow.frameDue()).isTrue(); + + flow.setEnabled(false); + assertThat(flow.frameDue()).as("turned off").isFalse(); + assertThat(flow.get("a")).isNull(); + } + + @Test + void theDotsAreCloserTogetherTheMoreMessagesThereAre() { + assertThat(FlowAnimation.spacing(1)).isGreaterThan(FlowAnimation.spacing(100)); + assertThat(FlowAnimation.spacing(0.1)).isLessThanOrEqualTo(14); + assertThat(FlowAnimation.spacing(1_000_000)).isEqualTo(4); + assertThat(FlowAnimation.speed(100)).isGreaterThan(FlowAnimation.speed(1)); + assertThat(FlowAnimation.speed(0)).isZero(); + } + + private static StatInfo stat(long total, long failed, long inflight) { + StatInfo s = new StatInfo(); + s.exchangesTotal = total; + s.exchangesFailed = failed; + s.exchangesInflight = inflight; + return s; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/FlowDotsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/FlowDotsTest.java new file mode 100644 index 000000000000..f2e31f4db95a --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/FlowDotsTest.java @@ -0,0 +1,306 @@ +/* + * 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.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.buffer.Cell; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +import org.apache.camel.diagram.RouteDiagramLayoutEngine; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo; +import org.apache.camel.diagram.RouteDiagramLayoutEngine.StatInfo; +import org.apache.camel.dsl.jbang.core.commands.tui.Theme; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The live flow in the route diagram: dots on the arrows the messages go along, the stuck step marked in its border, + * and none of it with the flow off or the metrics off. + */ +class FlowDotsTest { + + private final AtomicLong clock = new AtomicLong(50_000); + private final FlowAnimation flow = new FlowAnimation(clock::get); + private final RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine(); + + @Test + void dotsMoveOnTheArrowsTheMessagesGoAlong() { + int dots = 0; + boolean moved = false; + String before = null; + for (int reload = 0; reload < 3; reload++) { + LayoutRoute layout = layout(reload * 40L, 0, 0); + for (int frame = 0; frame < 12; frame++) { + Buffer buffer = render(layout, true); + String screen = screen(buffer); + dots += count(screen, '●'); + if (before != null && reload > 0 && !screen.equals(before)) { + moved = true; + } + before = screen; + clock.addAndGet(FlowAnimation.FRAME_MS); + } + clock.addAndGet(1000 - 12 * FlowAnimation.FRAME_MS); + } + assertThat(dots).isPositive(); + assertThat(moved).as("the dots move from frame to frame").isTrue(); + assertThat(flow.isAnimating()).isTrue(); + } + + @Test + void theBusyBoxesKeepTheirFrameAndColour() { + Buffer idle = render(layout(0, 0, 0), false); + Color colour = borderColor(idle); + + Buffer busy = null; + for (int reload = 0; reload < 4; reload++) { + LayoutRoute layout = layout(reload * 100L, 0, 0); + for (int frame = 0; frame < 12; frame++) { + busy = render(layout, true); + clock.addAndGet(FlowAnimation.FRAME_MS); + } + clock.addAndGet(1000 - 12 * FlowAnimation.FRAME_MS); + } + + assertThat(corner(busy)).isEqualTo(corner(idle)).isEqualTo("┌"); + assertThat(borderColor(busy)).as("the colour of its kind").isEqualTo(colour); + } + + @Test + void bareLightsUpTheLinesBetweenTheSteps() { + int lit = 0; + for (int reload = 0; reload < 3; reload++) { + LayoutRoute layout = layout(reload * 40L, 0, 0); + RouteDensity.BARE.apply(layout); + for (int frame = 0; frame < 12; frame++) { + Buffer buffer = render(layout, true, RouteDensity.BARE); + assertThat(screen(buffer)).as("no dot: it would look like the mark of a step").doesNotContain("•"); + lit += litLines(buffer); + clock.addAndGet(FlowAnimation.FRAME_MS); + } + clock.addAndGet(1000 - 12 * FlowAnimation.FRAME_MS); + } + assertThat(lit).isPositive(); + } + + /** The cells of the lines in the colour of the foreground, not the muted colour of a line. */ + private static int litLines(Buffer buffer) { + int lit = 0; + for (int y = 0; y < buffer.height(); y++) { + for (int x = 0; x < buffer.width(); x++) { + Cell cell = buffer.get(x, y); + if ("│".equals(cell.symbol()) && cell.style().fg().filter(Theme.baseFg()::equals).isPresent()) { + lit++; + } + } + } + return lit; + } + + @Test + void theOverviewHasALightPassingOverTheSteps() { + LayoutRoute quiet = layout(0, 0, 0); + RouteDensity.OVERVIEW.apply(quiet); + Color colour = markColor(render(quiet, false, RouteDensity.OVERVIEW)); + + Set<Color> colours = new HashSet<>(); + for (int reload = 0; reload < 3; reload++) { + LayoutRoute layout = layout(reload * 40L, 0, 0); + RouteDensity.OVERVIEW.apply(layout); + for (int frame = 0; frame < 12; frame++) { + colours.add(markColor(render(layout, true, RouteDensity.OVERVIEW))); + clock.addAndGet(FlowAnimation.FRAME_MS); + } + clock.addAndGet(1000 - 12 * FlowAnimation.FRAME_MS); + } + assertThat(colours).as("lit up as the light passes, and back").contains(colour).hasSizeGreaterThan(1); + } + + /** The colour of the mark of the last step in the overview. */ + private static Color markColor(Buffer buffer) { + for (int y = buffer.height() - 1; y >= 0; y--) { + int x = row(buffer, y).indexOf('■'); + if (x >= 0) { + return buffer.get(x, y).style().fg().orElse(Color.RESET); + } + } + throw new AssertionError("no step"); + } + + @Test + void aStuckStepHasASpinnerInItsBorder() { + render(layout(10, 0, 0), true); + for (int reload = 1; reload <= 3; reload++) { + clock.addAndGet(1000); + render(layout(10, 0, 2), true); + } + String screen = screen(render(layout(10, 0, 2), true)); + + assertThat(screen).contains("2 inflight · 3s"); + } + + @Test + void aStuckStepSaysWhenItLastFinishedAnExchange() { + render(layout(10, 0, 0), true); + for (int reload = 1; reload <= 3; reload++) { + clock.addAndGet(1000); + LayoutRoute layout = layout(10, 0, 2); + lastStep(layout).lastCompletedExchangeTimestamp = System.currentTimeMillis() - 5 * 60_000; + render(layout, true); + } + LayoutRoute layout = layout(10, 0, 2); + lastStep(layout).lastCompletedExchangeTimestamp = System.currentTimeMillis() - 5 * 60_000; + + assertThat(screen(render(layout, true))).contains("2 inflight · 5m"); + } + + @Test + void aStuckStepThatNeverFinishedOneSaysSo() { + render(layout(0, 0, 0), true); + for (int reload = 1; reload <= 3; reload++) { + clock.addAndGet(1000); + render(layout(0, 0, 4), true); + } + + assertThat(screen(render(layout(0, 0, 4), true))).contains("4 · none done"); + } + + private static StatInfo lastStep(LayoutRoute layout) { + return layout.nodes.stream().filter(n -> "to1".equals(n.id)).findFirst().orElseThrow().treeNode.info.stat; + } + + @Test + void offThereAreNoDots() { + flow.setEnabled(false); + for (int reload = 0; reload < 3; reload++) { + LayoutRoute layout = layout(reload * 40L, 0, 0); + for (int frame = 0; frame < 12; frame++) { + assertThat(screen(render(layout, true))).doesNotContain("●"); + clock.addAndGet(FlowAnimation.FRAME_MS); + } + clock.addAndGet(1000 - 12 * FlowAnimation.FRAME_MS); + } + assertThat(flow.isAnimating()).isFalse(); + } + + @Test + void withoutTheMetricsThereIsNoFlow() { + for (int reload = 0; reload < 3; reload++) { + LayoutRoute layout = layout(reload * 40L, 0, 0); + RouteDiagramWidget widget = new RouteDiagramWidget(layout, engine.getNodeWidth(), -1, 0, 0, false, Map.of()) + .withFlow(flow); + Rect area = new Rect(0, 0, 120, 60); + Buffer buffer = Buffer.empty(area); + widget.render(area, buffer); + assertThat(screen(buffer)).doesNotContain("●"); + clock.addAndGet(1000); + } + assertThat(flow.isAnimating()).isFalse(); + } + + private Buffer render(LayoutRoute layout, boolean withFlow) { + return render(layout, withFlow, RouteDensity.DETAILED); + } + + private Buffer render(LayoutRoute layout, boolean withFlow, RouteDensity density) { + RouteDiagramWidget widget = new RouteDiagramWidget(layout, engine.getNodeWidth(), -1, 0, 0, true, Map.of()) + .withDensity(density).withFlow(withFlow ? flow : null); + Rect area = new Rect(0, 0, 120, 60); + Buffer buffer = Buffer.empty(area); + widget.render(area, buffer); + return buffer; + } + + /** A route of a few steps in a row, each reached by the same messages; the last step has the inflight. */ + private LayoutRoute layout(long total, long failed, long inflight) { + RouteInfo route = new RouteInfo(); + route.routeId = "orders"; + NodeInfo r = node("route", "orders", 0, 0, 0, 0); + r.code = "route[orders]"; + route.nodes.add(r); + route.nodes.add(node("from", "from1", 0, total, 0, 0)); + route.nodes.add(node("unmarshal", "unmarshal1", 1, total, 0, 0)); + route.nodes.add(node("bean", "bean1", 1, total, 0, 0)); + route.nodes.add(node("to", "to1", 1, total, failed, inflight)); + return engine.layoutRoute(route, 0); + } + + private static NodeInfo node(String type, String id, int level, long total, long failed, long inflight) { + NodeInfo n = new NodeInfo(); + n.type = type; + n.id = id; + n.code = type; + n.level = level; + n.stat = new StatInfo(); + n.stat.exchangesTotal = total; + n.stat.exchangesFailed = failed; + n.stat.exchangesInflight = inflight; + return n; + } + + /** The colour of the top left corner of the box of the unmarshal step. */ + private static Color borderColor(Buffer buffer) { + int[] at = cornerOf(buffer); + return buffer.get(at[0], at[1]).style().fg().orElse(Color.RESET); + } + + /** The top left corner of the box of the unmarshal step. */ + private static String corner(Buffer buffer) { + int[] at = cornerOf(buffer); + return buffer.get(at[0], at[1]).symbol(); + } + + private static int[] cornerOf(Buffer buffer) { + for (int y = 0; y < buffer.height(); y++) { + String row = row(buffer, y); + int x = row.indexOf("unmarshal"); + if (x >= 0) { + int left = Math.max(row.lastIndexOf('│', x), row.lastIndexOf('┃', x)); + return new int[] { left, y - 1 }; + } + } + throw new AssertionError("no unmarshal box"); + } + + private static String screen(Buffer buffer) { + StringBuilder sb = new StringBuilder(); + for (int y = 0; y < buffer.height(); y++) { + sb.append(row(buffer, y)).append('\n'); + } + return sb.toString(); + } + + private static String row(Buffer buffer, int y) { + StringBuilder sb = new StringBuilder(); + for (int x = 0; x < buffer.width(); x++) { + sb.append(buffer.get(x, y).symbol()); + } + return sb.toString(); + } + + private static int count(String s, char c) { + return (int) s.chars().filter(ch -> ch == c).count(); + } +}
