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