davsclaus commented on code in PR #29385:
URL: https://github.com/apache/camel/pull/29385#discussion_r4503223213


##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java:
##########
@@ -0,0 +1,1014 @@
+/*
+ * 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.nio.file.Path;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+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.KeyEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.dsl.jbang.core.commands.ai.About;
+import org.apache.camel.dsl.jbang.core.commands.ai.AboutReport;
+import org.apache.camel.dsl.jbang.core.commands.ai.ProjectOverview;
+import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * About an integration (Overview ▸ About, CAMEL-26700): what it does at a 
glance and without an AI, and what has
+ * happened. A header with what it is and its purpose (the comment at the top 
of its main file, else the descriptions of
+ * its routes), then what comes in, its routes (the routes called with direct 
or seda under the route that calls them)
+ * and what goes out, from the analysis of its sources ({@link 
ProjectOverview}). Under it, the last minutes from the
+ * timeline the integration records, which the Timeline tab (4) shows second 
by second, and what to watch.
+ */
+final class AboutView {
+
+    // how often the sources are read again, to see a change
+    private static final long RELOAD_MILLIS = 10_000;
+    private static final int HAPPENED_SECONDS = 300;
+    private static final String BLOCKS = " ▁▂▃▄▅▆▇█";
+    // the strips have this many columns, whatever the zoom
+    private static final int STRIP_COLUMNS = 60;
+    // the sparklines of In and Out
+    private static final int SPARK_WIDTH = 14;
+    private static final DateTimeFormatter TIME = 
DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
+
+    private final MonitorContext ctx;
+    private volatile ProjectOverview.Overview overview;
+    private volatile About.Model model;
+    private volatile String purpose;
+    private volatile String purposeFrom;
+    private volatile boolean loading;
+    private volatile String error;
+    private Path dir;
+    private long loadedAt;
+    // the zoom of What happened, as the windows of the Timeline tab (+ and 
-), and its last second when moved (← →);
+    // -1 follows the last second recorded
+    private int zoom = TimelineTab.WINDOWS.length - 1;
+    private long stripsEnd = -1;
+    // the line of In, Routes or Out the cursor is on (↑ ↓), in the order of 
the columns; -1 when none is
+    private int cursor = -1;
+    private volatile List<Target> targets = List.of();
+    private volatile List<String> targetLabels = List.of();
+
+    /**
+     * What a line of In, Routes or Out is about, for Enter: a route (opened 
on the Diagram) or an endpoint a route
+     * sends to (opened on the Endpoint tab).
+     *
+     * @param routeId  the route
+     * @param endpoint the URI of the endpoint as the sources have it, or null 
for a route
+     */
+    record Target(String routeId, String endpoint) {
+    }
+
+    AboutView(MonitorContext ctx) {
+        this.ctx = ctx;
+    }
+
+    /** Reads the sources of the integration in the background, again when 
they may have changed. */
+    private void load(IntegrationInfo info, Consumer<Runnable> onRenderThread) 
{
+        Path d = info.directory != null ? Path.of(info.directory) : null;
+        long now = System.currentTimeMillis();
+        boolean same = d != null && d.equals(dir);
+        if (d == null || loading || same && now - loadedAt < RELOAD_MILLIS) {
+            if (d == null) {
+                error = "No source directory for this integration";
+            }
+            return;
+        }
+        if (!same) {
+            overview = null;
+            purpose = null;
+            error = null;
+        }
+        dir = d;
+        loadedAt = now;
+        loading = true;
+        Thread t = new Thread(() -> {
+            try {
+                ProjectOverview.Overview o = ProjectOverview.analyze(d, 
ArchitectureView.catalog());
+                About.Model m = About.of(d, o);
+                onRenderThread.accept(() -> {
+                    overview = o;
+                    model = m;
+                    purpose = m.purpose();
+                    purposeFrom = m.purposeFrom();
+                    error = o.flows().isEmpty() ? "No routes found in " + d : 
null;
+                    loading = false;
+                });
+            } catch (Exception e) {
+                onRenderThread.accept(() -> {
+                    error = "Cannot read the sources: " + e.getMessage();
+                    loading = false;
+                });
+            }
+        }, "CamelTuiAbout");
+        t.setDaemon(true);
+        t.start();
+    }
+
+    /**
+     * The keys of What happened: + zooms in on the last trouble in the window 
(a failure, a handled error, a
+     * redelivery, a route down, a circuit breaker open), - zooms out, and ← → 
move a zoomed window.
+     */
+    boolean handleKeyEvent(KeyEvent ke, IntegrationInfo info) {
+        if (info == null) {
+            return false;
+        }
+        if (ke.isConfirm()) {
+            return open();
+        }
+        long end = ctx.timeline.endSecond(info.pid);
+        if (ke.isChar('+') || ke.isChar('=')) {
+            if (zoom > 0 && end >= 0) {
+                long trouble = lastTrouble(info, windowEnd(end), seconds());
+                zoom--;
+                if (trouble >= 0) {
+                    // the trouble in the middle of the window, when there is 
time after it
+                    moveTo(trouble + seconds() / 2, end);
+                }
+            }
+            return true;
+        }
+        if (ke.isChar('-')) {
+            if (zoom < TimelineTab.WINDOWS.length - 1) {
+                zoom++;
+                if (end >= 0) {
+                    moveTo(windowEnd(end) + seconds() / 4, end);
+                }
+            }
+            return true;
+        }
+        if ((ke.isLeft() || ke.isRight()) && end >= 0) {
+            int by = Math.max(1, seconds() / 4);
+            moveTo(windowEnd(end) + (ke.isLeft() ? -by : by), end);
+            return true;
+        }
+        return false;
+    }
+
+    /** The lines the cursor can be on, for an agent (tui_get_state): the 
route, or the endpoint and its route. */
+    SelectionContext selectionContext() {
+        List<String> items = targetLabels;
+        return new SelectionContext("list", items, cursor < items.size() ? 
cursor : -1, items.size(), "About");
+    }
+
+    /**
+     * About the integration as data, for an agent (tui_get_table): what the 
screen shows, with what happened over the
+     * window of What happened.
+     */
+    JsonObject tableJson(IntegrationInfo info) {
+        JsonObject result = new JsonObject();
+        result.put("tab", "Overview");
+        result.put("view", "About");
+        result.put("integration", info.name != null ? info.name : info.pid);
+        result.put("pid", info.pid);
+        About.Model m = model;
+        if (m == null) {
+            result.put("loading", error != null ? error : "reading the 
sources");
+            return result;
+        }
+        if (m.purpose() != null) {
+            result.put("purpose", m.purpose());
+            result.put("purposeFrom", m.purposeFrom());
+        }
+        Map<String, Long> totals = new LinkedHashMap<>();
+        info.routes.forEach(r -> {
+            if (r.routeId != null) {
+                totals.put(r.routeId, r.total);
+            }
+        });
+        JsonArray in = new JsonArray();
+        for (About.In e : m.in()) {
+            JsonObject jo = new JsonObject();
+            jo.put("label", e.label());
+            jo.put("kind", e.kind());
+            jo.put("routeId", e.route());
+            putTotalAndRate(jo, info, e.route(), totals);
+            in.add(jo);
+        }
+        result.put("in", in);
+        JsonArray routes = new JsonArray();
+        for (About.RouteLine r : m.routes()) {
+            JsonObject jo = new JsonObject();
+            jo.put("routeId", r.routeId());
+            jo.put("depth", r.depth());
+            if (r.how() != null) {
+                jo.put("calledHow", r.how());
+            }
+            jo.put("from", r.from());
+            jo.put("steps", r.steps());
+            if (totals.get(r.routeId()) != null) {
+                jo.put("exchangesTotal", totals.get(r.routeId()));
+            }
+            routes.add(jo);
+        }
+        result.put("routes", routes);
+        JsonArray out = new JsonArray();
+        for (About.Out o : m.out()) {
+            JsonObject jo = new JsonObject();
+            jo.put("uri", o.uri());
+            jo.put("category", o.category());
+            jo.put("routeId", o.route());
+            Long hits = outHits(info, o.uri(), o.route());
+            if (hits != null) {
+                jo.put("sends", hits);
+            }
+            out.add(jo);
+        }
+        result.put("out", out);
+        long last = ctx.timeline.endSecond(info.pid);
+        if (last >= 0) {
+            long end = windowEnd(last);
+            int seconds = seconds();
+            JsonObject happened = new JsonObject();
+            happened.put("seconds", seconds);
+            happened.put("endSecond", end);
+            happened.put("following", stripsEnd < 0);
+            JsonArray lines = new JsonArray();
+            for (Timeline.RouteSeries r : ctx.timeline.routes(info.pid, 
routeOrder(info), end, seconds)) {
+                JsonObject jo = new JsonObject();
+                jo.put("routeId", r.route());
+                jo.put("exchanges", r.total(r.completed()) + 
r.total(r.recovered()) + r.total(r.handled())
+                                    + r.total(r.failed()));
+                long[] worst = r.total(r.failed()) > 0 ? r.failed() : 
r.total(r.handled()) > 0 ? r.handled()
+                        : r.total(r.recovered()) > 0 ? r.recovered() : null;
+                if (worst != null) {
+                    jo.put("worst", worst == r.failed() ? "failed" : worst == 
r.handled() ? "handled" : "recovered");
+                    jo.put("worstCount", r.total(worst));
+                    jo.put("worstSecondsAgo", last - lastAt(worst, end, 
seconds));
+                    if (worst == r.recovered()) {
+                        String from = recoveredFrom(info, r.route(), end - 
seconds + 1, end);
+                        if (from != null) {
+                            jo.put("recoveredFrom", from);
+                        }
+                    }
+                }
+                if (r.openSeconds() > 0) {
+                    jo.put("circuitBreakerOpenSeconds", r.openSeconds());
+                }
+                if (r.total(r.down()) > 0) {
+                    jo.put("downSeconds", r.total(r.down()));
+                }
+                lines.add(jo);
+            }
+            happened.put("routes", lines);
+            JsonArray causes = new JsonArray();
+            for (Timeline.CauseSeries c : ctx.timeline.causes(info.pid, end, 
seconds)) {
+                long total = Arrays.stream(c.values()).sum();
+                if (total > 0) {
+                    JsonObject jo = new JsonObject();
+                    jo.put("label", c.label());
+                    if (c.rule() != null) {
+                        jo.put("chaosRule", c.rule());
+                    }
+                    jo.put("count", total);
+                    causes.add(jo);
+                }
+            }
+            happened.put("causes", causes);
+            result.put("whatHappened", happened);
+        }
+        Target t = selected();
+        if (t != null) {
+            JsonObject sel = new JsonObject();
+            sel.put("routeId", t.routeId());
+            if (t.endpoint() != null) {
+                sel.put("endpoint", t.endpoint());
+            }
+            result.put("selected", sel);
+        }
+        result.put("keys", "v all integrations, Up/Down select a line, Enter 
opens it, +/- zoom, Left/Right move, 4 Timeline");
+        return result;
+    }
+
+    private void putTotalAndRate(JsonObject jo, IntegrationInfo info, String 
routeId, Map<String, Long> totals) {
+        if (routeId == null) {
+            return;
+        }
+        if (totals.get(routeId) != null) {
+            jo.put("exchangesTotal", totals.get(routeId));
+        }
+        long[] perSecond = routePerSecond(info, routeId);
+        if (perSecond != null) {
+            jo.put("rate", rate(perSecond));
+        }
+    }
+
+    /** ↑ ↓: the cursor moves over the lines of In, Routes and Out that are 
about a route or an endpoint. */
+    void moveCursor(int delta) {
+        int n = targets.size();
+        if (n == 0) {
+            cursor = -1;
+        } else if (cursor < 0) {
+            cursor = delta > 0 ? 0 : n - 1;
+        } else {
+            cursor = Math.max(0, Math.min(n - 1, cursor + delta));
+        }
+    }
+
+    /** The line the cursor is on, or null. */
+    Target selected() {
+        List<Target> t = targets;
+        return cursor >= 0 && cursor < t.size() ? t.get(cursor) : null;
+    }
+
+    /** Enter: opens what the cursor is on; false when it is on nothing. */
+    boolean open() {
+        Target t = selected();
+        if (t == null || ctx.openFromAbout == null) {
+            return false;
+        }
+        ctx.openFromAbout.accept(t);
+        return true;
+    }
+
+    private int seconds() {
+        return TimelineTab.WINDOWS[zoom];
+    }
+
+    private long windowEnd(long end) {
+        return stripsEnd >= 0 ? Math.min(stripsEnd, end) : end;
+    }
+
+    /** Moves the window to end at the given second, as far as there are 
seconds recorded; at the end it follows. */
+    private void moveTo(long second, long end) {
+        long first = Math.max(0, end - Timeline.SECONDS + 1);
+        long to = Math.max(first + seconds() - 1, second);
+        stripsEnd = to >= end ? -1 : to;
+    }
+
+    /** The last second in the window that a route had trouble in, or -1. */
+    private long lastTrouble(IntegrationInfo info, long windowEnd, int 
seconds) {
+        long last = -1;
+        for (Timeline.RouteSeries r : ctx.timeline.routes(info.pid, 
routeOrder(info), windowEnd, seconds)) {
+            for (int i = seconds - 1; i >= 0; i--) {
+                if (r.failed()[i] > 0 || r.handled()[i] > 0 || 
r.recovered()[i] > 0 || r.attempts()[i] > 0
+                        || r.down()[i] > 0 || r.open()[i] > 0) {
+                    last = Math.max(last, windowEnd - seconds + 1 + i);
+                    break;
+                }
+            }
+        }
+        return last;
+    }
+
+    private static List<String> routeOrder(IntegrationInfo info) {
+        return info.routes.stream().map(r -> r.routeId).filter(id -> id != 
null).toList();
+    }
+
+    void render(Frame frame, Rect area, IntegrationInfo info) {
+        load(info, Runnable::run);
+        ProjectOverview.Overview o = overview;
+        int width = area.width();
+        List<Line> head = new ArrayList<>();
+        head.add(Line.from(Span.raw(" "), Span.raw(meta(info, o)),
+                Span.styled(info.vanishing ? "  ○ stopped" : "  ● running",
+                        info.vanishing ? Theme.muted() : Theme.success())));
+        if (purpose != null) {
+            for (String l : wrap(purpose, Math.max(20, width - 6), 2)) {
+                head.add(Line.from(Span.styled(" " + l, Style.EMPTY.bold())));
+            }
+            head.add(Line.from(Span.styled(" " + purposeFrom, Theme.muted())));
+        } else if (o != null) {
+            head.add(Line.from(Span.styled(" No description yet: a comment at 
the top of " + mainFile(o)
+                                           + " says what it does, or ask the 
AI (Shift+F8 on a route)",
+                    Theme.muted())));
+        } else {
+            head.add(Line.from(Span.styled(error != null ? " " + error : " 
Reading the sources…", Theme.muted())));
+        }
+
+        List<Line> in = new ArrayList<>();
+        List<Line> routes = new ArrayList<>();
+        List<Line> out = new ArrayList<>();
+        List<Target> inTargets = new ArrayList<>();
+        List<Target> routeTargets = new ArrayList<>();
+        List<Target> outTargets = new ArrayList<>();
+        if (o != null) {
+            About.Model m = model;
+            if (m != null) {
+                columns(m, info, in, routes, out, inTargets, routeTargets, 
outTargets);
+            }
+        }
+        // the cursor goes down In, then Routes, then Out
+        List<Target> all = new ArrayList<>();
+        List<String> labels = new ArrayList<>();
+        List<String> inLabels = new ArrayList<>();
+        if (model != null) {
+            model.in().stream().filter(e -> e.route() != null).forEach(e -> 
inLabels.add(e.label()));

Review Comment:
   Fixed in cff592e8fdc5: `render()` reads `model` once into `m` and uses it 
for both the columns and the In labels, so they always agree.
   
   _Claude Code on behalf of Claus Ibsen_



##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java:
##########
@@ -0,0 +1,1014 @@
+/*
+ * 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.nio.file.Path;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+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.KeyEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.dsl.jbang.core.commands.ai.About;
+import org.apache.camel.dsl.jbang.core.commands.ai.AboutReport;
+import org.apache.camel.dsl.jbang.core.commands.ai.ProjectOverview;
+import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * About an integration (Overview ▸ About, CAMEL-26700): what it does at a 
glance and without an AI, and what has
+ * happened. A header with what it is and its purpose (the comment at the top 
of its main file, else the descriptions of
+ * its routes), then what comes in, its routes (the routes called with direct 
or seda under the route that calls them)
+ * and what goes out, from the analysis of its sources ({@link 
ProjectOverview}). Under it, the last minutes from the
+ * timeline the integration records, which the Timeline tab (4) shows second 
by second, and what to watch.
+ */
+final class AboutView {
+
+    // how often the sources are read again, to see a change
+    private static final long RELOAD_MILLIS = 10_000;
+    private static final int HAPPENED_SECONDS = 300;
+    private static final String BLOCKS = " ▁▂▃▄▅▆▇█";
+    // the strips have this many columns, whatever the zoom
+    private static final int STRIP_COLUMNS = 60;
+    // the sparklines of In and Out
+    private static final int SPARK_WIDTH = 14;
+    private static final DateTimeFormatter TIME = 
DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
+
+    private final MonitorContext ctx;
+    private volatile ProjectOverview.Overview overview;
+    private volatile About.Model model;
+    private volatile String purpose;
+    private volatile String purposeFrom;
+    private volatile boolean loading;
+    private volatile String error;
+    private Path dir;
+    private long loadedAt;
+    // the zoom of What happened, as the windows of the Timeline tab (+ and 
-), and its last second when moved (← →);
+    // -1 follows the last second recorded
+    private int zoom = TimelineTab.WINDOWS.length - 1;
+    private long stripsEnd = -1;
+    // the line of In, Routes or Out the cursor is on (↑ ↓), in the order of 
the columns; -1 when none is
+    private int cursor = -1;
+    private volatile List<Target> targets = List.of();
+    private volatile List<String> targetLabels = List.of();
+
+    /**
+     * What a line of In, Routes or Out is about, for Enter: a route (opened 
on the Diagram) or an endpoint a route
+     * sends to (opened on the Endpoint tab).
+     *
+     * @param routeId  the route
+     * @param endpoint the URI of the endpoint as the sources have it, or null 
for a route
+     */
+    record Target(String routeId, String endpoint) {
+    }
+
+    AboutView(MonitorContext ctx) {
+        this.ctx = ctx;
+    }
+
+    /** Reads the sources of the integration in the background, again when 
they may have changed. */
+    private void load(IntegrationInfo info, Consumer<Runnable> onRenderThread) 
{
+        Path d = info.directory != null ? Path.of(info.directory) : null;
+        long now = System.currentTimeMillis();
+        boolean same = d != null && d.equals(dir);
+        if (d == null || loading || same && now - loadedAt < RELOAD_MILLIS) {
+            if (d == null) {
+                error = "No source directory for this integration";
+            }
+            return;
+        }
+        if (!same) {
+            overview = null;
+            purpose = null;
+            error = null;
+        }
+        dir = d;
+        loadedAt = now;
+        loading = true;
+        Thread t = new Thread(() -> {
+            try {
+                ProjectOverview.Overview o = ProjectOverview.analyze(d, 
ArchitectureView.catalog());
+                About.Model m = About.of(d, o);
+                onRenderThread.accept(() -> {
+                    overview = o;
+                    model = m;
+                    purpose = m.purpose();
+                    purposeFrom = m.purposeFrom();
+                    error = o.flows().isEmpty() ? "No routes found in " + d : 
null;
+                    loading = false;
+                });
+            } catch (Exception e) {
+                onRenderThread.accept(() -> {
+                    error = "Cannot read the sources: " + e.getMessage();
+                    loading = false;
+                });
+            }
+        }, "CamelTuiAbout");
+        t.setDaemon(true);
+        t.start();
+    }
+
+    /**
+     * The keys of What happened: + zooms in on the last trouble in the window 
(a failure, a handled error, a
+     * redelivery, a route down, a circuit breaker open), - zooms out, and ← → 
move a zoomed window.
+     */
+    boolean handleKeyEvent(KeyEvent ke, IntegrationInfo info) {
+        if (info == null) {
+            return false;
+        }
+        if (ke.isConfirm()) {
+            return open();
+        }
+        long end = ctx.timeline.endSecond(info.pid);
+        if (ke.isChar('+') || ke.isChar('=')) {
+            if (zoom > 0 && end >= 0) {
+                long trouble = lastTrouble(info, windowEnd(end), seconds());
+                zoom--;
+                if (trouble >= 0) {
+                    // the trouble in the middle of the window, when there is 
time after it
+                    moveTo(trouble + seconds() / 2, end);
+                }
+            }
+            return true;
+        }
+        if (ke.isChar('-')) {
+            if (zoom < TimelineTab.WINDOWS.length - 1) {
+                zoom++;
+                if (end >= 0) {
+                    moveTo(windowEnd(end) + seconds() / 4, end);
+                }
+            }
+            return true;
+        }
+        if ((ke.isLeft() || ke.isRight()) && end >= 0) {
+            int by = Math.max(1, seconds() / 4);
+            moveTo(windowEnd(end) + (ke.isLeft() ? -by : by), end);
+            return true;
+        }
+        return false;
+    }
+
+    /** The lines the cursor can be on, for an agent (tui_get_state): the 
route, or the endpoint and its route. */
+    SelectionContext selectionContext() {
+        List<String> items = targetLabels;
+        return new SelectionContext("list", items, cursor < items.size() ? 
cursor : -1, items.size(), "About");
+    }
+
+    /**
+     * About the integration as data, for an agent (tui_get_table): what the 
screen shows, with what happened over the
+     * window of What happened.
+     */
+    JsonObject tableJson(IntegrationInfo info) {
+        JsonObject result = new JsonObject();
+        result.put("tab", "Overview");
+        result.put("view", "About");
+        result.put("integration", info.name != null ? info.name : info.pid);
+        result.put("pid", info.pid);
+        About.Model m = model;
+        if (m == null) {
+            result.put("loading", error != null ? error : "reading the 
sources");
+            return result;
+        }
+        if (m.purpose() != null) {
+            result.put("purpose", m.purpose());
+            result.put("purposeFrom", m.purposeFrom());
+        }
+        Map<String, Long> totals = new LinkedHashMap<>();
+        info.routes.forEach(r -> {
+            if (r.routeId != null) {
+                totals.put(r.routeId, r.total);
+            }
+        });
+        JsonArray in = new JsonArray();
+        for (About.In e : m.in()) {
+            JsonObject jo = new JsonObject();
+            jo.put("label", e.label());
+            jo.put("kind", e.kind());
+            jo.put("routeId", e.route());
+            putTotalAndRate(jo, info, e.route(), totals);
+            in.add(jo);
+        }
+        result.put("in", in);
+        JsonArray routes = new JsonArray();
+        for (About.RouteLine r : m.routes()) {
+            JsonObject jo = new JsonObject();
+            jo.put("routeId", r.routeId());
+            jo.put("depth", r.depth());
+            if (r.how() != null) {
+                jo.put("calledHow", r.how());
+            }
+            jo.put("from", r.from());
+            jo.put("steps", r.steps());
+            if (totals.get(r.routeId()) != null) {
+                jo.put("exchangesTotal", totals.get(r.routeId()));
+            }
+            routes.add(jo);
+        }
+        result.put("routes", routes);
+        JsonArray out = new JsonArray();
+        for (About.Out o : m.out()) {
+            JsonObject jo = new JsonObject();
+            jo.put("uri", o.uri());
+            jo.put("category", o.category());
+            jo.put("routeId", o.route());
+            Long hits = outHits(info, o.uri(), o.route());
+            if (hits != null) {
+                jo.put("sends", hits);
+            }
+            out.add(jo);
+        }
+        result.put("out", out);
+        long last = ctx.timeline.endSecond(info.pid);
+        if (last >= 0) {
+            long end = windowEnd(last);
+            int seconds = seconds();
+            JsonObject happened = new JsonObject();
+            happened.put("seconds", seconds);
+            happened.put("endSecond", end);
+            happened.put("following", stripsEnd < 0);
+            JsonArray lines = new JsonArray();
+            for (Timeline.RouteSeries r : ctx.timeline.routes(info.pid, 
routeOrder(info), end, seconds)) {
+                JsonObject jo = new JsonObject();
+                jo.put("routeId", r.route());
+                jo.put("exchanges", r.total(r.completed()) + 
r.total(r.recovered()) + r.total(r.handled())
+                                    + r.total(r.failed()));
+                long[] worst = r.total(r.failed()) > 0 ? r.failed() : 
r.total(r.handled()) > 0 ? r.handled()
+                        : r.total(r.recovered()) > 0 ? r.recovered() : null;
+                if (worst != null) {
+                    jo.put("worst", worst == r.failed() ? "failed" : worst == 
r.handled() ? "handled" : "recovered");
+                    jo.put("worstCount", r.total(worst));
+                    jo.put("worstSecondsAgo", last - lastAt(worst, end, 
seconds));
+                    if (worst == r.recovered()) {
+                        String from = recoveredFrom(info, r.route(), end - 
seconds + 1, end);
+                        if (from != null) {
+                            jo.put("recoveredFrom", from);
+                        }
+                    }
+                }
+                if (r.openSeconds() > 0) {
+                    jo.put("circuitBreakerOpenSeconds", r.openSeconds());
+                }
+                if (r.total(r.down()) > 0) {
+                    jo.put("downSeconds", r.total(r.down()));
+                }
+                lines.add(jo);
+            }
+            happened.put("routes", lines);
+            JsonArray causes = new JsonArray();
+            for (Timeline.CauseSeries c : ctx.timeline.causes(info.pid, end, 
seconds)) {
+                long total = Arrays.stream(c.values()).sum();
+                if (total > 0) {
+                    JsonObject jo = new JsonObject();
+                    jo.put("label", c.label());
+                    if (c.rule() != null) {
+                        jo.put("chaosRule", c.rule());
+                    }
+                    jo.put("count", total);
+                    causes.add(jo);
+                }
+            }
+            happened.put("causes", causes);
+            result.put("whatHappened", happened);
+        }
+        Target t = selected();
+        if (t != null) {
+            JsonObject sel = new JsonObject();
+            sel.put("routeId", t.routeId());
+            if (t.endpoint() != null) {
+                sel.put("endpoint", t.endpoint());
+            }
+            result.put("selected", sel);
+        }
+        result.put("keys", "v all integrations, Up/Down select a line, Enter 
opens it, +/- zoom, Left/Right move, 4 Timeline");
+        return result;
+    }
+
+    private void putTotalAndRate(JsonObject jo, IntegrationInfo info, String 
routeId, Map<String, Long> totals) {
+        if (routeId == null) {
+            return;
+        }
+        if (totals.get(routeId) != null) {
+            jo.put("exchangesTotal", totals.get(routeId));
+        }
+        long[] perSecond = routePerSecond(info, routeId);
+        if (perSecond != null) {
+            jo.put("rate", rate(perSecond));
+        }
+    }
+
+    /** ↑ ↓: the cursor moves over the lines of In, Routes and Out that are 
about a route or an endpoint. */
+    void moveCursor(int delta) {
+        int n = targets.size();
+        if (n == 0) {
+            cursor = -1;
+        } else if (cursor < 0) {
+            cursor = delta > 0 ? 0 : n - 1;
+        } else {
+            cursor = Math.max(0, Math.min(n - 1, cursor + delta));
+        }
+    }
+
+    /** The line the cursor is on, or null. */
+    Target selected() {
+        List<Target> t = targets;
+        return cursor >= 0 && cursor < t.size() ? t.get(cursor) : null;
+    }
+
+    /** Enter: opens what the cursor is on; false when it is on nothing. */
+    boolean open() {
+        Target t = selected();
+        if (t == null || ctx.openFromAbout == null) {
+            return false;
+        }
+        ctx.openFromAbout.accept(t);
+        return true;
+    }
+
+    private int seconds() {
+        return TimelineTab.WINDOWS[zoom];
+    }
+
+    private long windowEnd(long end) {
+        return stripsEnd >= 0 ? Math.min(stripsEnd, end) : end;
+    }
+
+    /** Moves the window to end at the given second, as far as there are 
seconds recorded; at the end it follows. */
+    private void moveTo(long second, long end) {
+        long first = Math.max(0, end - Timeline.SECONDS + 1);
+        long to = Math.max(first + seconds() - 1, second);
+        stripsEnd = to >= end ? -1 : to;
+    }
+
+    /** The last second in the window that a route had trouble in, or -1. */
+    private long lastTrouble(IntegrationInfo info, long windowEnd, int 
seconds) {
+        long last = -1;
+        for (Timeline.RouteSeries r : ctx.timeline.routes(info.pid, 
routeOrder(info), windowEnd, seconds)) {
+            for (int i = seconds - 1; i >= 0; i--) {
+                if (r.failed()[i] > 0 || r.handled()[i] > 0 || 
r.recovered()[i] > 0 || r.attempts()[i] > 0
+                        || r.down()[i] > 0 || r.open()[i] > 0) {
+                    last = Math.max(last, windowEnd - seconds + 1 + i);
+                    break;
+                }
+            }
+        }
+        return last;
+    }
+
+    private static List<String> routeOrder(IntegrationInfo info) {
+        return info.routes.stream().map(r -> r.routeId).filter(id -> id != 
null).toList();
+    }
+
+    void render(Frame frame, Rect area, IntegrationInfo info) {
+        load(info, Runnable::run);
+        ProjectOverview.Overview o = overview;
+        int width = area.width();
+        List<Line> head = new ArrayList<>();
+        head.add(Line.from(Span.raw(" "), Span.raw(meta(info, o)),
+                Span.styled(info.vanishing ? "  ○ stopped" : "  ● running",
+                        info.vanishing ? Theme.muted() : Theme.success())));
+        if (purpose != null) {
+            for (String l : wrap(purpose, Math.max(20, width - 6), 2)) {

Review Comment:
   Fixed in cff592e8fdc5: `purpose` and `purposeFrom` are read once into locals 
at the top of `render()`.
   
   _Claude Code on behalf of Claus Ibsen_



##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java:
##########
@@ -0,0 +1,1014 @@
+/*
+ * 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.nio.file.Path;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+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.KeyEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.dsl.jbang.core.commands.ai.About;
+import org.apache.camel.dsl.jbang.core.commands.ai.AboutReport;
+import org.apache.camel.dsl.jbang.core.commands.ai.ProjectOverview;
+import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * About an integration (Overview ▸ About, CAMEL-26700): what it does at a 
glance and without an AI, and what has
+ * happened. A header with what it is and its purpose (the comment at the top 
of its main file, else the descriptions of
+ * its routes), then what comes in, its routes (the routes called with direct 
or seda under the route that calls them)
+ * and what goes out, from the analysis of its sources ({@link 
ProjectOverview}). Under it, the last minutes from the
+ * timeline the integration records, which the Timeline tab (4) shows second 
by second, and what to watch.
+ */
+final class AboutView {
+
+    // how often the sources are read again, to see a change
+    private static final long RELOAD_MILLIS = 10_000;
+    private static final int HAPPENED_SECONDS = 300;
+    private static final String BLOCKS = " ▁▂▃▄▅▆▇█";
+    // the strips have this many columns, whatever the zoom
+    private static final int STRIP_COLUMNS = 60;
+    // the sparklines of In and Out
+    private static final int SPARK_WIDTH = 14;
+    private static final DateTimeFormatter TIME = 
DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
+
+    private final MonitorContext ctx;
+    private volatile ProjectOverview.Overview overview;
+    private volatile About.Model model;
+    private volatile String purpose;
+    private volatile String purposeFrom;
+    private volatile boolean loading;
+    private volatile String error;
+    private Path dir;
+    private long loadedAt;
+    // the zoom of What happened, as the windows of the Timeline tab (+ and 
-), and its last second when moved (← →);
+    // -1 follows the last second recorded
+    private int zoom = TimelineTab.WINDOWS.length - 1;
+    private long stripsEnd = -1;
+    // the line of In, Routes or Out the cursor is on (↑ ↓), in the order of 
the columns; -1 when none is
+    private int cursor = -1;
+    private volatile List<Target> targets = List.of();
+    private volatile List<String> targetLabels = List.of();
+
+    /**
+     * What a line of In, Routes or Out is about, for Enter: a route (opened 
on the Diagram) or an endpoint a route
+     * sends to (opened on the Endpoint tab).
+     *
+     * @param routeId  the route
+     * @param endpoint the URI of the endpoint as the sources have it, or null 
for a route
+     */
+    record Target(String routeId, String endpoint) {
+    }
+
+    AboutView(MonitorContext ctx) {
+        this.ctx = ctx;
+    }
+
+    /** Reads the sources of the integration in the background, again when 
they may have changed. */
+    private void load(IntegrationInfo info, Consumer<Runnable> onRenderThread) 
{
+        Path d = info.directory != null ? Path.of(info.directory) : null;
+        long now = System.currentTimeMillis();
+        boolean same = d != null && d.equals(dir);
+        if (d == null || loading || same && now - loadedAt < RELOAD_MILLIS) {
+            if (d == null) {
+                error = "No source directory for this integration";
+            }
+            return;
+        }
+        if (!same) {
+            overview = null;
+            purpose = null;
+            error = null;
+        }
+        dir = d;
+        loadedAt = now;
+        loading = true;
+        Thread t = new Thread(() -> {
+            try {
+                ProjectOverview.Overview o = ProjectOverview.analyze(d, 
ArchitectureView.catalog());
+                About.Model m = About.of(d, o);
+                onRenderThread.accept(() -> {
+                    overview = o;
+                    model = m;
+                    purpose = m.purpose();
+                    purposeFrom = m.purposeFrom();
+                    error = o.flows().isEmpty() ? "No routes found in " + d : 
null;
+                    loading = false;
+                });
+            } catch (Exception e) {
+                onRenderThread.accept(() -> {
+                    error = "Cannot read the sources: " + e.getMessage();
+                    loading = false;
+                });
+            }
+        }, "CamelTuiAbout");
+        t.setDaemon(true);
+        t.start();
+    }
+
+    /**
+     * The keys of What happened: + zooms in on the last trouble in the window 
(a failure, a handled error, a
+     * redelivery, a route down, a circuit breaker open), - zooms out, and ← → 
move a zoomed window.
+     */
+    boolean handleKeyEvent(KeyEvent ke, IntegrationInfo info) {
+        if (info == null) {
+            return false;
+        }
+        if (ke.isConfirm()) {
+            return open();
+        }
+        long end = ctx.timeline.endSecond(info.pid);
+        if (ke.isChar('+') || ke.isChar('=')) {
+            if (zoom > 0 && end >= 0) {
+                long trouble = lastTrouble(info, windowEnd(end), seconds());
+                zoom--;
+                if (trouble >= 0) {
+                    // the trouble in the middle of the window, when there is 
time after it
+                    moveTo(trouble + seconds() / 2, end);
+                }
+            }
+            return true;
+        }
+        if (ke.isChar('-')) {
+            if (zoom < TimelineTab.WINDOWS.length - 1) {
+                zoom++;
+                if (end >= 0) {
+                    moveTo(windowEnd(end) + seconds() / 4, end);
+                }
+            }
+            return true;
+        }
+        if ((ke.isLeft() || ke.isRight()) && end >= 0) {
+            int by = Math.max(1, seconds() / 4);
+            moveTo(windowEnd(end) + (ke.isLeft() ? -by : by), end);
+            return true;
+        }
+        return false;
+    }
+
+    /** The lines the cursor can be on, for an agent (tui_get_state): the 
route, or the endpoint and its route. */
+    SelectionContext selectionContext() {
+        List<String> items = targetLabels;
+        return new SelectionContext("list", items, cursor < items.size() ? 
cursor : -1, items.size(), "About");
+    }
+
+    /**
+     * About the integration as data, for an agent (tui_get_table): what the 
screen shows, with what happened over the
+     * window of What happened.
+     */
+    JsonObject tableJson(IntegrationInfo info) {
+        JsonObject result = new JsonObject();
+        result.put("tab", "Overview");
+        result.put("view", "About");
+        result.put("integration", info.name != null ? info.name : info.pid);
+        result.put("pid", info.pid);
+        About.Model m = model;
+        if (m == null) {
+            result.put("loading", error != null ? error : "reading the 
sources");
+            return result;
+        }
+        if (m.purpose() != null) {
+            result.put("purpose", m.purpose());
+            result.put("purposeFrom", m.purposeFrom());
+        }
+        Map<String, Long> totals = new LinkedHashMap<>();
+        info.routes.forEach(r -> {
+            if (r.routeId != null) {
+                totals.put(r.routeId, r.total);
+            }
+        });
+        JsonArray in = new JsonArray();
+        for (About.In e : m.in()) {
+            JsonObject jo = new JsonObject();
+            jo.put("label", e.label());
+            jo.put("kind", e.kind());
+            jo.put("routeId", e.route());
+            putTotalAndRate(jo, info, e.route(), totals);
+            in.add(jo);
+        }
+        result.put("in", in);
+        JsonArray routes = new JsonArray();
+        for (About.RouteLine r : m.routes()) {
+            JsonObject jo = new JsonObject();
+            jo.put("routeId", r.routeId());
+            jo.put("depth", r.depth());
+            if (r.how() != null) {
+                jo.put("calledHow", r.how());
+            }
+            jo.put("from", r.from());
+            jo.put("steps", r.steps());
+            if (totals.get(r.routeId()) != null) {
+                jo.put("exchangesTotal", totals.get(r.routeId()));
+            }
+            routes.add(jo);
+        }
+        result.put("routes", routes);
+        JsonArray out = new JsonArray();
+        for (About.Out o : m.out()) {
+            JsonObject jo = new JsonObject();
+            jo.put("uri", o.uri());
+            jo.put("category", o.category());
+            jo.put("routeId", o.route());
+            Long hits = outHits(info, o.uri(), o.route());
+            if (hits != null) {
+                jo.put("sends", hits);
+            }
+            out.add(jo);
+        }
+        result.put("out", out);
+        long last = ctx.timeline.endSecond(info.pid);
+        if (last >= 0) {
+            long end = windowEnd(last);
+            int seconds = seconds();
+            JsonObject happened = new JsonObject();
+            happened.put("seconds", seconds);
+            happened.put("endSecond", end);
+            happened.put("following", stripsEnd < 0);
+            JsonArray lines = new JsonArray();
+            for (Timeline.RouteSeries r : ctx.timeline.routes(info.pid, 
routeOrder(info), end, seconds)) {
+                JsonObject jo = new JsonObject();
+                jo.put("routeId", r.route());
+                jo.put("exchanges", r.total(r.completed()) + 
r.total(r.recovered()) + r.total(r.handled())
+                                    + r.total(r.failed()));
+                long[] worst = r.total(r.failed()) > 0 ? r.failed() : 
r.total(r.handled()) > 0 ? r.handled()
+                        : r.total(r.recovered()) > 0 ? r.recovered() : null;
+                if (worst != null) {
+                    jo.put("worst", worst == r.failed() ? "failed" : worst == 
r.handled() ? "handled" : "recovered");
+                    jo.put("worstCount", r.total(worst));
+                    jo.put("worstSecondsAgo", last - lastAt(worst, end, 
seconds));
+                    if (worst == r.recovered()) {
+                        String from = recoveredFrom(info, r.route(), end - 
seconds + 1, end);
+                        if (from != null) {
+                            jo.put("recoveredFrom", from);
+                        }
+                    }
+                }
+                if (r.openSeconds() > 0) {
+                    jo.put("circuitBreakerOpenSeconds", r.openSeconds());
+                }
+                if (r.total(r.down()) > 0) {
+                    jo.put("downSeconds", r.total(r.down()));
+                }
+                lines.add(jo);
+            }
+            happened.put("routes", lines);
+            JsonArray causes = new JsonArray();
+            for (Timeline.CauseSeries c : ctx.timeline.causes(info.pid, end, 
seconds)) {
+                long total = Arrays.stream(c.values()).sum();
+                if (total > 0) {
+                    JsonObject jo = new JsonObject();
+                    jo.put("label", c.label());
+                    if (c.rule() != null) {
+                        jo.put("chaosRule", c.rule());
+                    }
+                    jo.put("count", total);
+                    causes.add(jo);
+                }
+            }
+            happened.put("causes", causes);
+            result.put("whatHappened", happened);
+        }
+        Target t = selected();
+        if (t != null) {
+            JsonObject sel = new JsonObject();
+            sel.put("routeId", t.routeId());
+            if (t.endpoint() != null) {
+                sel.put("endpoint", t.endpoint());
+            }
+            result.put("selected", sel);
+        }
+        result.put("keys", "v all integrations, Up/Down select a line, Enter 
opens it, +/- zoom, Left/Right move, 4 Timeline");
+        return result;
+    }
+
+    private void putTotalAndRate(JsonObject jo, IntegrationInfo info, String 
routeId, Map<String, Long> totals) {
+        if (routeId == null) {
+            return;
+        }
+        if (totals.get(routeId) != null) {
+            jo.put("exchangesTotal", totals.get(routeId));
+        }
+        long[] perSecond = routePerSecond(info, routeId);
+        if (perSecond != null) {
+            jo.put("rate", rate(perSecond));
+        }
+    }
+
+    /** ↑ ↓: the cursor moves over the lines of In, Routes and Out that are 
about a route or an endpoint. */
+    void moveCursor(int delta) {
+        int n = targets.size();
+        if (n == 0) {
+            cursor = -1;
+        } else if (cursor < 0) {
+            cursor = delta > 0 ? 0 : n - 1;
+        } else {
+            cursor = Math.max(0, Math.min(n - 1, cursor + delta));
+        }
+    }
+
+    /** The line the cursor is on, or null. */
+    Target selected() {
+        List<Target> t = targets;
+        return cursor >= 0 && cursor < t.size() ? t.get(cursor) : null;
+    }
+
+    /** Enter: opens what the cursor is on; false when it is on nothing. */
+    boolean open() {
+        Target t = selected();
+        if (t == null || ctx.openFromAbout == null) {
+            return false;
+        }
+        ctx.openFromAbout.accept(t);
+        return true;
+    }
+
+    private int seconds() {
+        return TimelineTab.WINDOWS[zoom];
+    }
+
+    private long windowEnd(long end) {
+        return stripsEnd >= 0 ? Math.min(stripsEnd, end) : end;
+    }
+
+    /** Moves the window to end at the given second, as far as there are 
seconds recorded; at the end it follows. */
+    private void moveTo(long second, long end) {
+        long first = Math.max(0, end - Timeline.SECONDS + 1);
+        long to = Math.max(first + seconds() - 1, second);
+        stripsEnd = to >= end ? -1 : to;
+    }
+
+    /** The last second in the window that a route had trouble in, or -1. */
+    private long lastTrouble(IntegrationInfo info, long windowEnd, int 
seconds) {
+        long last = -1;
+        for (Timeline.RouteSeries r : ctx.timeline.routes(info.pid, 
routeOrder(info), windowEnd, seconds)) {
+            for (int i = seconds - 1; i >= 0; i--) {
+                if (r.failed()[i] > 0 || r.handled()[i] > 0 || 
r.recovered()[i] > 0 || r.attempts()[i] > 0
+                        || r.down()[i] > 0 || r.open()[i] > 0) {
+                    last = Math.max(last, windowEnd - seconds + 1 + i);
+                    break;
+                }
+            }
+        }
+        return last;
+    }
+
+    private static List<String> routeOrder(IntegrationInfo info) {
+        return info.routes.stream().map(r -> r.routeId).filter(id -> id != 
null).toList();
+    }
+
+    void render(Frame frame, Rect area, IntegrationInfo info) {
+        load(info, Runnable::run);
+        ProjectOverview.Overview o = overview;

Review Comment:
   Agreed. In cff592e8fdc5, `load()` now gets `ctx.runner::runOnRenderThread`, 
as HeapHistogramTab and MemoryLeakTab do. It only falls back to `Runnable::run` 
when there is no runner (the tests). The render-time snapshots stay, for the 
MCP state readers.
   
   _Claude Code on behalf of Claus Ibsen_



##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/About.java:
##########
@@ -0,0 +1,235 @@
+/*
+ * 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.ai;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * About an integration (CAMEL-26700): what it does at a glance and without an 
AI, from the analysis of its sources
+ * ({@link ProjectOverview}). Its purpose in the words of its author, what 
comes in, its routes (a route called with
+ * direct or seda under the route that calls it) and what goes out. The About 
screen of the TUI and {@code camel get
+ * about} are built from it, so they say the same.
+ */
+public final class About {
+
+    /**
+     * What starts the integration.
+     *
+     * @param kind  the kind of entry: http, timer, rest, or for a consumer 
the scheme of what it consumes from
+     * @param label the entry as the sources have it, such as timer:orders 
(period=500)
+     * @param route the route it starts
+     */
+    public record In(String kind, String label, String route) {
+    }
+
+    /**
+     * A route, in the order to show it: a route that something else in the 
integration calls comes after its caller,
+     * one deeper.
+     *
+     * @param routeId the route ID (else its key in the analysis)
+     * @param depth   how deep under the route that calls it (0 for a route 
that nothing in the integration calls)
+     * @param how     call (direct: and the like, the caller waits), async 
(seda: and the like), or null at depth 0
+     * @param from    the scheme the route consumes from
+     * @param steps   the number of steps of the route
+     */
+    public record RouteLine(String routeId, int depth, String how, String 
from, int steps) {
+    }
+
+    /**
+     * A system the integration talks to.
+     *
+     * @param category  the kind of system, as the catalog groups the component
+     * @param uri       the endpoint as the sources have it
+     * @param direction out, or in/out for a request-reply
+     * @param route     the route that talks to it
+     */
+    public record Out(String category, String uri, String direction, String 
route) {
+    }
+
+    /**
+     * About an integration.
+     *
+     * @param purpose     what it does, in the words of its author, or null
+     * @param purposeFrom where the purpose comes from, or null
+     */
+    public record Model(String purpose, String purposeFrom, List<In> in, 
List<RouteLine> routes, List<Out> out) {
+    }
+
+    private About() {
+    }
+
+    /** About the integration in the directory, from the analysis of its 
sources. */
+    public static Model of(Path dir, ProjectOverview.Overview o) {
+        String[] p = purpose(dir, o);
+        List<In> in = new ArrayList<>();
+        for (ProjectOverview.EntryPoint e : o.entryPoints()) {
+            // a consumer by the kind of system it consumes from (its scheme)
+            String kind = "consumer".equals(e.kind()) && 
e.label().indexOf(':') > 0
+                    ? e.label().substring(0, e.label().indexOf(':')) : 
e.kind();
+            in.add(new In(kind, e.label(), e.route()));
+        }
+
+        // the routes that something else in the integration calls go under 
their caller
+        Map<String, List<ProjectOverview.Link>> calledBy = new 
LinkedHashMap<>();
+        Set<String> called = new LinkedHashSet<>();
+        for (ProjectOverview.Link l : o.links()) {
+            if (("call".equals(l.kind()) || "async".equals(l.kind())) && 
!l.from().equals(l.to())) {
+                calledBy.computeIfAbsent(l.from(), k -> new 
ArrayList<>()).add(l);
+                called.add(l.to());
+            }
+        }
+        List<RouteLine> routes = new ArrayList<>();
+        Set<String> shown = new LinkedHashSet<>();
+        for (ProjectRoutes.Route r : o.flows()) {
+            if (!called.contains(r.key())) {
+                routeLines(o, r, 0, null, calledBy, shown, routes);
+            }
+        }
+        // what is left: routes called only by routes in a cycle
+        for (ProjectRoutes.Route r : o.flows()) {
+            if (!shown.contains(r.key())) {
+                routeLines(o, r, 0, null, calledBy, shown, routes);
+            }
+        }
+
+        List<Out> out = new ArrayList<>();
+        Set<String> systems = new LinkedHashSet<>();
+        for (ProjectOverview.SystemUse s : o.systems()) {
+            if (!"in".equals(s.direction()) && systems.add(s.uri() + "|" + 
s.direction())) {
+                out.add(new Out(s.category(), s.uri(), s.direction(), 
s.route()));
+            }
+        }
+        return new Model(p != null ? p[0] : null, p != null ? p[1] : null, in, 
routes, out);
+    }
+
+    private static void routeLines(
+            ProjectOverview.Overview o, ProjectRoutes.Route r, int depth, 
String how,
+            Map<String, List<ProjectOverview.Link>> calledBy, Set<String> 
shown, List<RouteLine> lines) {
+        if (!shown.add(r.key()) || depth > 6) {
+            return;
+        }
+        String from = r.from() != null ? r.from().scheme() : r.kind();
+        lines.add(new RouteLine(r.id() != null ? r.id() : r.key(), depth, how, 
from, r.steps()));
+        for (ProjectOverview.Link l : calledBy.getOrDefault(r.key(), 
List.of())) {
+            ProjectRoutes.Route target = o.route(l.to());
+            if (target != null) {
+                routeLines(o, target, depth + 1, l.kind(), calledBy, shown, 
lines);
+            }
+        }
+    }
+
+    /**
+     * What the integration does, in the words of its author: the comment at 
the top of a route file (the files in the
+     * order of the analysis), else the descriptions of the routes that 
something outside starts. Returns the text and
+     * where it comes from, or null.
+     */
+    public static String[] purpose(Path dir, ProjectOverview.Overview o) {
+        for (String file : o.files()) {
+            try {
+                String text = leadingComment(file, 
Files.readString(dir.resolve(file), StandardCharsets.UTF_8));
+                if (text != null) {
+                    return new String[] { text, "from the comment at the top 
of " + file };
+                }
+            } catch (IOException e) {
+                // an unreadable file has no comment
+            }
+        }
+        List<String> descriptions = new ArrayList<>();
+        Set<String> entryRoutes = new LinkedHashSet<>();
+        o.entryPoints().forEach(e -> entryRoutes.add(e.route()));
+        for (ProjectRoutes.Route r : o.flows()) {
+            if (r.hasDescription() && entryRoutes.contains(r.key())) {
+                descriptions.add(r.description());
+            }
+        }
+        for (ProjectRoutes.Route r : o.flows()) {
+            if (r.hasDescription() && !entryRoutes.contains(r.key()) && 
descriptions.size() < 2) {
+                descriptions.add(r.description());
+            }
+        }
+        if (!descriptions.isEmpty()) {
+            return new String[] {
+                    String.join(" ", descriptions.subList(0, Math.min(2, 
descriptions.size()))),
+                    "from the descriptions of the routes" };
+        }
+        return null;
+    }
+
+    /**
+     * The comment a file starts with: the {@code #} lines of YAML, the 
Javadoc or {@code //} lines before the class of
+     * Java, the first {@code <!-- -->} of XML. A license header is no 
description.
+     */
+    public static String leadingComment(String file, String content) {
+        String text = null;
+        if (file.endsWith(".yaml") || file.endsWith(".yml")) {
+            StringBuilder sb = new StringBuilder();
+            for (String line : content.lines().toList()) {
+                String l = line.strip();
+                if (l.startsWith("#")) {
+                    sb.append(l.replaceFirst("^#+\\s?", "")).append(' ');
+                } else if (!l.isEmpty() || !sb.isEmpty()) {
+                    break;
+                }
+            }
+            text = sb.toString();
+        } else if (file.endsWith(".java")) {
+            int cls = content.indexOf(" class ");
+            String before = cls > 0 ? content.substring(0, cls) : content;
+            int doc = before.lastIndexOf("/**");
+            if (doc >= 0) {
+                int end = before.indexOf("*/", doc);
+                text = end > 0 ? before.substring(doc + 3, 
end).replaceAll("(?m)^\\s*\\*\\s?", " ") : null;
+            } else {
+                StringBuilder sb = new StringBuilder();
+                for (String line : before.lines().toList()) {
+                    String l = line.strip();
+                    if (l.startsWith("//") && !l.startsWith("///") && 
!l.startsWith("//DEPS")
+                            && !l.startsWith("//JAVA")) {
+                        sb.append(l.substring(2).strip()).append(' ');
+                    }
+                }
+                text = sb.toString();
+            }
+        } else if (file.endsWith(".xml")) {
+            int start = content.indexOf("<!--");

Review Comment:
   Fixed in cff592e8fdc5: for XML it now walks the comments before the first 
element (after the `<?xml?>` prolog) and takes the first one that is not a 
license header. YAML had the same problem: a `#` license block followed by a 
blank line hid the description block after it, so that is fixed too. A comment 
inside `<routes>` is deliberately not taken. Tests: 
`thePurposeIsTheCommentAfterTheLicenseHeader`.
   
   _Claude Code on behalf of Claus Ibsen_



##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AboutReport.java:
##########
@@ -0,0 +1,409 @@
+/*
+ * 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.ai;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * About an integration as text and JSON (CAMEL-26700), for {@code camel get 
about} and the AI tools: {@link About}
+ * (what it does, from its sources) and, when it runs, what has happened: the 
exchanges of its routes since it started
+ * (its status), and the last minutes from the timeline it records (the 
timeline dev console).
+ */
+public final class AboutReport {
+
+    private AboutReport() {
+    }
+
+    /**
+     * About the integration as JSON.
+     *
+     * @param model    about it, from its sources
+     * @param status   its status (the status file of the CLI connector), or 
null when it does not run
+     * @param timeline its timeline (the timeline dev console), or null
+     */
+    public static JsonObject json(About.Model model, JsonObject status, 
JsonObject timeline) {
+        Map<String, JsonObject> routeStatus = routeStatus(status);
+        JsonObject answer = new JsonObject();
+        if (model.purpose() != null) {
+            answer.put("purpose", model.purpose());
+            answer.put("purposeFrom", model.purposeFrom());
+        }
+        JsonArray in = new JsonArray();
+        for (About.In e : model.in()) {
+            JsonObject jo = new JsonObject();
+            jo.put("kind", e.kind());
+            jo.put("label", e.label());
+            jo.put("routeId", e.route());
+            putTotal(jo, routeStatus.get(e.route()));
+            in.add(jo);
+        }
+        answer.put("in", in);
+        JsonArray routes = new JsonArray();
+        for (About.RouteLine r : model.routes()) {
+            JsonObject jo = new JsonObject();
+            jo.put("routeId", r.routeId());
+            jo.put("depth", r.depth());
+            if (r.how() != null) {
+                jo.put("calledHow", r.how());
+            }
+            jo.put("from", r.from());
+            jo.put("steps", r.steps());
+            putTotal(jo, routeStatus.get(r.routeId()));
+            routes.add(jo);
+        }
+        answer.put("routes", routes);
+        JsonArray out = new JsonArray();
+        for (About.Out o : model.out()) {
+            JsonObject jo = new JsonObject();
+            jo.put("category", o.category());
+            jo.put("uri", o.uri());
+            jo.put("direction", o.direction());
+            jo.put("routeId", o.route());
+            Long hits = sends(status, o.uri(), o.route());
+            if (hits != null) {
+                jo.put("sends", hits);
+            }
+            out.add(jo);
+        }
+        answer.put("out", out);
+        if (timeline != null) {
+            answer.put("lastMinutes", lastMinutes(timeline));
+        }
+        return answer;
+    }
+
+    /**
+     * About the integration that runs from the directory, if one does: its 
pid and name, and {@link #json} with its
+     * status and timeline; null when none runs from there.
+     */
+    public static JsonObject running(Path dir, About.Model model) {
+        Path wanted = dir.toAbsolutePath().normalize();
+        for (RuntimeHelper.ProcessInfo p : RuntimeHelper.discoverProcesses()) {
+            JsonObject status = RuntimeHelper.readStatus(p.pid());
+            JsonObject runtime = status != null ? (JsonObject) 
status.get("runtime") : null;
+            String directory = runtime != null ? 
runtime.getString("directory") : null;
+            if (directory == null || 
!Path.of(directory).toAbsolutePath().normalize().equals(wanted)) {
+                continue;
+            }
+            JsonObject timeline = null;
+            try {
+                JsonObject request = new JsonObject();
+                request.put("action", "timeline");
+                request.put("exchanges", "true");
+                timeline = RuntimeHelper.executeAction(p.pid(), request, 5000);
+            } catch (IOException e) {
+                // no timeline: the routes since the start only
+            }
+            JsonObject answer = json(model, status, timeline != null && 
timeline.containsKey("routes") ? timeline : null);
+            answer.put("pid", Long.toString(p.pid()));
+            answer.put("name", p.name());
+            return answer;
+        }
+        return null;
+    }
+
+    /** About the integration as text, from its JSON ({@link #json}). */
+    public static String text(String title, JsonObject about) {
+        StringBuilder sb = new StringBuilder();
+        sb.append(title).append('\n');
+        if (about.getString("purpose") != null) {
+            sb.append(about.getString("purpose")).append('\n');
+            sb.append("  
(").append(about.getString("purposeFrom")).append(")\n");
+        }
+        sb.append("\nIn\n");
+        List<JsonObject> in = list(about, "in");
+        if (in.isEmpty()) {
+            sb.append("  nothing from outside\n");
+        }
+        for (JsonObject e : in) {
+            sb.append(String.format("  %-44s %s%n", e.getString("label"), 
total(e)));
+        }
+        sb.append("\nRoutes\n");
+        for (JsonObject r : list(about, "routes")) {
+            String how = "call".equals(r.getString("calledHow")) ? "↳ " : 
"async".equals(r.getString("calledHow")) ? "⇢ " : "";
+            int steps = r.getIntegerOrDefault("steps", 0);
+            String line = "  ".repeat(r.getIntegerOrDefault("depth", 0)) + how 
+ r.getString("routeId") + "  "
+                          + r.getString("from") + " → " + steps + (steps == 1 
? " step" : " steps");
+            sb.append(String.format("  %-44s %s%n", line, total(r)));
+        }
+        sb.append("\nOut\n");
+        List<JsonObject> out = list(about, "out");
+        if (out.isEmpty()) {
+            sb.append("  nothing outside the integration\n");
+        }
+        for (JsonObject o : out) {
+            sb.append(String.format("  %-44s %s%n", o.getString("uri") + "  
from " + o.getString("routeId"),
+                    o.get("sends") != null ? o.getLong("sends") + "" : ""));
+        }
+        JsonObject last = (JsonObject) about.get("lastMinutes");
+        if (last != null) {
+            sb.append("\nLast 
").append(minutes(last.getIntegerOrDefault("seconds", 0))).append('\n');
+            sb.append("  ").append(last.getLongOrDefault("exchanges", 
0)).append(" exchanges");
+            for (String k : List.of("recovered", "handled", "failed")) {
+                if (last.getLongOrDefault(k, 0) > 0) {
+                    sb.append(" · ").append(last.getLong(k)).append(' 
').append(k);
+                }
+            }
+            JsonObject cause = (JsonObject) last.get("cause");
+            if (cause != null) {
+                sb.append(" · ").append(cause.getString("label")).append(" 
×").append(cause.getLong("count"))
+                        .append(" (last 
").append(ago(cause.getLongOrDefault("lastSecondsAgo", 0))).append(')');
+            }
+            sb.append('\n');
+            for (JsonObject r : list(last, "routes")) {
+                StringBuilder line = new StringBuilder();
+                line.append(String.format("  %-20s %6d", 
r.getString("routeId"), r.getLongOrDefault("exchanges", 0)));
+                if (r.get("worst") != null) {
+                    line.append("  ").append(r.getLong("worstCount")).append(' 
').append(r.getString("worst"))
+                            .append(' 
').append(ago(r.getLongOrDefault("worstSecondsAgo", 0)));
+                    if (r.getString("recoveredFrom") != null) {
+                        line.append(" · 
").append(r.getString("recoveredFrom"));
+                    }
+                }
+                if (r.getLongOrDefault("circuitBreakerOpenSeconds", 0) > 0) {
+                    line.append("  circuit breaker open 
").append(r.getLong("circuitBreakerOpenSeconds")).append('s');
+                }
+                if (r.getLongOrDefault("downSeconds", 0) > 0) {
+                    line.append("  down 
").append(r.getLong("downSeconds")).append('s');
+                }
+                sb.append(line).append('\n');
+            }
+        }
+        return sb.toString();
+    }
+
+    /**
+     * What happened in the seconds of the timeline: the exchanges and how 
they ended, the cause that happened most with
+     * when it last did, and per route the worst that happened to it with 
when, the failure a recovery recovered from,
+     * and the seconds a circuit breaker was open or a chaos rule had it down.
+     */
+    static JsonObject lastMinutes(JsonObject timeline) {
+        int n = timeline.getIntegerOrDefault("seconds", 0);
+        JsonObject answer = new JsonObject();
+        answer.put("seconds", n);
+        long exchanges = 0;
+        Map<String, Long> totals = new LinkedHashMap<>();
+        Map<String, long[]> open = new LinkedHashMap<>();
+        for (JsonObject cb : list(timeline, "circuitBreakers")) {
+            long[] states = values(cb, "states", n);
+            long[] v = open.computeIfAbsent(cb.getString("routeId"), k -> new 
long[n]);
+            for (int i = 0; i < n; i++) {
+                v[i] = Math.max(v[i], states[i]);
+            }
+        }
+        // the failure a recovered exchange of a route was redelivered for, 
the last one
+        Map<String, String> recoveredFrom = new LinkedHashMap<>();
+        String anyRecoveredFrom = null;
+        for (JsonObject e : list(timeline, "exchanges")) {
+            if (e.getBooleanOrDefault("redelivered", false) && 
!e.getBooleanOrDefault("failed", false)
+                    && !e.getBooleanOrDefault("handled", false) && 
e.getString("exception") != null) {
+                recoveredFrom.put(e.getString("routeId"), 
e.getString("exception"));
+                anyRecoveredFrom = e.getString("exception");
+            }
+        }
+        JsonArray routes = new JsonArray();
+        for (JsonObject r : list(timeline, "routes")) {
+            String routeId = r.getString("routeId");
+            long[] completed = values(r, "completed", n);
+            long[] recovered = values(r, "recovered", n);
+            long[] handled = values(r, "handled", n);
+            long[] failed = values(r, "failed", n);
+            long done = sum(completed) + sum(recovered) + sum(handled) + 
sum(failed);
+            totals.merge("recovered", sum(recovered), Long::sum);
+            totals.merge("handled", sum(handled), Long::sum);
+            totals.merge("failed", sum(failed), Long::sum);
+            JsonObject jo = new JsonObject();
+            jo.put("routeId", routeId);
+            jo.put("exchanges", done);
+            long[] worst = sum(failed) > 0 ? failed : sum(handled) > 0 ? 
handled : sum(recovered) > 0 ? recovered : null;
+            if (worst != null) {
+                jo.put("worst", worst == failed ? "failed" : worst == handled 
? "handled" : "recovered");
+                jo.put("worstCount", sum(worst));
+                jo.put("worstSecondsAgo", n - 1 - last(worst));
+                if (worst == recovered) {
+                    String from = recoveredFrom.getOrDefault(routeId, 
anyRecoveredFrom);
+                    if (from != null) {
+                        jo.put("recoveredFrom", from);
+                    }
+                }
+            }
+            long[] states = open.get(routeId);
+            if (states != null) {
+                jo.put("circuitBreakerOpenSeconds", 
Arrays.stream(states).filter(v -> v >= 2).count());
+            }
+            if (r.get("down") != null && sum(values(r, "down", n)) > 0) {
+                jo.put("downSeconds", sum(values(r, "down", n)));

Review Comment:
   Fixed in cff592e8fdc5: computed once into a local.
   
   _Claude Code on behalf of Claus Ibsen_



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to