You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/08/03 13:58:40 UTC
(camel) 01/04: CAMEL-24328: Show JFR runtime data in TUI JFR tab
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 <cl...@gmail.com>
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 <no...@anthropic.com>
Signed-off-by: Claus Ibsen <cl...@gmail.com>
---
.../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;
}
}