This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch CAMEL-26691-timeline-tui
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 1e42ce9731b768bd074512120f256472ce77d0e1
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 6 23:26:20 2026 +0100

    CAMEL-26691: timeline - the seconds of the integration, the worst route 
first, and explain with AI
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../core/commands/tui/DataRefreshService.java      |  18 ++-
 .../jbang/core/commands/tui/TimelineAiPrompt.java  | 130 +++++++++++++++++++++
 .../dsl/jbang/core/commands/tui/TimelineTab.java   |  65 ++++++++++-
 .../core/commands/tui/TimelineTabRenderTest.java   |  44 ++++++-
 4 files changed, 250 insertions(+), 7 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
index dac93f316267..0371d413e479 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
@@ -323,7 +323,7 @@ class DataRefreshService {
                 IntegrationInfo info = StatusParser.parseIntegration(ph, root);
                 if (info != null && ctx != null) {
                     ctx.chaosMarks.record(info);
-                    ctx.timeline.record(info, System.currentTimeMillis());
+                    ctx.timeline.record(info, writtenAt(pid));
                 }
                 if (info != null) {
                     IntegrationInfo prev = previousByPid.get(info.pid);
@@ -877,6 +877,22 @@ class DataRefreshService {
         return TuiHelper.loadStatus(pid, statusFileResolver);
     }
 
+    /**
+     * When the integration wrote its status file: the timeline puts each 
status at the second it was written, so a read
+     * that sees the same status again adds no second, and the seconds follow 
the clock of the integration.
+     */
+    private long writtenAt(long pid) {
+        try {
+            Path f = statusFileResolver.apply(Long.toString(pid));
+            if (f != null) {
+                return Files.getLastModifiedTime(f).toMillis();
+            }
+        } catch (Exception e) {
+            // the time of the read then
+        }
+        return System.currentTimeMillis();
+    }
+
     private JsonObject loadErrorFile(long pid) {
         return TuiHelper.loadStatus(pid, errorFileResolver);
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineAiPrompt.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineAiPrompt.java
new file mode 100644
index 000000000000..207f69130e43
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineAiPrompt.java
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.List;
+
+/**
+ * The question the AI panel is opened with to explain the timeline (Shift+F8 
on the Timeline screen, CAMEL-26691): what
+ * went wrong in the window and when, and what it did to each route, so the AI 
can tell the story of cause and effect in
+ * plain words. It is put in the input of the panel, for the user to send with 
Enter or change first.
+ */
+final class TimelineAiPrompt {
+
+    private TimelineAiPrompt() {
+    }
+
+    static String of(
+            IntegrationInfo info, List<Timeline.CauseSeries> causes, 
List<Timeline.RouteSeries> routes, int seconds) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("Explain what happened in the last 
").append(TimelineTab.axisTime(seconds))
+                .append(" of the running integration ").append(info.name != 
null ? info.name : info.pid)
+                .append(", as its timeline shows it: what went wrong and when, 
and what it did to the routes.\n");
+        boolean anyCause = false;
+        for (Timeline.CauseSeries c : causes) {
+            long total = total(c.values());
+            if (total == 0) {
+                continue;
+            }
+            if (!anyCause) {
+                sb.append("The causes, per second:\n");
+                anyCause = true;
+            }
+            sb.append("  ").append(c.rule() != null ? "chaos rule " : "error 
").append(c.label()).append(": ")
+                    .append(total).append(" in 
").append(active(c.values())).append(" seconds, ")
+                    .append(when(c.values())).append('\n');
+        }
+        if (!anyCause) {
+            sb.append("No errors and no chaos faults in that time.\n");
+        }
+        sb.append("The routes:\n");
+        for (Timeline.RouteSeries r : routes) {
+            long completed = r.total(r.completed());
+            long recovered = r.total(r.recovered());
+            long handled = r.total(r.handled());
+            long failed = r.total(r.failed());
+            sb.append("  ").append(r.route()).append(": 
").append(completed).append(" completed, ").append(recovered)
+                    .append(" recovered by redelivery, 
").append(handled).append(" handled by the error handler, ")
+                    .append(failed).append(" failed");
+            if (handled + failed > 0) {
+                long[] trouble = new long[r.handled().length];
+                for (int i = 0; i < trouble.length; i++) {
+                    trouble[i] = Math.max(0, r.handled()[i]) + Math.max(0, 
r.failed()[i]);
+                }
+                sb.append(" (handled or failed 
").append(when(trouble)).append(')');
+            }
+            long down = r.total(r.down());
+            if (down > 0) {
+                sb.append(", down ").append(down).append("s by a chaos rule");
+            }
+            long maxMean = 0;
+            for (long m : r.mean()) {
+                maxMean = Math.max(maxMean, m);
+            }
+            if (maxMean > 0) {
+                sb.append(", slowest second 
").append(ChaosTab.shortTime(maxMean)).append(" mean");
+            }
+            sb.append('\n');
+        }
+        sb.append("Tell it as a short story of cause and effect, from left to 
right in time: which errors or faults led")
+                .append(" to which outcome in which route, whether a message 
was lost, and whether it recovered by")
+                .append(" itself. Then suggest what to change if the routes 
did not cope well. tui_get_table on the")
+                .append(" Timeline tab has the series per second. Make no 
changes to the files unless I agree.");
+        return sb.toString();
+    }
+
+    /** When the non-zero seconds were, as seconds ago: from 42s to 30s ago. */
+    private static String when(long[] values) {
+        int first = -1;
+        int last = -1;
+        for (int i = 0; i < values.length; i++) {
+            if (values[i] > 0) {
+                if (first < 0) {
+                    first = i;
+                }
+                last = i;
+            }
+        }
+        if (first < 0) {
+            return "never";
+        }
+        int fromAgo = values.length - 1 - first;
+        int toAgo = values.length - 1 - last;
+        if (fromAgo == toAgo) {
+            return toAgo == 0 ? "in the last second" : toAgo + "s ago";
+        }
+        return "from " + fromAgo + "s to " + (toAgo == 0 ? "now" : toAgo + "s 
ago");
+    }
+
+    private static int active(long[] values) {
+        int n = 0;
+        for (long v : values) {
+            if (v > 0) {
+                n++;
+            }
+        }
+        return n;
+    }
+
+    private static long total(long[] values) {
+        long sum = 0;
+        for (long v : values) {
+            sum += Math.max(0, v);
+        }
+        return sum;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTab.java
index f906204e59b6..ebe7474408d6 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTab.java
@@ -30,6 +30,7 @@ import dev.tamboui.terminal.Frame;
 import dev.tamboui.text.Line;
 import dev.tamboui.text.Span;
 import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyCode;
 import dev.tamboui.tui.event.KeyEvent;
 import dev.tamboui.tui.event.MouseEvent;
 import dev.tamboui.widgets.block.Block;
@@ -86,6 +87,38 @@ class TimelineTab extends AbstractTableTab {
     @Override
     public void onTabSelected() {
         refreshWhileVisible();
+        // the detail opens on the route that had the worst time in the window
+        List<Timeline.RouteSeries> routes = 
routes(ctx.findSelectedIntegration());
+        int worst = worst(routes);
+        if (worst >= 0) {
+            tableState.select(worst);
+        }
+    }
+
+    /** The route that failed the most, else was handled, recovered or down 
the most, else the busiest; -1 if none. */
+    static int worst(List<Timeline.RouteSeries> routes) {
+        int best = -1;
+        long[] bestScore = null;
+        for (int i = 0; i < routes.size(); i++) {
+            Timeline.RouteSeries r = routes.get(i);
+            long[] score = {
+                    r.total(r.failed()), r.total(r.handled()), 
r.total(r.recovered()) + r.total(r.down()),
+                    r.total(r.completed()) };
+            if (bestScore == null || compare(score, bestScore) > 0) {
+                best = i;
+                bestScore = score;
+            }
+        }
+        return best;
+    }
+
+    private static int compare(long[] a, long[] b) {
+        for (int i = 0; i < a.length; i++) {
+            if (a[i] != b[i]) {
+                return Long.compare(a[i], b[i]);
+            }
+        }
+        return 0;
     }
 
     @Override
@@ -100,18 +133,27 @@ class TimelineTab extends AbstractTableTab {
 
     @Override
     protected boolean handleTabKeyEvent(KeyEvent ke) {
+        if (ke.isKey(KeyCode.F8) && ke.hasShift()) {
+            // explain with AI: the story of cause and effect of the window
+            IntegrationInfo info = ctx.findSelectedIntegration();
+            if (info != null && ctx.askAiCallback != null) {
+                ctx.askAiCallback.ask(TimelineAiPrompt.of(info, 
ctx.timeline.causes(info.pid, endSecond(), seconds()),
+                        routes(info), seconds()));
+            }
+            return true;
+        }
         if (ke.isChar('w')) {
             window = (window + 1) % WINDOWS.length;
             return true;
         }
         if (ke.isChar(' ')) {
-            pausedAt = pausedAt < 0 ? clock.getAsLong() / 1000 : -1;
+            pausedAt = pausedAt < 0 ? nowSecond() : -1;
             return true;
         }
         if (pausedAt >= 0 && (ke.isLeft() || ke.isRight())) {
             IntegrationInfo info = ctx.findSelectedIntegration();
             long first = info != null ? ctx.timeline.firstSecond(info.pid) : 
-1;
-            long now = clock.getAsLong() / 1000;
+            long now = nowSecond();
             long step = Math.max(5, seconds() / 4);
             long to = pausedAt + (ke.isLeft() ? -step : step);
             long earliest = first >= 0 ? Math.min(now, first + seconds() - 1) 
: now;
@@ -146,8 +188,13 @@ class TimelineTab extends AbstractTableTab {
         return pausedAt >= 0;
     }
 
+    /** The last whole second: the current one is still counting. */
+    private long nowSecond() {
+        return clock.getAsLong() / 1000 - 1;
+    }
+
     private long endSecond() {
-        return pausedAt >= 0 ? pausedAt : clock.getAsLong() / 1000;
+        return pausedAt >= 0 ? pausedAt : nowSecond();
     }
 
     private List<Timeline.RouteSeries> routes(IntegrationInfo info) {
@@ -209,7 +256,7 @@ class TimelineTab extends AbstractTableTab {
         StringBuilder sb = new StringBuilder(" Timeline ─ ").append(info.name 
!= null ? info.name : info.pid)
                 .append(" ─ last ").append(span).append(' ');
         if (pausedAt >= 0) {
-            long ago = clock.getAsLong() / 1000 - pausedAt;
+            long ago = nowSecond() - pausedAt;
             sb.append("─ paused").append(ago > 0 ? ", " + 
ChaosTab.shortTime(ago * 1000) + " ago " : " ");
         }
         return sb.toString();
@@ -343,7 +390,7 @@ class TimelineTab extends AbstractTableTab {
         put(axis, columns / 2 - middle.length() / 2, middle);
         put(axis, columns - end.length(), pausedAt >= 0 ? "" : end);
         if (pausedAt >= 0) {
-            String at = "-" + ChaosTab.shortTime(Math.max(0, clock.getAsLong() 
/ 1000 - pausedAt) * 1000);
+            String at = "-" + ChaosTab.shortTime(Math.max(0, nowSecond() - 
pausedAt) * 1000);
             put(axis, columns - at.length(), at);
         }
         String text = new String(axis);
@@ -544,6 +591,14 @@ class TimelineTab extends AbstractTableTab {
         return 
Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).build();
     }
 
+    @Override
+    public void renderFKeyHints(List<Span> spans) {
+        // with the global F-keys, as the AI key of a screen is an F-key of 
every screen that has one
+        if (ctx.askAiCallback != null && ctx.findSelectedIntegration() != 
null) {
+            hint(spans, "Shift+F8", "explain with AI");
+        }
+    }
+
     @Override
     public void renderFooter(List<Span> spans) {
         hint(spans, "↑↓", "route");
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTabRenderTest.java
index 9b09190c0e72..995df7bf84ee 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTabRenderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TimelineTabRenderTest.java
@@ -16,13 +16,16 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
+import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicReference;
 
 import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyCode;
 import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
 import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
 import org.junit.jupiter.api.BeforeEach;
@@ -38,7 +41,7 @@ class TimelineTabRenderTest {
 
     private static final long T0 = 1_800_000_000_000L;
 
-    private final AtomicLong now = new AtomicLong(T0 + 59_000);
+    private final AtomicLong now = new AtomicLong(T0 + 60_000);
     private MonitorContext ctx;
     private IntegrationInfo info;
 
@@ -173,6 +176,45 @@ class TimelineTabRenderTest {
         assertThat(screen).contains("Errors/s  none").contains("idle");
     }
 
+    @Test
+    void theScreenOpensOnTheRouteWithTheWorstTime() {
+        TimelineTab tab = new TimelineTab(ctx, now::get);
+        TuiTestHelper.renderToString(tab, 160, 40);
+        tab.onTabSelected();
+        String screen = TuiTestHelper.renderToString(tab, 160, 40);
+        // orders had its exchanges handled by the dead letter channel
+        assertThat(screen).contains(">> orders").contains("orders ─ per 
second");
+    }
+
+    @Test
+    void shiftF8AsksTheAiForTheStoryOfCauseAndEffect() {
+        List<String> asked = new ArrayList<>();
+        ctx.askAiCallback = new MonitorContext.AskAi() {
+            @Override
+            public void fixProblem(Path file, int line, String problem, String 
lineText) {
+            }
+
+            @Override
+            public void ask(String question) {
+                asked.add(question);
+            }
+        };
+        TimelineTab tab = new TimelineTab(ctx, now::get);
+        TuiTestHelper.renderToString(tab, 160, 40);
+        List<Span> hints = new ArrayList<>();
+        tab.renderFKeyHints(hints);
+        assertThat(text(hints)).contains("explain with AI");
+
+        tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.F8, KeyModifiers.SHIFT));
+
+        assertThat(asked).hasSize(1);
+        assertThat(asked.get(0)).startsWith("Explain what happened in the last 
60s of the running integration shop")
+                .contains("error HttpOperationFailedException 503: 30 in 15 
seconds, from 34s to 20s ago")
+                .contains("orders: 88 completed, 0 recovered by redelivery, 30 
handled by the error handler, 0 failed"
+                          + " (handled or failed from 34s to 20s ago)")
+                .contains("shipping: 88 completed").endsWith("Make no changes 
to the files unless I agree.");
+    }
+
     private static String text(List<Span> spans) {
         StringBuilder sb = new StringBuilder();
         spans.forEach(s -> sb.append(s.content()));

Reply via email to