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;
         }
     }

Reply via email to