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]
