gnodet-bot commented on code in PR #29385: URL: https://github.com/apache/camel/pull/29385#discussion_r4503063270
########## 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: 💡 **Root cause of concurrency issues: `Runnable::run` runs the update on the background thread** `render()` passes `Runnable::run` as the `onRenderThread` consumer, so when `load()`'s background thread calls `onRenderThread.accept(...)`, the lambda that sets `model`, `purpose`, `purposeFrom`, etc. executes on the **background thread**, not the render thread. This means all those volatile writes happen concurrently with render reads — and since they can't be made atomic with individual volatile fields, the TOCTOU races in B1/B2 above are unavoidable unless locals are snapshotted. If other views use `ctx.runner.runOnRenderThread()` to push updates to the render thread, consider doing the same here for consistency (it also keeps the render thread's field-read perspective coherent). ########## 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: ⚠️ **Concurrency bug: volatile `purpose` and `purposeFrom` read without snapshot** The null check at line 404 (`if (purpose != null)`) and the actual read at line 405 (`wrap(purpose, ...)`) are two separate volatile reads. Between them, the background thread can set `purpose = null` (when switching integrations) → `wrap()` receives null → NPE on `text.split(" ")` or similar. `purposeFrom` at line 408 is also an unguarded re-read that can be null even after the check passed. ```suggestion String p = purpose; String pFrom = purposeFrom; if (p != null) { for (String l : wrap(p, Math.max(20, width - 6), 2)) { head.add(Line.from(Span.styled(" " + l, Style.EMPTY.bold()))); } head.add(Line.from(Span.styled(" " + pFrom, Theme.muted()))); ``` ########## 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))); + } + routes.add(jo); + } + for (Object v : timeline.getCollectionOrDefault("exchangesDone", List.of())) { + exchanges += v instanceof Number num ? num.longValue() : 0; + } + answer.put("exchanges", exchanges); + totals.forEach(answer::put); + // the cause that happened most + JsonObject cause = null; + long most = 0; + for (JsonObject e : list(timeline, "errors")) { + long[] counts = values(e, "counts", n); + if (sum(counts) > most) { + most = sum(counts); + String exception = e.getString("exception"); + String label = exception != null ? exception.substring(exception.lastIndexOf('.') + 1) : "error"; + if (e.get("statusCode") != null) { + label += " " + e.getInteger("statusCode"); + } + if (e.getString("chaosRule") != null) { + label = "chaos rule " + e.getString("chaosRule") + ": " + label; + } + cause = new JsonObject(); + cause.put("label", label); + cause.put("routeId", e.getString("routeId")); + cause.put("count", most); + cause.put("lastSecondsAgo", n - 1 - last(counts)); + } + } + if (cause != null) { + answer.put("cause", cause); + } + answer.put("routes", routes); + return answer; + } + + private static Map<String, JsonObject> routeStatus(JsonObject status) { + Map<String, JsonObject> answer = new LinkedHashMap<>(); + if (status != null && status.get("routes") instanceof List<?> routes) { + for (Object o : routes) { + if (o instanceof JsonObject r && r.getString("routeId") != null) { + answer.put(r.getString("routeId"), r); + } + } + } + return answer; + } + + private static void putTotal(JsonObject jo, JsonObject route) { + if (route != null && route.get("statistics") instanceof Map<?, ?> stats) { + if (stats.get("exchangesTotal") instanceof Number total) { + jo.put("exchangesTotal", total.longValue()); + } + if (stats.get("exchangesFailed") instanceof Number failed && failed.longValue() > 0) { + jo.put("exchangesFailed", failed.longValue()); + } + } + } + + /** + * How many times the route sent to the endpoint, from the endpoint statistics in the status; null when there is no + * such endpoint (a URI with a placeholder, or no statistics). + */ + static Long sends(JsonObject status, String uri, String route) { + if (status == null || !(status.get("endpoints") instanceof JsonObject endpoints) + || !(endpoints.get("endpoints") instanceof List<?> list)) { + return null; + } + String base = baseUri(uri); + Long answer = null; + for (Object o : list) { + if (o instanceof JsonObject ep && "out".equals(ep.getString("direction")) + && (route == null || route.equals(ep.getString("routeId"))) && base.equals(baseUri(ep.getString("uri"))) + && ep.get("hits") instanceof Number hits) { + answer = (answer != null ? answer : 0) + hits.longValue(); + } + } + return answer; + } + + /** The URI without its options, and the same with or without the // after the scheme. */ + public static String baseUri(String uri) { + if (uri == null) { + return ""; + } + int q = uri.indexOf('?'); + String answer = q >= 0 ? uri.substring(0, q) : uri; + answer = answer.replaceFirst("^([a-zA-Z0-9+.-]+):/{0,2}", "$1:"); Review Comment: ⚠️ **`baseUri()` regex `/{0,2}` normalizes `file:` URIs inconsistently** `/{0,2}` matches 0, 1, or 2 leading slashes after the scheme. Result: - `file:/tmp/foo` → strips 1 slash → `file:tmp/foo` ❌ - `file:///tmp/foo` → strips 2 of 3 slashes → `file:/tmp/foo` ✅ (correct) Same logical endpoint, different normalized form. This breaks send-count matching in `sends()` when the source records `file:/path` and the runtime reports `file:///path`. Change to `/{1,3}` to require at least one slash (so scheme-only URIs like `direct:foo` with no slash are unchanged) and strip up to 3 (normalizing all `file:/`, `file://`, `file:///` to `file:`): ```suggestion answer = answer.replaceFirst("^([a-zA-Z0-9+.-]+):/{1,3}", "$1:"); ``` ########## 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: 💡 **`values(r, "down", n)` called twice — extract to local** Both the null guard and the `put` value call `values(r, "down", n)` with identical arguments. Each call allocates a `long[n]` and iterates the JSON list. ```suggestion long[] down = values(r, "down", n); if (r.get("down") != null && sum(down) > 0) { jo.put("downSeconds", sum(down)); ``` ########## 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) { Review Comment: 💡 **`leadingComment` is `public` but has no null guard on `content`** A null `content` will NPE at `content.lines()` (YAML branch) or `content.indexOf(...)` (XML/Java). All current call sites pass non-null content (from `Files.readString`) but a public method that NPEs on null is a trap for future callers. Consider adding an early return: `if (content == null) return null;` ########## 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: ⚠️ **Concurrency bug: volatile `model` read twice** `model` is snapshot to local `m` at line 429, correctly used in `columns(m, ...)`. But 4 lines later (here), `model` is re-read directly from the volatile field — bypassing the snapshot. **Race scenario:** background thread nulls `model` between the snapshot at line 429 and this re-read → `m` is non-null (columns populated) but `model` is null → no NPE here. Reverse race: `m` is null at line 429 (columns skipped), but `model` is non-null here → `inLabels` is populated while `inTargets` is empty → cursor/label mismatch: pressing Enter navigates to nothing. ```suggestion if (m != null) { m.in().stream().filter(e -> e.route() != null).forEach(e -> inLabels.add(e.label())); } ``` ########## 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: ⚠️ **XML `leadingComment` only examines the first `<!--` comment** `content.indexOf("<!--")` finds only the first XML comment. In Apache XML route files, this is typically the license header. The license filter at the end of the method correctly rejects it and returns `null` — but then the actual description comment (a second `<!-- -->`) is never examined. By contrast: the Java branch uses `before.lastIndexOf("/**")` to skip to the class-level Javadoc, correctly ignoring the license header. Consider looping through XML comments until a non-license one is found: ```java int start = 0; String text = null; while (true) { int s = content.indexOf("<!--", start); if (s < 0) break; int e = content.indexOf("-->", s); if (e < 0) break; String candidate = content.substring(s + 4, e).replaceAll("\\s+", " ").strip(); if (!candidate.contains("Licensed to the Apache") && !candidate.contains("SPDX-License")) { text = candidate; break; } start = e + 3; } ``` -- 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]
