This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch feature/CAMEL-24328-jfr-tui-data in repository https://gitbox.apache.org/repos/asf/camel.git
commit 2c9cd9a45c697bed13bf592e6135495c834c14f7 Author: Claus Ibsen <[email protected]> AuthorDate: Mon Aug 3 15:27:45 2026 +0200 CAMEL-24328: Show JFR runtime data in TUI JFR tab Add a snapshot command to CamelJfrDevConsole that takes a point-in-time JFR recording snapshot, aggregates Camel events server-side, and returns JSON with five data sections: routes, processors, endpoints, failures, and redeliveries. Restructure the TUI JfrTab to display this data in navigable tables with view switching (1-5), sort cycling (s/S), and route-to-processor drill-down (Enter). Data refresh is on-demand via F5. Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../META-INF/org/apache/camel/dev-console/jfr.json | 32 +- .../camel/runtime/jfr/CamelJfrDevConsole.java | 243 ++++++- .../camel/runtime/jfr/CamelJfrDevConsoleTest.java | 76 ++ .../camel/cli/connector/LocalCliConnector.java | 8 + .../camel/dsl/jbang/core/commands/tui/JfrTab.java | 809 +++++++++++++++++++-- .../jbang/core/commands/tui/JfrTabRenderTest.java | 161 +++- 6 files changed, 1262 insertions(+), 67 deletions(-) diff --git a/components/camel-jfr/src/generated/resources/META-INF/org/apache/camel/dev-console/jfr.json b/components/camel-jfr/src/generated/resources/META-INF/org/apache/camel/dev-console/jfr.json index 0886083d6657..1d7b21b30794 100644 --- a/components/camel-jfr/src/generated/resources/META-INF/org/apache/camel/dev-console/jfr.json +++ b/components/camel-jfr/src/generated/resources/META-INF/org/apache/camel/dev-console/jfr.json @@ -25,7 +25,8 @@ "status", "enable", "disable", - "jfc" + "jfc", + "snapshot" ], "deprecated": false, "autowired": false, @@ -70,6 +71,35 @@ "secret": false, "defaultValue": "all", "description": "The runtime event to enable or disable, or all for every event" + }, + "limit": { + "index": 3, + "kind": "option", + "displayName": "Limit", + "group": "query", + "label": "query", + "required": false, + "type": "integer", + "javaType": "int", + "deprecated": false, + "autowired": false, + "secret": false, + "defaultValue": 50, + "description": "Maximum number of failure and redelivery entries to return in a snapshot" + }, + "routeId": { + "index": 4, + "kind": "option", + "displayName": "Route Id", + "group": "query", + "label": "query", + "required": false, + "type": "string", + "javaType": "java.lang.String", + "deprecated": false, + "autowired": false, + "secret": false, + "description": "Filter snapshot results to the given route id" } } } diff --git a/components/camel-jfr/src/main/java/org/apache/camel/runtime/jfr/CamelJfrDevConsole.java b/components/camel-jfr/src/main/java/org/apache/camel/runtime/jfr/CamelJfrDevConsole.java index 0f19b4b25ec8..e2a270a2834c 100644 --- a/components/camel-jfr/src/main/java/org/apache/camel/runtime/jfr/CamelJfrDevConsole.java +++ b/components/camel-jfr/src/main/java/org/apache/camel/runtime/jfr/CamelJfrDevConsole.java @@ -16,8 +16,14 @@ */ package org.apache.camel.runtime.jfr; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; import java.util.ArrayList; +import java.util.Comparator; import java.util.EnumSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -25,6 +31,8 @@ import java.util.Set; import jdk.jfr.FlightRecorder; import jdk.jfr.Recording; import jdk.jfr.RecordingState; +import jdk.jfr.consumer.RecordedEvent; +import jdk.jfr.consumer.RecordingFile; import org.apache.camel.CamelContext; import org.apache.camel.spi.LifecycleStrategy; import org.apache.camel.spi.Metadata; @@ -44,7 +52,7 @@ import org.apache.camel.util.json.JsonObject; public class CamelJfrDevConsole extends AbstractDevConsole { @Metadata(label = "query", description = "Command to perform", javaType = "java.lang.String", - defaultValue = "status", enums = "status,enable,disable,jfc") + defaultValue = "status", enums = "status,enable,disable,jfc,snapshot") public static final String COMMAND = "command"; @Metadata(label = "query", description = "The runtime event to enable or disable, or all for every event", @@ -57,7 +65,17 @@ public class CamelJfrDevConsole extends AbstractDevConsole { javaType = "java.lang.String") public static final String DISABLE = "disable"; + @Metadata(label = "query", description = "Filter snapshot results to the given route id", + javaType = "java.lang.String") + public static final String ROUTE_ID = "routeId"; + + @Metadata(label = "query", + description = "Maximum number of failure and redelivery entries to return in a snapshot", + javaType = "int", defaultValue = "50") + public static final String LIMIT = "limit"; + private static final String ALL = "all"; + private static final int DEFAULT_LIMIT = 50; public CamelJfrDevConsole() { super("camel", "jfr", "JFR Runtime Instrumentation", @@ -199,6 +217,223 @@ public class CamelJfrDevConsole extends AbstractDevConsole { + "\n(replace 'default' with whatever base profile your recording already uses)"; } + // ---- snapshot ---- + + private static class DurationStats { + long count; + long failed; + long minNanos = Long.MAX_VALUE; + long maxNanos; + long totalNanos; + + void record(long nanos, boolean isFailed) { + count++; + if (isFailed) { + failed++; + } + totalNanos += nanos; + if (nanos < minNanos) { + minNanos = nanos; + } + if (nanos > maxNanos) { + maxNanos = nanos; + } + } + + double minMs() { + return count == 0 ? 0 : minNanos / 1_000_000.0; + } + + double meanMs() { + return count == 0 ? 0 : (totalNanos / (double) count) / 1_000_000.0; + } + + double maxMs() { + return maxNanos / 1_000_000.0; + } + + JsonObject toJson() { + JsonObject jo = new JsonObject(); + jo.put("total", count); + jo.put("failed", failed); + jo.put("minMs", round3(minMs())); + jo.put("meanMs", round3(meanMs())); + jo.put("maxMs", round3(maxMs())); + return jo; + } + } + + private static double round3(double value) { + return Math.round(value * 1000.0) / 1000.0; + } + + private JsonObject doSnapshot(Map<String, Object> options) { + String routeIdFilter = optionString(options, ROUTE_ID); + int limit = DEFAULT_LIMIT; + String limitStr = optionString(options, LIMIT); + if (limitStr != null) { + try { + limit = Integer.parseInt(limitStr); + } catch (NumberFormatException e) { + // keep default + } + } + + JsonObject result = new JsonObject(); + result.put("snapshot", true); + + Path tempFile = null; + try (Recording snapshot = FlightRecorder.getFlightRecorder().takeSnapshot()) { + if (snapshot.getSize() == 0) { + result.put("error", "no JFR data available: ensure a recording is active"); + return result; + } + tempFile = Files.createTempFile("camel-jfr-snapshot-", ".jfr"); + snapshot.dump(tempFile); + + Map<String, DurationStats> routeStats = new LinkedHashMap<>(); + Map<String, DurationStats> processorStats = new LinkedHashMap<>(); + Map<String, String> processorTypes = new LinkedHashMap<>(); + Map<String, String> processorRoutes = new LinkedHashMap<>(); + Map<String, DurationStats> endpointStats = new LinkedHashMap<>(); + List<JsonObject> failures = new ArrayList<>(); + List<JsonObject> redeliveries = new ArrayList<>(); + int eventCount = 0; + + String routeEventName = CamelJfrEvents.ROUTE.getEventName(); + String processorEventName = CamelJfrEvents.PROCESSOR.getEventName(); + String sendEventName = CamelJfrEvents.SEND.getEventName(); + String failedEventName = CamelJfrEvents.FAILED.getEventName(); + String redeliveryEventName = CamelJfrEvents.REDELIVERY.getEventName(); + + try (RecordingFile rf = new RecordingFile(tempFile)) { + while (rf.hasMoreEvents()) { + RecordedEvent event = rf.readEvent(); + String eventName = event.getEventType().getName(); + + if (routeEventName.equals(eventName)) { + String routeId = event.getString("routeId"); + if (routeIdFilter == null || routeIdFilter.equals(routeId)) { + long nanos = durationNanos(event); + boolean failed = event.getBoolean("failed"); + routeStats.computeIfAbsent(routeId, k -> new DurationStats()).record(nanos, failed); + eventCount++; + } + } else if (processorEventName.equals(eventName)) { + String routeId = event.getString("routeId"); + if (routeIdFilter == null || routeIdFilter.equals(routeId)) { + String processorId = event.getString("processorId"); + long nanos = durationNanos(event); + boolean failed = event.getBoolean("failed"); + processorStats.computeIfAbsent(processorId, k -> new DurationStats()).record(nanos, failed); + processorTypes.putIfAbsent(processorId, event.getString("processorType")); + processorRoutes.putIfAbsent(processorId, routeId); + eventCount++; + } + } else if (sendEventName.equals(eventName)) { + String endpointUri = event.getString("endpointUri"); + long nanos = durationNanos(event); + boolean failed = event.getBoolean("failed"); + endpointStats.computeIfAbsent(endpointUri, k -> new DurationStats()).record(nanos, failed); + eventCount++; + } else if (failedEventName.equals(eventName)) { + String routeId = event.getString("routeId"); + if (routeIdFilter == null || routeIdFilter.equals(routeId)) { + JsonObject fo = new JsonObject(); + fo.put("timestamp", event.getStartTime().toString()); + fo.put("exchangeId", event.getString("exchangeId")); + fo.put("routeId", routeId); + fo.put("exceptionType", event.getString("exceptionType")); + fo.put("exceptionMessage", event.getString("exceptionMessage")); + failures.add(fo); + eventCount++; + } + } else if (redeliveryEventName.equals(eventName)) { + String routeId = event.getString("routeId"); + if (routeIdFilter == null || routeIdFilter.equals(routeId)) { + JsonObject ro = new JsonObject(); + ro.put("timestamp", event.getStartTime().toString()); + ro.put("exchangeId", event.getString("exchangeId")); + ro.put("routeId", routeId); + ro.put("attempt", event.getInt("attempt")); + ro.put("maxAttempts", event.getInt("maxAttempts")); + redeliveries.add(ro); + eventCount++; + } + } + } + } + + result.put("eventCount", eventCount); + + // routes — sorted by total descending + JsonArray routesJson = new JsonArray(); + routeStats.entrySet().stream() + .sorted(Comparator.<Map.Entry<String, DurationStats>> comparingLong(e -> e.getValue().count).reversed()) + .forEach(e -> { + JsonObject jo = e.getValue().toJson(); + jo.put("routeId", e.getKey()); + routesJson.add(jo); + }); + result.put("routes", routesJson); + + // processors — sorted by mean duration descending (slowest first) + JsonArray processorsJson = new JsonArray(); + processorStats.entrySet().stream() + .sorted(Comparator.<Map.Entry<String, DurationStats>> comparingDouble( + e -> e.getValue().meanMs()).reversed()) + .forEach(e -> { + JsonObject jo = e.getValue().toJson(); + jo.put("processorId", e.getKey()); + jo.put("processorType", processorTypes.get(e.getKey())); + jo.put("routeId", processorRoutes.get(e.getKey())); + processorsJson.add(jo); + }); + result.put("processors", processorsJson); + + // endpoints — sorted by total descending + JsonArray endpointsJson = new JsonArray(); + endpointStats.entrySet().stream() + .sorted(Comparator.<Map.Entry<String, DurationStats>> comparingLong( + e -> e.getValue().count).reversed()) + .forEach(e -> { + JsonObject jo = e.getValue().toJson(); + jo.put("endpointUri", e.getKey()); + endpointsJson.add(jo); + }); + result.put("endpoints", endpointsJson); + + // failures — newest first, capped at limit + failures.sort(Comparator.comparing((JsonObject o) -> o.getString("timestamp")).reversed()); + JsonArray failuresJson = new JsonArray(); + failures.stream().limit(limit).forEach(failuresJson::add); + result.put("failures", failuresJson); + + // redeliveries — newest first, capped at limit + redeliveries.sort(Comparator.comparing((JsonObject o) -> o.getString("timestamp")).reversed()); + JsonArray redeliveriesJson = new JsonArray(); + redeliveries.stream().limit(limit).forEach(redeliveriesJson::add); + result.put("redeliveries", redeliveriesJson); + + } catch (IOException e) { + result.put("error", "failed to read JFR snapshot: " + e.getMessage()); + } finally { + if (tempFile != null) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException ignored) { + } + } + } + + return result; + } + + private static long durationNanos(RecordedEvent event) { + Duration d = event.getDuration(); + return d != null ? d.toNanos() : 0; + } + @Override protected String doCallText(Map<String, Object> options) { String command = optionString(options, COMMAND); @@ -213,6 +448,7 @@ public class CamelJfrDevConsole extends AbstractDevConsole { yield e.getMessage(); } } + case "snapshot" -> doSnapshot(options).toJson(); default -> unknownCommand(command); }; } @@ -252,12 +488,15 @@ public class CamelJfrDevConsole extends AbstractDevConsole { root.put("error", e.getMessage()); } } + case "snapshot" -> { + return doSnapshot(options); + } default -> root.put("error", unknownCommand(command)); } return root; } private static String unknownCommand(String command) { - return "unknown command: " + command + ". Valid values: status, enable, disable, jfc"; + return "unknown command: " + command + ". Valid values: status, enable, disable, jfc, snapshot"; } } diff --git a/components/camel-jfr/src/test/java/org/apache/camel/runtime/jfr/CamelJfrDevConsoleTest.java b/components/camel-jfr/src/test/java/org/apache/camel/runtime/jfr/CamelJfrDevConsoleTest.java index 0965a4c03890..eb9e105904aa 100644 --- a/components/camel-jfr/src/test/java/org/apache/camel/runtime/jfr/CamelJfrDevConsoleTest.java +++ b/components/camel-jfr/src/test/java/org/apache/camel/runtime/jfr/CamelJfrDevConsoleTest.java @@ -26,6 +26,7 @@ import org.apache.camel.impl.DefaultCamelContext; import org.apache.camel.startup.jfr.FlightRecorderStartupStepRecorder; import org.apache.camel.support.PluginHelper; import org.apache.camel.test.junit6.CamelTestSupport; +import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; import org.junit.jupiter.api.Test; @@ -256,4 +257,79 @@ class CamelJfrDevConsoleTest extends CamelTestSupport { assertThat(json.getString("error")).contains("unknown event: bogus"); assertThat(json.get("jfc")).isNull(); } + + @Test + void snapshotReturnsAggregatedDataFromActiveRecording() throws Exception { + try (DefaultCamelContext ctx = new DefaultCamelContext(false)) { + FlightRecorderStartupStepRecorder recorder = new FlightRecorderStartupStepRecorder(); + recorder.setRuntimeEnabled(true); + recorder.setCamelContext(ctx); + ctx.getCamelContextExtension().setStartupStepRecorder(recorder); + ctx.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("direct:snap").routeId("snap-route").to("mock:snap-result"); + } + }); + ctx.build(); + ctx.start(); + + try (Recording recording = startRecordingWithAllEvents()) { + ctx.createProducerTemplate().sendBody("direct:snap", "hello"); + ctx.createProducerTemplate().sendBody("direct:snap", "world"); + + CamelJfrDevConsole console = resolveConsole(ctx); + JsonObject json = (JsonObject) console.call(DevConsole.MediaType.JSON, Map.of("command", "snapshot")); + + assertThat(json.getBoolean("snapshot")).isTrue(); + assertThat(json.getInteger("eventCount")).isGreaterThan(0); + + JsonArray routes = json.getJsonArray("routes"); + assertThat(routes).isNotNull().isNotEmpty(); + + JsonArray processors = json.getJsonArray("processors"); + assertThat(processors).isNotNull(); + + JsonArray endpoints = json.getJsonArray("endpoints"); + assertThat(endpoints).isNotNull(); + } + } + } + + @Test + void snapshotTextFormatContainsRouteData() throws Exception { + try (DefaultCamelContext ctx = new DefaultCamelContext(false)) { + FlightRecorderStartupStepRecorder recorder = new FlightRecorderStartupStepRecorder(); + recorder.setRuntimeEnabled(true); + recorder.setCamelContext(ctx); + ctx.getCamelContextExtension().setStartupStepRecorder(recorder); + ctx.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("direct:txt").routeId("txt-route").to("mock:txt-result"); + } + }); + ctx.build(); + ctx.start(); + + try (Recording recording = startRecordingWithAllEvents()) { + ctx.createProducerTemplate().sendBody("direct:txt", "test"); + + CamelJfrDevConsole console = resolveConsole(ctx); + String text = (String) console.call(DevConsole.MediaType.TEXT, Map.of("command", "snapshot")); + + assertThat(text).contains("snapshot").contains("txt-route"); + } + } + } + + @Test + void snapshotWithNoRecordingReturnsErrorMessage() { + CamelJfrDevConsole console = resolveConsole(context); + + JsonObject json = (JsonObject) console.call(DevConsole.MediaType.JSON, Map.of("command", "snapshot")); + + assertThat(json.getBoolean("snapshot")).isTrue(); + assertThat(json.getString("error")).contains("no JFR data available"); + } } diff --git a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java index b1e4a9507fea..c5859350123f 100644 --- a/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java +++ b/dsl/camel-cli-connector/src/main/java/org/apache/camel/cli/connector/LocalCliConnector.java @@ -1012,6 +1012,14 @@ public class LocalCliConnector extends ServiceSupport implements CliConnector, C if (disable != null) { params.put("disable", disable); } + String routeId = root.getString("routeId"); + if (routeId != null) { + params.put("routeId", routeId); + } + String limit = root.getString("limit"); + if (limit != null) { + params.put("limit", limit); + } JsonObject json = (JsonObject) dc.call(DevConsole.MediaType.JSON, params); LOG.trace("Updating output file: {}", outputFile); IOHelper.writeText(json.toJson(), outputFile); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/JfrTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/JfrTab.java index ba0b46104c5f..10a9dcf8b92c 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/JfrTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/JfrTab.java @@ -17,12 +17,15 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; 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; @@ -31,10 +34,16 @@ import dev.tamboui.text.Span; import dev.tamboui.text.Text; import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.MouseEvent; import dev.tamboui.widgets.block.Block; import dev.tamboui.widgets.block.BorderType; import dev.tamboui.widgets.block.Borders; import dev.tamboui.widgets.paragraph.Paragraph; +import dev.tamboui.widgets.scrollbar.ScrollbarState; +import dev.tamboui.widgets.table.Cell; +import dev.tamboui.widgets.table.Row; +import dev.tamboui.widgets.table.Table; +import dev.tamboui.widgets.table.TableState; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; @@ -43,17 +52,67 @@ import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.*; class JfrTab extends AbstractTab { private static final Style LABEL = Theme.muted(); - private static final Style OK = Style.EMPTY.fg(Theme.baseFg()).bold(); + private static final Style VALUE = Style.EMPTY.fg(Theme.baseFg()).bold(); + + enum View { + ROUTES("Routes"), + PROCESSORS("Processors"), + ENDPOINTS("Endpoints"), + FAILURES("Failures"), + REDELIVERIES("Redeliveries"); + + final String label; + + View(String label) { + this.label = label; + } + } private final AtomicBoolean loading = new AtomicBoolean(false); private final Consumer<Runnable> renderThreadExecutor; + // status state private boolean registered; private List<String> recordings = List.of(); private Map<String, Boolean> events = new LinkedHashMap<>(); private String message; private String errorMessage; - private boolean dataLoaded; + private boolean statusLoaded; + + // snapshot data + private List<RouteStats> routeData = List.of(); + private List<ProcessorStats> processorData = List.of(); + private List<EndpointStats> endpointData = List.of(); + private List<FailureEntry> failureData = List.of(); + private List<RedeliveryEntry> redeliveryData = List.of(); + private int snapshotEventCount; + private boolean snapshotLoaded; + + // view state + private View activeView = View.ROUTES; + private final TableState tableState = new TableState(); + private final ScrollbarState scrollState = new ScrollbarState(); + private Rect lastTableArea; + private String drillRouteId; + private int sortIndex; + private boolean sortReversed; + + record RouteStats(String routeId, long total, long failed, double minMs, double meanMs, double maxMs) { + } + + record ProcessorStats(String processorId, String processorType, String routeId, + long total, long failed, double minMs, double meanMs, double maxMs) { + } + + record EndpointStats(String endpointUri, long total, long failed, double minMs, double meanMs, double maxMs) { + } + + record FailureEntry(String timestamp, String exchangeId, String routeId, + String exceptionType, String exceptionMessage) { + } + + record RedeliveryEntry(String timestamp, String exchangeId, String routeId, int attempt, int maxAttempts) { + } JfrTab(MonitorContext ctx) { this(ctx, action -> { @@ -70,7 +129,7 @@ class JfrTab extends AbstractTab { @Override public void onTabSelected() { - if (!dataLoaded) { + if (!statusLoaded) { refreshStatus(); } } @@ -82,13 +141,26 @@ class JfrTab extends AbstractTab { events = new LinkedHashMap<>(); message = null; errorMessage = null; - dataLoaded = false; + statusLoaded = false; + clearSnapshotData(); + } + + private void clearSnapshotData() { + routeData = List.of(); + processorData = List.of(); + endpointData = List.of(); + failureData = List.of(); + redeliveryData = List.of(); + snapshotEventCount = 0; + snapshotLoaded = false; + drillRouteId = null; + tableState.select(0); } @Override public boolean handleKeyEvent(KeyEvent ke) { if (ke.isKey(KeyCode.F5)) { - refreshStatus(); + takeSnapshot(); return true; } if (ke.isCharIgnoreCase('e')) { @@ -103,9 +175,114 @@ class JfrTab extends AbstractTab { generateJfc(); return true; } + + if (ke.isChar('1')) { + switchView(View.ROUTES); + return true; + } + if (ke.isChar('2')) { + switchView(View.PROCESSORS); + return true; + } + if (ke.isChar('3')) { + switchView(View.ENDPOINTS); + return true; + } + if (ke.isChar('4')) { + switchView(View.FAILURES); + return true; + } + if (ke.isChar('5')) { + switchView(View.REDELIVERIES); + return true; + } + + if (ke.isChar('s')) { + sortIndex++; + sortReversed = false; + return true; + } + if (ke.isChar('S')) { + sortReversed = !sortReversed; + return true; + } + + if (ke.isKey(KeyCode.ENTER) && activeView == View.ROUTES && snapshotLoaded) { + int sel = tableState.selected() != null ? tableState.selected() : 0; + List<RouteStats> sorted = sortedRoutes(); + if (sel >= 0 && sel < sorted.size()) { + drillRouteId = sorted.get(sel).routeId; + switchView(View.PROCESSORS); + } + return true; + } + + if (ke.isKey(KeyCode.ESCAPE) && drillRouteId != null) { + drillRouteId = null; + switchView(View.ROUTES); + return true; + } + + if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) { + for (int i = 0; i < 20 && tableState.selected() != null && tableState.selected() > 0; i++) { + tableState.selectPrevious(); + } + return true; + } + if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) { + for (int i = 0; i < 20; i++) { + tableState.selectNext(getRowCount()); + } + return true; + } + if (ke.isHome()) { + tableState.selectFirst(); + return true; + } + if (ke.isEnd()) { + tableState.selectLast(getRowCount()); + return true; + } + return false; } + @Override + public void navigateUp() { + tableState.selectPrevious(); + } + + @Override + public void navigateDown() { + tableState.selectNext(getRowCount()); + } + + @Override + public boolean handleMouseEvent(MouseEvent me, Rect area) { + return handleTableClick(me, lastTableArea, tableState, getRowCount()); + } + + private void switchView(View view) { + if (view != activeView) { + activeView = view; + sortIndex = 0; + sortReversed = false; + tableState.select(0); + } + } + + private int getRowCount() { + return switch (activeView) { + case ROUTES -> sortedRoutes().size(); + case PROCESSORS -> filteredProcessors().size(); + case ENDPOINTS -> sortedEndpoints().size(); + case FAILURES -> failureData.size(); + case REDELIVERIES -> redeliveryData.size(); + }; + } + + // ---- rendering ---- + @Override public void render(Frame frame, Rect area) { IntegrationInfo info = ctx.findSelectedIntegration(); @@ -114,10 +291,10 @@ class JfrTab extends AbstractTab { return; } - if (loading.get() && !dataLoaded) { + if (loading.get() && !statusLoaded && !snapshotLoaded) { frame.renderWidget( Paragraph.builder() - .text(Text.from(Line.from(Span.styled(" Loading JFR status...", LABEL)))) + .text(Text.from(Line.from(Span.styled(" Loading...", LABEL)))) .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) .title(" JFR Runtime Instrumentation ").build()) .build(), @@ -125,7 +302,7 @@ class JfrTab extends AbstractTab { return; } - if (errorMessage != null && !dataLoaded) { + if (errorMessage != null && !statusLoaded && !snapshotLoaded) { frame.renderWidget( Paragraph.builder() .text(Text.from(Line.from(Span.styled(" " + errorMessage, Theme.error())))) @@ -136,53 +313,356 @@ class JfrTab extends AbstractTab { return; } + List<Rect> rows = Layout.vertical() + .constraints(Constraint.length(3), Constraint.length(1), Constraint.fill()) + .split(area); + + renderStatusHeader(frame, rows.get(0)); + renderViewTabs(frame, rows.get(1)); + renderDataTable(frame, rows.get(2)); + } + + private void renderStatusHeader(Frame frame, Rect area) { List<Line> lines = new ArrayList<>(); - lines.add(Line.from( - Span.styled(" registered: ", LABEL), - Span.styled(String.valueOf(registered), registered ? OK : Theme.error()))); - lines.add(Line.from(Span.raw(""))); - if (recordings.isEmpty()) { - lines.add(Line.from(Span.styled(" no active recording", LABEL))); + + List<Span> line1 = new ArrayList<>(); + line1.add(Span.styled(" registered: ", LABEL)); + line1.add(Span.styled(String.valueOf(registered), registered ? VALUE : Theme.error())); + + if (!recordings.isEmpty()) { + line1.add(Span.styled(" recording: ", LABEL)); + line1.add(Span.styled(recordings.get(0), VALUE)); } else { - for (String recording : recordings) { - lines.add(Line.from(Span.styled(" recording: ", LABEL), Span.styled(recording, OK))); - } + line1.add(Span.styled(" no active recording", LABEL)); } - lines.add(Line.from(Span.raw(""))); - for (Map.Entry<String, Boolean> entry : events.entrySet()) { - boolean enabled = Boolean.TRUE.equals(entry.getValue()); - lines.add(Line.from( - Span.styled(" " + entry.getKey() + ": ", LABEL), - Span.styled(enabled ? "enabled" : "disabled", enabled ? OK : Theme.error()))); + + if (snapshotLoaded) { + line1.add(Span.styled(" snapshot: ", LABEL)); + line1.add(Span.styled(snapshotEventCount + " events", VALUE)); } + lines.add(Line.from(line1)); + + long enabledCount = events.values().stream().filter(Boolean.TRUE::equals).count(); + List<Span> line2 = new ArrayList<>(); + line2.add(Span.styled(" events: ", LABEL)); + line2.add(Span.styled(enabledCount + "/" + events.size() + " enabled", VALUE)); + if (message != null) { - lines.add(Line.from(Span.raw(""))); - lines.add(Line.from(Span.styled(" " + message, LABEL))); + line2.add(Span.styled(" " + message, LABEL)); } - // an error raised after the status was loaded, so it is shown inline rather than replacing the whole pane if (errorMessage != null) { - lines.add(Line.from(Span.raw(""))); - lines.add(Line.from(Span.styled(" " + errorMessage, Theme.error()))); + line2.add(Span.styled(" " + errorMessage, Theme.error())); + } + lines.add(Line.from(line2)); + + lines.add(Line.from(Span.raw(""))); + + frame.renderWidget(Paragraph.builder().text(Text.from(lines)).build(), area); + } + + private void renderViewTabs(Frame frame, Rect area) { + List<Span> spans = new ArrayList<>(); + spans.add(Span.raw(" ")); + for (int i = 0; i < View.values().length; i++) { + View v = View.values()[i]; + boolean active = v == activeView; + String label = " " + (i + 1) + " " + v.label + " "; + if (active) { + spans.add(Span.styled(label, Style.EMPTY.fg(Theme.baseBg()).bg(Theme.accent()))); + } else { + spans.add(Span.styled(label, LABEL)); + } + spans.add(Span.raw(" ")); + } + if (drillRouteId != null && activeView == View.PROCESSORS) { + spans.add(Span.styled(" filtered: " + drillRouteId, Style.EMPTY.fg(Theme.accent()))); + } + frame.renderWidget(Paragraph.builder().text(Text.from(Line.from(spans))).build(), area); + } + + private void renderDataTable(Frame frame, Rect area) { + if (!snapshotLoaded) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from(Span.styled( + " Press F5 to take a JFR snapshot and view runtime data", LABEL)))) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" " + activeView.label + " ").build()) + .build(), + area); + return; + } + + switch (activeView) { + case ROUTES -> renderRoutesTable(frame, area); + case PROCESSORS -> renderProcessorsTable(frame, area); + case ENDPOINTS -> renderEndpointsTable(frame, area); + case FAILURES -> renderFailuresTable(frame, area); + case REDELIVERIES -> renderRedeliveriesTable(frame, area); + } + } + + private void renderRoutesTable(Frame frame, Rect area) { + List<RouteStats> data = sortedRoutes(); + List<Row> rows = new ArrayList<>(); + for (int i = 0; i < data.size(); i++) { + RouteStats r = data.get(i); + String rate = r.total > 0 ? String.format("%.1f%%", (r.failed * 100.0 / r.total)) : "0.0%"; + rows.add(Row.from( + Cell.from(Span.styled(String.valueOf(i + 1), LABEL)), + Cell.from(Span.styled(r.routeId, Style.EMPTY.fg(Theme.baseFg()))), + rightCell(String.valueOf(r.total), 8), + rightCell(String.valueOf(r.failed), 8, r.failed > 0 ? Theme.error() : Style.EMPTY), + rightCell(rate, 8), + rightCell(formatMs(r.minMs), 10), + rightCell(formatMs(r.meanMs), 10), + rightCell(formatMs(r.maxMs), 10))); + } + + String[] cols = { "#", "ROUTE", "TOTAL", "FAILED", "RATE", "MIN", "MEAN", "MAX" }; + Table table = Table.builder() + .rows(rows) + .header(headerRow(cols)) + .widths(Constraint.length(4), Constraint.fill(), + Constraint.length(8), Constraint.length(8), Constraint.length(8), + Constraint.length(10), Constraint.length(10), Constraint.length(10)) + .highlightStyle(Theme.selectionBg()) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" Routes (" + data.size() + ") ").build()) + .build(); + lastTableArea = area; + frame.renderStatefulWidget(table, area, tableState); + renderTableScrollbar(frame, lastTableArea, tableState, scrollState, data.size()); + } + + private void renderProcessorsTable(Frame frame, Rect area) { + List<ProcessorStats> data = filteredProcessors(); + List<Row> rows = new ArrayList<>(); + for (int i = 0; i < data.size(); i++) { + ProcessorStats p = data.get(i); + rows.add(Row.from( + Cell.from(Span.styled(String.valueOf(i + 1), LABEL)), + Cell.from(Span.styled(p.processorId, Style.EMPTY.fg(Theme.baseFg()))), + Cell.from(Span.styled(p.processorType != null ? p.processorType : "", LABEL)), + Cell.from(Span.styled(p.routeId, LABEL)), + rightCell(String.valueOf(p.total), 8), + rightCell(String.valueOf(p.failed), 8, p.failed > 0 ? Theme.error() : Style.EMPTY), + rightCell(formatMs(p.minMs), 10), + rightCell(formatMs(p.meanMs), 10), + rightCell(formatMs(p.maxMs), 10))); + } + + String title = drillRouteId != null + ? " Processors [" + drillRouteId + "] (" + data.size() + ") " + : " Processors (" + data.size() + ") "; + String[] cols = { "#", "PROCESSOR", "TYPE", "ROUTE", "TOTAL", "FAILED", "MIN", "MEAN", "MAX" }; + Table table = Table.builder() + .rows(rows) + .header(headerRow(cols)) + .widths(Constraint.length(4), Constraint.fill(), Constraint.length(12), Constraint.length(16), + Constraint.length(8), Constraint.length(8), + Constraint.length(10), Constraint.length(10), Constraint.length(10)) + .highlightStyle(Theme.selectionBg()) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(title).build()) + .build(); + lastTableArea = area; + frame.renderStatefulWidget(table, area, tableState); + renderTableScrollbar(frame, lastTableArea, tableState, scrollState, data.size()); + } + + private void renderEndpointsTable(Frame frame, Rect area) { + List<EndpointStats> data = sortedEndpoints(); + List<Row> rows = new ArrayList<>(); + for (int i = 0; i < data.size(); i++) { + EndpointStats e = data.get(i); + rows.add(Row + .from( + Cell.from(Span.styled(String.valueOf(i + 1), LABEL)), + Cell.from(Span.styled(e.endpointUri, Style.EMPTY.fg(Theme.baseFg()))), + rightCell(String.valueOf(e.total), 8), + rightCell(String.valueOf(e.failed), 8, e.failed > 0 ? Theme.error() : Style.EMPTY), + rightCell(formatMs(e.minMs), 10), + rightCell(formatMs(e.meanMs), 10), + rightCell(formatMs(e.maxMs), 10))); + } + + String[] cols = { "#", "ENDPOINT", "TOTAL", "FAILED", "MIN", "MEAN", "MAX" }; + Table table = Table.builder() + .rows(rows) + .header(headerRow(cols)) + .widths(Constraint.length(4), Constraint.fill(), + Constraint.length(8), Constraint.length(8), + Constraint.length(10), Constraint.length(10), Constraint.length(10)) + .highlightStyle(Theme.selectionBg()) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" Endpoints (" + data.size() + ") ").build()) + .build(); + lastTableArea = area; + frame.renderStatefulWidget(table, area, tableState); + renderTableScrollbar(frame, lastTableArea, tableState, scrollState, data.size()); + } + + private void renderFailuresTable(Frame frame, Rect area) { + List<Row> rows = new ArrayList<>(); + for (int i = 0; i < failureData.size(); i++) { + FailureEntry f = failureData.get(i); + String time = f.timestamp != null && f.timestamp.length() > 19 + ? f.timestamp.substring(11, 19) : (f.timestamp != null ? f.timestamp : ""); + String msg = f.exceptionMessage != null + ? (f.exceptionMessage.length() > 60 ? f.exceptionMessage.substring(0, 60) + "..." : f.exceptionMessage) + : ""; + rows.add(Row.from( + Cell.from(Span.styled(String.valueOf(i + 1), LABEL)), + Cell.from(Span.styled(time, LABEL)), + Cell.from(Span.styled(f.routeId != null ? f.routeId : "", Style.EMPTY.fg(Theme.baseFg()))), + Cell.from(Span.styled( + f.exceptionType != null ? shortClassName(f.exceptionType) : "", Theme.error())), + Cell.from(Span.styled(msg, LABEL)))); + } + + String[] cols = { "#", "TIME", "ROUTE", "EXCEPTION", "MESSAGE" }; + Table table = Table.builder() + .rows(rows) + .header(headerRow(cols)) + .widths(Constraint.length(4), Constraint.length(10), Constraint.length(16), + Constraint.length(28), Constraint.fill()) + .highlightStyle(Theme.selectionBg()) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" Failures (" + failureData.size() + ") ").build()) + .build(); + lastTableArea = area; + frame.renderStatefulWidget(table, area, tableState); + renderTableScrollbar(frame, lastTableArea, tableState, scrollState, failureData.size()); + } + + private void renderRedeliveriesTable(Frame frame, Rect area) { + List<Row> rows = new ArrayList<>(); + for (int i = 0; i < redeliveryData.size(); i++) { + RedeliveryEntry r = redeliveryData.get(i); + String time = r.timestamp != null && r.timestamp.length() > 19 + ? r.timestamp.substring(11, 19) : (r.timestamp != null ? r.timestamp : ""); + boolean exhausted = r.attempt >= r.maxAttempts && r.maxAttempts > 0; + rows.add(Row.from( + Cell.from(Span.styled(String.valueOf(i + 1), LABEL)), + Cell.from(Span.styled(time, LABEL)), + Cell.from(Span.styled(r.routeId != null ? r.routeId : "", Style.EMPTY.fg(Theme.baseFg()))), + rightCell(String.valueOf(r.attempt), 8), + rightCell(String.valueOf(r.maxAttempts), 8, exhausted ? Theme.error() : Style.EMPTY))); + } + + String[] cols = { "#", "TIME", "ROUTE", "ATTEMPT", "MAX" }; + Table table = Table.builder() + .rows(rows) + .header(headerRow(cols)) + .widths(Constraint.length(4), Constraint.length(10), Constraint.fill(), + Constraint.length(8), Constraint.length(8)) + .highlightStyle(Theme.selectionBg()) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" Redeliveries (" + redeliveryData.size() + ") ").build()) + .build(); + lastTableArea = area; + frame.renderStatefulWidget(table, area, tableState); + renderTableScrollbar(frame, lastTableArea, tableState, scrollState, redeliveryData.size()); + } + + private static Row headerRow(String[] cols) { + Cell[] cells = new Cell[cols.length]; + for (int i = 0; i < cols.length; i++) { + cells[i] = Cell.from(Span.styled(cols[i], Style.EMPTY.bold())); + } + return Row.from(cells); + } + + private static String formatMs(double ms) { + if (ms < 1.0) { + return String.format("%.1fms", ms); + } else if (ms < 1000.0) { + return String.format("%.0fms", ms); + } else { + return String.format("%.1fs", ms / 1000.0); } + } - frame.renderWidget( - Paragraph.builder() - .text(Text.from(lines)) - .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) - .title(" JFR Runtime Instrumentation ").build()) - .build(), - area); + private static String shortClassName(String fqcn) { + int dot = fqcn.lastIndexOf('.'); + return dot >= 0 ? fqcn.substring(dot + 1) : fqcn; } @Override public void renderFooter(List<Span> spans) { hint(spans, "Esc", "back"); - hint(spans, "E", "enable all"); - hint(spans, "D", "disable all"); - hint(spans, "J", "generate .jfc"); - hintLast(spans, "F5", "refresh"); + hint(spans, "1-5", "view"); + if (snapshotLoaded && activeView == View.ROUTES) { + hint(spans, "Enter", "drill"); + } + hint(spans, "s", "sort"); + hint(spans, "E", "enable"); + hint(spans, "D", "disable"); + hintLast(spans, "F5", "snapshot"); + } + + // ---- sorting ---- + + private List<RouteStats> sortedRoutes() { + if (routeData.isEmpty()) { + return routeData; + } + List<RouteStats> sorted = new ArrayList<>(routeData); + int idx = sortIndex % 4; + Comparator<RouteStats> cmp = switch (idx) { + case 0 -> Comparator.comparingLong(RouteStats::total); + case 1 -> Comparator.comparingLong(RouteStats::failed); + case 2 -> Comparator.comparingDouble(RouteStats::meanMs); + case 3 -> Comparator.comparingDouble(RouteStats::maxMs); + default -> Comparator.comparingLong(RouteStats::total); + }; + cmp = sortReversed ? cmp : cmp.reversed(); + sorted.sort(cmp); + return sorted; } + private List<ProcessorStats> filteredProcessors() { + List<ProcessorStats> filtered = drillRouteId != null + ? processorData.stream().filter(p -> drillRouteId.equals(p.routeId)).toList() + : processorData; + if (filtered.isEmpty()) { + return filtered; + } + List<ProcessorStats> sorted = new ArrayList<>(filtered); + int idx = sortIndex % 4; + Comparator<ProcessorStats> cmp = switch (idx) { + case 0 -> Comparator.comparingDouble(ProcessorStats::meanMs); + case 1 -> Comparator.comparingLong(ProcessorStats::total); + case 2 -> Comparator.comparingLong(ProcessorStats::failed); + case 3 -> Comparator.comparingDouble(ProcessorStats::maxMs); + default -> Comparator.comparingDouble(ProcessorStats::meanMs); + }; + cmp = sortReversed ? cmp : cmp.reversed(); + sorted.sort(cmp); + return sorted; + } + + private List<EndpointStats> sortedEndpoints() { + if (endpointData.isEmpty()) { + return endpointData; + } + List<EndpointStats> sorted = new ArrayList<>(endpointData); + int idx = sortIndex % 4; + Comparator<EndpointStats> cmp = switch (idx) { + case 0 -> Comparator.comparingLong(EndpointStats::total); + case 1 -> Comparator.comparingLong(EndpointStats::failed); + case 2 -> Comparator.comparingDouble(EndpointStats::meanMs); + case 3 -> Comparator.comparingDouble(EndpointStats::maxMs); + default -> Comparator.comparingLong(EndpointStats::total); + }; + cmp = sortReversed ? cmp : cmp.reversed(); + sorted.sort(cmp); + return sorted; + } + + // ---- server communication ---- + private void refreshStatus() { if (ctx.selectedPid == null) { return; @@ -208,6 +688,31 @@ class JfrTab extends AbstractTab { }); } + private void takeSnapshot() { + if (ctx.selectedPid == null) { + return; + } + if (!loading.compareAndSet(false, true)) { + return; + } + + String pid = ctx.selectedPid; + ctx.backgroundExecutor.execute(() -> { + try { + JsonObject root = new JsonObject(); + root.put("action", "jfr"); + root.put("command", "snapshot"); + + JsonObject jo = ctx.executeAction(pid, root, 30000); + applySnapshot(jo); + } catch (Exception e) { + applyError("Error taking snapshot: " + e.getMessage()); + } finally { + loading.set(false); + } + }); + } + private void toggleAll(boolean enable) { if (ctx.selectedPid == null) { return; @@ -241,7 +746,6 @@ class JfrTab extends AbstractTab { } finally { loading.set(false); } - // only refresh once the loading flag is released, as refreshStatus takes it again refreshStatus(); }); } @@ -279,17 +783,19 @@ class JfrTab extends AbstractTab { }); } + // ---- apply results on render thread ---- + private void applyStatus(JsonObject jo) { renderThreadExecutor.accept(() -> { if (jo == null) { errorMessage = "No response from integration"; - dataLoaded = true; + statusLoaded = true; return; } String error = jo.getString("error"); if (error != null) { errorMessage = error; - dataLoaded = true; + statusLoaded = true; return; } registered = Boolean.TRUE.equals(jo.getBoolean("registered")); @@ -297,7 +803,7 @@ class JfrTab extends AbstractTab { if (jo.get("recordings") instanceof JsonArray recordingsArr) { for (Object o : recordingsArr) { if (o instanceof JsonObject rec) { - recs.add(rec.getString("name") + " (state=" + rec.getString("state") + ")"); + recs.add(rec.getString("name") + " (" + rec.getString("state") + ")"); } } } @@ -308,14 +814,151 @@ class JfrTab extends AbstractTab { } events = evts; errorMessage = null; - dataLoaded = true; + statusLoaded = true; + }); + } + + private void applySnapshot(JsonObject jo) { + renderThreadExecutor.accept(() -> { + if (jo == null) { + errorMessage = "No snapshot response from integration"; + return; + } + String error = jo.getString("error"); + if (error != null) { + errorMessage = error; + return; + } + + snapshotEventCount = jo.getInteger("eventCount") != null ? jo.getInteger("eventCount") : 0; + + routeData = parseRoutes(jo); + processorData = parseProcessors(jo); + endpointData = parseEndpoints(jo); + failureData = parseFailures(jo); + redeliveryData = parseRedeliveries(jo); + + errorMessage = null; + message = null; + snapshotLoaded = true; + tableState.select(0); }); } - /** - * The integration reports why it could not answer in an {@code error} field, which is more useful than the generic - * fallback. - */ + private static List<RouteStats> parseRoutes(JsonObject jo) { + List<RouteStats> result = new ArrayList<>(); + if (jo.get("routes") instanceof JsonArray arr) { + for (Object o : arr) { + if (o instanceof JsonObject r) { + result.add(new RouteStats( + r.getString("routeId"), + longVal(r, "total"), + longVal(r, "failed"), + doubleVal(r, "minMs"), + doubleVal(r, "meanMs"), + doubleVal(r, "maxMs"))); + } + } + } + return result; + } + + private static List<ProcessorStats> parseProcessors(JsonObject jo) { + List<ProcessorStats> result = new ArrayList<>(); + if (jo.get("processors") instanceof JsonArray arr) { + for (Object o : arr) { + if (o instanceof JsonObject p) { + result.add(new ProcessorStats( + p.getString("processorId"), + p.getString("processorType"), + p.getString("routeId"), + longVal(p, "total"), + longVal(p, "failed"), + doubleVal(p, "minMs"), + doubleVal(p, "meanMs"), + doubleVal(p, "maxMs"))); + } + } + } + return result; + } + + private static List<EndpointStats> parseEndpoints(JsonObject jo) { + List<EndpointStats> result = new ArrayList<>(); + if (jo.get("endpoints") instanceof JsonArray arr) { + for (Object o : arr) { + if (o instanceof JsonObject e) { + result.add(new EndpointStats( + e.getString("endpointUri"), + longVal(e, "total"), + longVal(e, "failed"), + doubleVal(e, "minMs"), + doubleVal(e, "meanMs"), + doubleVal(e, "maxMs"))); + } + } + } + return result; + } + + private static List<FailureEntry> parseFailures(JsonObject jo) { + List<FailureEntry> result = new ArrayList<>(); + if (jo.get("failures") instanceof JsonArray arr) { + for (Object o : arr) { + if (o instanceof JsonObject f) { + result.add(new FailureEntry( + f.getString("timestamp"), + f.getString("exchangeId"), + f.getString("routeId"), + f.getString("exceptionType"), + f.getString("exceptionMessage"))); + } + } + } + return result; + } + + private static List<RedeliveryEntry> parseRedeliveries(JsonObject jo) { + List<RedeliveryEntry> result = new ArrayList<>(); + if (jo.get("redeliveries") instanceof JsonArray arr) { + for (Object o : arr) { + if (o instanceof JsonObject r) { + result.add(new RedeliveryEntry( + r.getString("timestamp"), + r.getString("exchangeId"), + r.getString("routeId"), + intVal(r, "attempt"), + intVal(r, "maxAttempts"))); + } + } + } + return result; + } + + private static long longVal(JsonObject jo, String key) { + Object v = jo.get(key); + if (v instanceof Number n) { + return n.longValue(); + } + return 0; + } + + private static double doubleVal(JsonObject jo, String key) { + Object v = jo.get(key); + if (v instanceof Number n) { + return n.doubleValue(); + } + return 0; + } + + private static int intVal(JsonObject jo, String key) { + Object v = jo.get(key); + if (v instanceof Number n) { + return n.intValue(); + } + return 0; + } + private static String errorOf(JsonObject jo, String fallback) { String error = jo != null ? jo.getString("error") : null; return error != null ? error : fallback; @@ -337,12 +980,45 @@ class JfrTab extends AbstractTab { @Override public SelectionContext getSelectionContext() { - return null; + if (!snapshotLoaded) { + return null; + } + int sel = tableState.selected() != null ? tableState.selected() : -1; + return switch (activeView) { + case ROUTES -> { + List<RouteStats> data = sortedRoutes(); + yield sel >= 0 && sel < data.size() + ? new SelectionContext( + "route", + data.stream().map(RouteStats::routeId).toList(), + sel, data.size(), data.get(sel).routeId) + : null; + } + case PROCESSORS -> { + List<ProcessorStats> data = filteredProcessors(); + yield sel >= 0 && sel < data.size() + ? new SelectionContext( + "processor", + data.stream().map(ProcessorStats::processorId).toList(), + sel, data.size(), data.get(sel).processorId) + : null; + } + case ENDPOINTS -> { + List<EndpointStats> data = sortedEndpoints(); + yield sel >= 0 && sel < data.size() + ? new SelectionContext( + "endpoint", + data.stream().map(EndpointStats::endpointUri).toList(), + sel, data.size(), data.get(sel).endpointUri) + : null; + } + default -> null; + }; } @Override public String description() { - return "JFR runtime instrumentation status and live event toggling"; + return "JFR runtime instrumentation status, event toggling, and runtime data snapshot"; } @Override @@ -350,21 +1026,36 @@ class JfrTab extends AbstractTab { return """ # JFR - The JFR tab shows the live status of camel-jfr's runtime instrumentation: - whether it is registered, any active Flight Recorder recording(s), and the - current enabled/disabled state of each of the six runtime events (route, - processor, exchange, send, failed, redelivery). + The JFR tab shows the live status of camel-jfr's runtime instrumentation + and lets you view aggregated runtime data from JFR recordings. + + ## Data Views + + Press **F5** to take a snapshot of the active JFR recording. The snapshot + data is aggregated into five views: + + - **1 Routes** — per-route exchange count, failures, and timing + - **2 Processors** — per-processor invocation count and timing (slowest first) + - **3 Endpoints** — per-endpoint send count and timing + - **4 Failures** — recent exchange failures with exception details + - **5 Redeliveries** — recent redelivery attempts + + Press **Enter** on a route in the Routes view to drill down into its + processors. Press **Esc** to return from drill-down. - ## Keys + ## Controls + - `F5` — take JFR snapshot + - `1`-`5` — switch view + - `Enter` — drill into route processors + - `s` / `S` — cycle sort / reverse sort - `E` — enable all runtime events on every active recording - `D` — disable all runtime events on every active recording - `J` — generate a `.jfc` overlay with the current event selection - - `F5` — refresh status - - `Esc` — back + - `Esc` — back (from drill-down or tab) - Toggling requires at least one active recording; start one first via - `--jfr`, `jcmd <pid> JFR.start`, or JMX. + Requires at least one active recording; start one via `--jfr`, + `jcmd <pid> JFR.start`, or JMX. """; } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/JfrTabRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/JfrTabRenderTest.java index 4e6ca7a5633f..e16088b86795 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/JfrTabRenderTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/JfrTabRenderTest.java @@ -22,6 +22,10 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import dev.tamboui.text.Span; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.KeyModifiers; +import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,10 +33,6 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; -/** - * Rendering tests for {@link JfrTab}. These tests render the tab into a virtual terminal buffer via - * {@link Frame#forTesting(Buffer)} and inspect the rendered cell content. - */ class JfrTabRenderTest { private MonitorContext ctx; @@ -92,7 +92,74 @@ class JfrTabRenderTest { tab.renderFooter(footerSpans); String footer = footerSpans.stream().map(Span::content).reduce("", String::concat); - assertThat(footer).contains("Esc").contains("F5").contains("refresh"); + assertThat(footer).contains("Esc").contains("F5").contains("snapshot"); + } + + @Test + void renderFooterShowsViewHint() { + JfrTab tab = new JfrTab(ctx); + List<Span> footerSpans = new ArrayList<>(); + tab.renderFooter(footerSpans); + String footer = footerSpans.stream().map(Span::content).reduce("", String::concat); + + assertThat(footer).contains("1-5").contains("view"); + } + + @Test + void renderSnapshotDataShowsRoutesTable() { + TestMonitorContext snapshotCtx = new TestMonitorContext(dataWith(info), statusResponse()) + .withSnapshot(snapshotResponse()); + snapshotCtx.selectedPid = "1234"; + JfrTab tab = new JfrTab(snapshotCtx, Runnable::run); + + tab.onTabSelected(); + tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, KeyModifiers.NONE)); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + String rendered = TuiTestHelper.renderToString(tab, 140, 30); + assertThat(rendered).contains("Routes"); + }); + } + + @Test + void renderSnapshotDataShowsViewTabs() { + TestMonitorContext snapshotCtx = new TestMonitorContext(dataWith(info), statusResponse()) + .withSnapshot(snapshotResponse()); + snapshotCtx.selectedPid = "1234"; + JfrTab tab = new JfrTab(snapshotCtx, Runnable::run); + + tab.onTabSelected(); + tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, KeyModifiers.NONE)); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + String rendered = TuiTestHelper.renderToString(tab, 140, 30); + assertThat(rendered).contains("1 Routes").contains("2 Processors") + .contains("3 Endpoints").contains("4 Failures").contains("5 Redeliveries"); + }); + } + + @Test + void viewSwitchingChangesActiveView() { + TestMonitorContext snapshotCtx = new TestMonitorContext(dataWith(info), statusResponse()) + .withSnapshot(snapshotResponse()); + snapshotCtx.selectedPid = "1234"; + JfrTab tab = new JfrTab(snapshotCtx, Runnable::run); + + tab.onTabSelected(); + tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, KeyModifiers.NONE)); + tab.handleKeyEvent(KeyEvent.ofChar('3')); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + String rendered = TuiTestHelper.renderToString(tab, 140, 30); + assertThat(rendered).contains("Endpoints"); + }); + } + + @Test + void promptToSnapshotWhenNoData() { + JfrTab tab = new JfrTab(ctx); + String rendered = TuiTestHelper.renderToString(tab, 140, 30); + assertThat(rendered).contains("F5"); } @Test @@ -111,9 +178,84 @@ class JfrTabRenderTest { return response; } + private static JsonObject snapshotResponse() { + JsonObject response = new JsonObject(); + response.put("snapshot", true); + response.put("eventCount", 150); + + JsonArray routes = new JsonArray(); + JsonObject route1 = new JsonObject(); + route1.put("routeId", "order-in"); + route1.put("total", 100L); + route1.put("failed", 5L); + route1.put("minMs", 1.2); + route1.put("meanMs", 8.5); + route1.put("maxMs", 250.3); + routes.add(route1); + JsonObject route2 = new JsonObject(); + route2.put("routeId", "notify"); + route2.put("total", 50L); + route2.put("failed", 0L); + route2.put("minMs", 2.0); + route2.put("meanMs", 5.0); + route2.put("maxMs", 45.0); + routes.add(route2); + response.put("routes", routes); + + JsonArray processors = new JsonArray(); + JsonObject proc1 = new JsonObject(); + proc1.put("processorId", "to1"); + proc1.put("processorType", "to"); + proc1.put("routeId", "order-in"); + proc1.put("total", 100L); + proc1.put("failed", 3L); + proc1.put("minMs", 0.5); + proc1.put("meanMs", 5.2); + proc1.put("maxMs", 200.1); + processors.add(proc1); + response.put("processors", processors); + + JsonArray endpoints = new JsonArray(); + JsonObject ep1 = new JsonObject(); + ep1.put("endpointUri", "kafka://orders"); + ep1.put("total", 100L); + ep1.put("failed", 3L); + ep1.put("minMs", 0.8); + ep1.put("meanMs", 4.1); + ep1.put("maxMs", 180.5); + endpoints.add(ep1); + response.put("endpoints", endpoints); + + response.put("failures", new JsonArray()); + response.put("redeliveries", new JsonArray()); + + return response; + } + + private static JsonObject statusResponse() { + JsonObject response = new JsonObject(); + response.put("registered", true); + JsonArray recordings = new JsonArray(); + JsonObject rec = new JsonObject(); + rec.put("name", "default"); + rec.put("state", "RUNNING"); + recordings.add(rec); + response.put("recordings", recordings); + JsonObject events = new JsonObject(); + events.put("route", true); + events.put("processor", true); + events.put("exchange", true); + events.put("send", true); + events.put("failed", true); + events.put("redelivery", true); + response.put("events", events); + return response; + } + private static final class TestMonitorContext extends MonitorContext { private final JsonObject response; + private JsonObject snapshotResp; private TestMonitorContext( AtomicReference<List<IntegrationInfo>> data, @@ -122,8 +264,17 @@ class JfrTabRenderTest { this.response = response; } + TestMonitorContext withSnapshot(JsonObject snapshot) { + this.snapshotResp = snapshot; + return this; + } + @Override JsonObject executeAction(String pid, JsonObject request, long timeoutMs) { + String command = request.getString("command"); + if ("snapshot".equals(command) && snapshotResp != null) { + return snapshotResp; + } return response; } }
