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();
+    }
+}

Reply via email to