This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 61e20cf392ca CAMEL-26608: camel-jbang - a chaos screen: the rules, 
their live state and the report of a running integration (#29338)
61e20cf392ca is described below

commit 61e20cf392ca62079666fe028aef7b276d084991
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 6 16:11:37 2026 +0100

    CAMEL-26608: camel-jbang - a chaos screen: the rules, their live state and 
the report of a running integration (#29338)
    
    * CAMEL-26608: camel-jbang - a chaos screen: the rules, their live state 
and the report of a running integration
    * Regen
    * CAMEL-26608: chaos screen - a rule without an id does not fail the 
detail, and the next fire of an every:N rule is a count of matches
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../modules/ROOT/images/jbang/camel-tui-chaos.png  | Bin 0 -> 597980 bytes
 .../ROOT/pages/camel-jbang-tui-observe.adoc        |  22 +
 .../modules/ROOT/pages/camel-jbang-tui.adoc        |   5 +-
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  |   4 +-
 .../dsl/jbang/core/commands/tui/ChaosInfo.java     | 324 ++++++++++
 .../dsl/jbang/core/commands/tui/ChaosTab.java      | 676 +++++++++++++++++++++
 .../jbang/core/commands/tui/IntegrationInfo.java   |   2 +
 .../dsl/jbang/core/commands/tui/PopupManager.java  |   6 +
 .../dsl/jbang/core/commands/tui/StatusParser.java  |   1 +
 .../dsl/jbang/core/commands/tui/TabRegistry.java   |   5 +
 .../dsl/jbang/core/commands/tui/TuiIcons.java      |   1 +
 .../dsl/jbang/core/commands/tui/TuiToolGroups.java |   2 +-
 .../src/main/resources/tui/help/chaos.md           |  54 ++
 .../core/commands/tui/AiPanelToolGroupsTest.java   |   3 +-
 .../core/commands/tui/ChaosTabRenderTest.java      | 239 ++++++++
 15 files changed, 1338 insertions(+), 6 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos.png 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos.png
new file mode 100644
index 000000000000..05ab6b977938
Binary files /dev/null and 
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos.png differ
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
index 10b454908f59..b8d1566b82e0 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-observe.adoc
@@ -240,6 +240,28 @@ right panel. While sending, the status shows a spinner.
 
 Beyond the core tabs described above, the TUI includes several specialized 
screens worth highlighting.
 
+=== Chaos
+
+With xref:next@components:others:chaos.adoc[camel-chaos] (`camel run 
--chaos`), the Chaos tab (under More >
+Observability) shows the faults that the chaos rules inject into the running 
integration, and what they do:
+
+* The verdict of the report (PASS or FAIL with the reason), the messages lost, 
the expectations (met, unmet, pending)
+and the faults of all rules in the last minute.
+* The rules: target, fault, the observed rate next to the rate of the rule 
(`24% of 25%`), how often and how long ago it
+fired, its faults per second over the last minute as a sparkline, the effect 
of its faults on the exchanges (completed,
+recovered by redelivery, handled by the error handler, failed, in flight), and 
a note (when it ends or starts, its
+bursts, what a stall or lifecycle rule does to its route).
+* The selected rule in detail: the effect as a bar, its expectation with the 
time left, why it skipped faults, and its
+recent faults with the endpoint or step, the route and the exchange.
+* The counts of the report per route: completed, failed, kept by the consumer, 
handled, redeliveries.
+
+image::jbang/camel-tui-chaos.png[The Chaos tab: a failed expectation, four 
rules with their live state, and the recent faults of the selected rule]
+
+While a rule injects faults, the title bar shows a badge on every screen (`🎲 
chaos 3 rules · FAIL`), so a failure made
+on purpose is not debugged as a real one. The tab only reads: add, change and 
remove rules with `camel cmd chaos-add`,
+`chaos-change`, `chaos-remove` and `chaos-clear` (see
+xref:next@components:others:chaos.adoc#_control_while_it_runs[Control while it 
runs]).
+
 === CVE Audit
 
 The CVE Audit tab (under More > Project) scans all Maven dependencies on your 
integration's classpath
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
index 1096a8aabbbd..44efe2a3de04 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -80,14 +80,15 @@ to any tab, or *Shift+F2* to go to a tab by name.
 The *More* menu (key *0*) opens a popup with tabs organized into groups:
 
 * *Routing* -- Browse Endpoints, Consumers, HTTP, Inflight, Producers, Route 
Controller
-* *Observability* -- Circuit Breaker, Health, JFR, Metrics, Network Services, 
Exchange Events, Recovery Tasks, OpenTelemetry Spans
+* *Observability* -- Chaos, Circuit Breaker, Health, JFR, Metrics, Network 
Services, Exchange Events, Recovery Tasks, OpenTelemetry Spans
 * *AI* -- Semantic (definitions, expert contracts, relationships, sample 
evaluation, Audit and Monitoring), Ollama (listed when an Ollama server is 
detected)
 * *Data* -- JDBC DataSource, Kafka, Secrets, SQL Query, SQL Trace
 * *JVM* -- Classpath, Heap Memory Histogram, Memory Usage, Memory Leak, 
Process, Startup, Threads
 * *Project* -- Beans, Catalog, Configuration, CVE Audit, Maven Dependencies, 
Type Converters, Data Type Transformers
 
 Tabs appear dynamically based on what the integration uses. For example, the 
Kafka tab appears
-when a Kafka component is in use, SQL tabs when a DataSource is present, 
Circuit Breaker when
+when a Kafka component is in use, SQL tabs when a DataSource is present, Chaos 
when the integration has
+xref:next@components:others:chaos.adoc[camel-chaos] (`camel run --chaos`), 
Circuit Breaker when
 resilience4j is in use, Spans when OpenTelemetry is enabled, and JFR when 
`camel-jfr` is on the
 classpath. The TUI adapts to show only what's relevant to your integration.
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 7240c404c0a0..6482766a6d24 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -1875,8 +1875,8 @@ public class CamelMonitor extends CamelCommand {
                 monitorNotification = null;
             }
         }
-        // Right-aligned header spans (web server indicator)
-        List<Span> headerRight = new ArrayList<>();
+        // Right-aligned header spans (the chaos badge, web server indicator)
+        List<Span> headerRight = new 
ArrayList<>(ChaosTab.headerBadge(ctx.findSelectedIntegration()));
         if (web && webBackend == null) {
             headerRight.add(Span.styled("web :" + webPort + " ",
                     Theme.notice().underlined().hyperlink("http://127.0.0.1:"; 
+ webPort + "/")));
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
new file mode 100644
index 000000000000..d0a63c9ca646
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosInfo.java
@@ -0,0 +1,324 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * The chaos section of the status of an integration (the {@code chaos} dev 
console of camel-chaos): whether chaos is
+ * on, the current rules with their live state, and the report (verdict, 
counts per route, recent faults). Parsed
+ * leniently: a camel-chaos of another version may leave fields out, which 
then read as unknown (-1 or null).
+ */
+final class ChaosInfo {
+
+    boolean enabled;
+    // why chaos is off
+    String reason;
+    String verdict;
+    boolean isFinal = true;
+    long seed;
+    boolean seedGiven;
+    long lost;
+    final List<String> reasons = new ArrayList<>();
+    final List<Rule> rules = new ArrayList<>();
+    final List<RouteCounts> routes = new ArrayList<>();
+    final List<RecentFault> recentFaults = new ArrayList<>();
+
+    /** A current rule, with its live state. */
+    static final class Rule {
+        String id;
+        int version;
+        String text;
+        String state;
+        long matches;
+        long fired;
+        long skipped;
+        final Map<String, Long> skippedReasons = new LinkedHashMap<>();
+        long lostAtDlq = -1;
+        long endsIn = -1;
+        long startsIn = -1;
+        double rate = -1;
+        double observedRate = -1;
+        long nextFireIn = -1;
+        long burstEndsIn = -1;
+        long nextBurstIn = -1;
+        long lastFiredAgo = -1;
+        long faultsLastMinute;
+        long[] faultsPerSecond = new long[0];
+        // the effect of its faults; faulted is 0 until it faulted an exchange
+        long faulted;
+        long completed;
+        long recovered;
+        long handled;
+        long failed;
+        long inFlight;
+        // the expectation, or null
+        String expectRoute;
+        String expectKind;
+        String expectOp;
+        long expectCount;
+        long expectValue;
+        String expectOutcome;
+        long expectTimeLeft = -1;
+        // what a stall or lifecycle rule did to its route, or null
+        String lifecycleAction;
+        String lifecycleRoute;
+        String lifecycleState;
+        int lifecycleRound;
+        int lifecycleRepeat;
+        long lifecycleActsIn = -1;
+        long lifecycleBackIn = -1;
+        String lifecycleReason;
+
+        /** The target of the rule as it is written, such as {@code 
endpoint=kafka:orders*}. */
+        String target() {
+            for (String token : tokens()) {
+                if (token.startsWith("endpoint=") || 
token.startsWith("component=") || token.startsWith("route=")
+                        || token.startsWith("step=")) {
+                    return token;
+                }
+            }
+            return "";
+        }
+
+        /**
+         * The fault of the rule with its parameters, as it is written, 
without the target and the options of the rule
+         * (at, rate, until, from, expect, within), such as {@code error 
status=503}.
+         */
+        String fault() {
+            StringBuilder sb = new StringBuilder();
+            for (String token : tokens()) {
+                int eq = token.indexOf('=');
+                String key = eq > 0 ? token.substring(0, eq) : null;
+                if (token.equals(target()) || key != null && 
OPTIONS.contains(key)) {
+                    continue;
+                }
+                if (!sb.isEmpty()) {
+                    sb.append(' ');
+                }
+                sb.append(token);
+            }
+            return sb.toString();
+        }
+
+        /** The options at, rate, until and from as they are written, for the 
RATE column. */
+        String option(String name) {
+            for (String token : tokens()) {
+                if (token.startsWith(name + "=")) {
+                    return token.substring(name.length() + 1);
+                }
+            }
+            return null;
+        }
+
+        /** The rule split at the spaces that are not inside a quoted value. */
+        List<String> tokens() {
+            List<String> answer = new ArrayList<>();
+            if (text == null) {
+                return answer;
+            }
+            StringBuilder current = new StringBuilder();
+            char quote = 0;
+            for (int i = 0; i < text.length(); i++) {
+                char ch = text.charAt(i);
+                if (quote != 0) {
+                    current.append(ch);
+                    if (ch == quote) {
+                        quote = 0;
+                    }
+                } else if (ch == ' ' || ch == '\t') {
+                    if (!current.isEmpty()) {
+                        answer.add(current.toString());
+                        current.setLength(0);
+                    }
+                } else {
+                    if (ch == '"' || ch == '\'') {
+                        quote = ch;
+                    }
+                    current.append(ch);
+                }
+            }
+            if (!current.isEmpty()) {
+                answer.add(current.toString());
+            }
+            return answer;
+        }
+
+        boolean isActive() {
+            return "active".equals(state) || "waiting".equals(state);
+        }
+    }
+
+    // the options of a rule besides its target and its fault
+    private static final List<String> OPTIONS = List.of("at", "rate", "until", 
"from", "expect", "within");
+
+    /** The counts of a route in the report. */
+    record RouteCounts(String route, long completed, long failed, long 
failedKept, long handled, long redeliveries) {
+    }
+
+    /** A recent fault of a rule. */
+    record RecentFault(long ago, String rule, String fault, String hook, 
String route, String where, String exchangeId) {
+    }
+
+    /** The active (and waiting) rules. */
+    long activeRules() {
+        return rules.stream().filter(Rule::isActive).count();
+    }
+
+    /** The faults of all rules in the last minute. */
+    long faultsLastMinute() {
+        return rules.stream().mapToLong(r -> r.faultsLastMinute).sum();
+    }
+
+    /** The current rule with the id, or null. */
+    Rule rule(String id) {
+        for (Rule r : rules) {
+            if (r.id != null && r.id.equals(id)) {
+                return r;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Parses the chaos section of the status.
+     *
+     * @return the chaos info, or null when there is no section (the 
integration has no camel-chaos)
+     */
+    static ChaosInfo parse(JsonObject chaos) {
+        if (chaos == null) {
+            return null;
+        }
+        ChaosInfo info = new ChaosInfo();
+        info.enabled = chaos.getBooleanOrDefault("enabled", false);
+        info.reason = chaos.getString("reason");
+        if (chaos.get("rules") instanceof JsonArray rules) {
+            for (Object o : rules) {
+                if (o instanceof JsonObject jo) {
+                    info.rules.add(rule(jo));
+                }
+            }
+        }
+        if (chaos.get("report") instanceof JsonObject report) {
+            info.verdict = report.getString("verdict");
+            info.isFinal = report.getBooleanOrDefault("final", true);
+            info.seed = num(report.get("seed"), 0);
+            info.seedGiven = report.getBooleanOrDefault("seedGiven", false);
+            info.lost = num(report.get("lost"), 0);
+            if (report.get("reasons") instanceof JsonArray reasons) {
+                reasons.forEach(r -> info.reasons.add(String.valueOf(r)));
+            }
+            if (report.get("routes") instanceof JsonArray routes) {
+                for (Object o : routes) {
+                    if (o instanceof JsonObject r) {
+                        info.routes.add(new RouteCounts(
+                                r.getString("route"), num(r.get("completed"), 
0), num(r.get("failed"), 0),
+                                num(r.get("failedKeptByConsumer"), 0), 
num(r.get("handled"), 0),
+                                num(r.get("redeliveries"), 0)));
+                    }
+                }
+            }
+            if (report.get("recentFaults") instanceof JsonArray faults) {
+                for (Object o : faults) {
+                    if (o instanceof JsonObject f) {
+                        String where = f.getString("endpoint") != null ? 
f.getString("endpoint") : f.getString("step");
+                        info.recentFaults.add(new RecentFault(
+                                num(f.get("ago"), -1), f.getString("rule"), 
f.getString("fault"), f.getString("hook"),
+                                f.getString("route"), where, 
f.getString("exchangeId")));
+                    }
+                }
+            }
+        }
+        return info;
+    }
+
+    private static Rule rule(JsonObject jo) {
+        Rule r = new Rule();
+        r.id = jo.getString("id");
+        r.version = (int) num(jo.get("version"), 1);
+        r.text = jo.getString("rule");
+        r.state = jo.getString("state");
+        r.matches = num(jo.get("matches"), 0);
+        if (jo.get("fired") instanceof Map<?, ?> fired) {
+            for (Object v : fired.values()) {
+                r.fired += num(v, 0);
+            }
+        }
+        r.skipped = num(jo.get("skipped"), 0);
+        if (jo.get("skippedReasons") instanceof Map<?, ?> reasons) {
+            reasons.forEach((k, v) -> r.skippedReasons.put(String.valueOf(k), 
num(v, 0)));
+        }
+        r.lostAtDlq = num(jo.get("lostAtDlq"), -1);
+        r.endsIn = num(jo.get("endsIn"), -1);
+        r.startsIn = num(jo.get("startsIn"), -1);
+        r.rate = dbl(jo.get("rate"));
+        r.observedRate = dbl(jo.get("observedRate"));
+        r.nextFireIn = num(jo.get("nextFireIn"), -1);
+        r.burstEndsIn = num(jo.get("burstEndsIn"), -1);
+        r.nextBurstIn = num(jo.get("nextBurstIn"), -1);
+        r.lastFiredAgo = num(jo.get("lastFiredAgo"), -1);
+        r.faultsLastMinute = num(jo.get("faultsLastMinute"), 0);
+        if (jo.get("faultsPerSecond") instanceof List<?> perSecond) {
+            r.faultsPerSecond = new long[perSecond.size()];
+            for (int i = 0; i < perSecond.size(); i++) {
+                r.faultsPerSecond[i] = num(perSecond.get(i), 0);
+            }
+        }
+        if (jo.get("effect") instanceof JsonObject e) {
+            r.faulted = num(e.get("faulted"), 0);
+            r.completed = num(e.get("completed"), 0);
+            r.recovered = num(e.get("recovered"), 0);
+            r.handled = num(e.get("handled"), 0);
+            r.failed = num(e.get("failed"), 0);
+            r.inFlight = num(e.get("inFlight"), 0);
+        }
+        if (jo.get("expectation") instanceof JsonObject e) {
+            r.expectRoute = e.getString("route");
+            r.expectKind = e.getStringOrDefault("kind", "completed");
+            r.expectOp = e.getStringOrDefault("op", ">=");
+            r.expectCount = num(e.get("count"), 0);
+            r.expectValue = num(e.get("value") != null ? e.get("value") : 
e.get("completed"), 0);
+            r.expectOutcome = e.getString("outcome");
+            r.expectTimeLeft = num(e.get("timeLeft"), -1);
+        }
+        if (jo.get("lifecycle") instanceof JsonObject l) {
+            r.lifecycleAction = l.getString("action");
+            r.lifecycleRoute = l.getString("route");
+            r.lifecycleState = l.getString("state");
+            r.lifecycleRound = (int) num(l.get("round"), 0);
+            r.lifecycleRepeat = (int) num(l.get("repeat"), 1);
+            r.lifecycleActsIn = num(l.get("actsIn"), -1);
+            r.lifecycleBackIn = num(l.get("backIn"), -1);
+            r.lifecycleReason = l.getString("reason");
+        }
+        return r;
+    }
+
+    private static long num(Object value, long otherwise) {
+        return value instanceof Number n ? n.longValue() : otherwise;
+    }
+
+    private static double dbl(Object value) {
+        return value instanceof Number n ? n.doubleValue() : -1;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
new file mode 100644
index 000000000000..d14f906f0b89
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java
@@ -0,0 +1,676 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.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.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;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hint;
+
+/**
+ * The chaos screen (camel-chaos): whether chaos is on, the verdict of the 
report, the current rules with their live
+ * state (the observed rate, when a rule last fired, its faults per second 
over the last minute as a sparkline, and the
+ * effect of its faults on the exchanges), the selected rule in detail with 
its recent faults, and the counts per route
+ * of the report. It only reads: rules are added, changed and removed with 
{@code camel cmd chaos-*}.
+ */
+class ChaosTab extends AbstractTableTab {
+
+    private static final String SPARK = "▁▂▃▄▅▆▇█";
+    // the width of the sparkline in the rules table: the 60 seconds, 4 per 
column
+    private static final int TABLE_SPARK = 15;
+    private static final int ROUTES_WIDTH = 46;
+
+    ChaosTab(MonitorContext ctx) {
+        super(ctx);
+    }
+
+    @Override
+    protected boolean selectsFirstRow() {
+        return true;
+    }
+
+    @Override
+    protected int getRowCount() {
+        IntegrationInfo info = ctx.findSelectedIntegration();
+        return info != null && info.chaos != null ? info.chaos.rules.size() : 
0;
+    }
+
+    @Override
+    protected void renderContent(Frame frame, Rect area, IntegrationInfo info) 
{
+        ChaosInfo chaos = info.chaos;
+        if (chaos == null || !chaos.enabled) {
+            String why = chaos == null
+                    ? "camel-chaos is not in this integration: run it with 
camel run --chaos"
+                    : chaos.reason != null ? chaos.reason : "Chaos is off";
+            frame.renderWidget(Paragraph.builder()
+                    .text(Text.from(Line.from(Span.raw("")), 
Line.from(Span.styled("  " + why, Theme.muted()))))
+                    .block(block(" Chaos "))
+                    .build(), area);
+            return;
+        }
+        int tableHeight = Math.max(5, Math.min(chaos.rules.size() + 3, 
area.height() / 3));
+        List<Rect> rows = Layout.vertical()
+                .constraints(Constraint.length(4), 
Constraint.length(tableHeight), Constraint.fill())
+                .split(area);
+        renderVerdict(frame, rows.get(0), chaos);
+        renderRules(frame, rows.get(1), chaos, info.name);
+        List<Rect> bottom = Layout.horizontal()
+                .constraints(Constraint.fill(), 
Constraint.length(ROUTES_WIDTH))
+                .split(rows.get(2));
+        renderDetail(frame, bottom.get(0), chaos, selectedRule(chaos));
+        renderRoutes(frame, bottom.get(1), chaos);
+    }
+
+    private ChaosInfo.Rule selectedRule(ChaosInfo chaos) {
+        Integer sel = tableState.selected();
+        return sel != null && sel >= 0 && sel < chaos.rules.size() ? 
chaos.rules.get(sel) : null;
+    }
+
+    // ---- the verdict ----
+
+    private static void renderVerdict(Frame frame, Rect area, ChaosInfo chaos) 
{
+        boolean fail = "FAIL".equals(chaos.verdict);
+        List<Span> first = new ArrayList<>();
+        first.add(Span.raw(" "));
+        first.add(Span.styled(chaos.verdict != null ? chaos.verdict : "-",
+                (fail ? Theme.error() : Theme.success()).bold()));
+        first.add(Span.raw("   "));
+        first.add(Span.styled("lost ", Theme.muted()));
+        first.add(Span.styled(String.valueOf(chaos.lost), chaos.lost > 0 ? 
Theme.error() : Style.EMPTY));
+        long met = 0;
+        long unmet = 0;
+        long pending = 0;
+        long waiting = 0;
+        for (ChaosInfo.Rule r : chaos.rules) {
+            if ("met".equals(r.expectOutcome)) {
+                met++;
+            } else if ("unmet".equals(r.expectOutcome)) {
+                unmet++;
+            } else if ("pending".equals(r.expectOutcome)) {
+                pending++;
+            }
+            if ("waiting".equals(r.state)) {
+                waiting++;
+            }
+        }
+        if (met + unmet + pending > 0) {
+            first.add(Span.styled("   expectations ", Theme.muted()));
+            first.add(Span.styled(met + " met", Theme.success()));
+            first.add(Span.raw(" · "));
+            first.add(Span.styled(unmet + " unmet", unmet > 0 ? Theme.error() 
: Style.EMPTY));
+            first.add(Span.raw(" · "));
+            first.add(Span.styled(pending + " pending", pending > 0 ? 
Theme.warning() : Style.EMPTY));
+        }
+        first.add(Span.styled("   faults ", Theme.muted()));
+        first.add(Span.raw(String.valueOf(chaos.faultsLastMinute())));
+        first.add(Span.styled(" in the last minute", Theme.muted()));
+        first.add(Span.styled("   rules ", Theme.muted()));
+        first.add(Span.raw((chaos.activeRules() - waiting) + " active"
+                           + (waiting > 0 ? " · " + waiting + " waiting" : 
"")));
+        // the seed is in the title; the reason of a failed verdict, or what 
the verdict waits for
+        Line second;
+        if (!chaos.reasons.isEmpty()) {
+            second = Line.from(Span.styled(" " + TuiIcons.CROSS + " " + 
chaos.reasons.get(0)
+                                           + (chaos.reasons.size() > 1 ? " (+" 
+ (chaos.reasons.size() - 1) + " more)" : ""),
+                    Theme.error()));
+        } else if (!chaos.isFinal) {
+            second = Line.from(Span.styled(" not final: an expectation is 
pending", Theme.muted()));
+        } else {
+            second = Line.from(Span.raw(""));
+        }
+        frame.renderWidget(Paragraph.builder()
+                .text(Text.from(Line.from(first), second))
+                .block(block(" Chaos ─ on ─ seed " + chaos.seed + " "))
+                .build(), area);
+    }
+
+    // ---- the rules ----
+
+    private void renderRules(Frame frame, Rect area, ChaosInfo chaos, String 
name) {
+        List<Row> rows = new ArrayList<>();
+        for (ChaosInfo.Rule r : chaos.rules) {
+            rows.add(Row.from(
+                    Cell.from(Span.styled(r.id + (r.version > 1 ? " v" + 
r.version : ""), Style.EMPTY.fg(Theme.accent()))),
+                    Cell.from(Span.styled(r.state != null ? r.state : "", 
stateStyle(r.state))),
+                    Cell.from(target(r)),
+                    Cell.from(r.fault()),
+                    Cell.from(rate(r)),
+                    rightCell(String.valueOf(r.fired), 5),
+                    rightCell(r.lastFiredAgo >= 0 ? shortTime(r.lastFiredAgo) 
: "-", 6),
+                    Cell.from(Span.styled(sparkline(r.faultsPerSecond, 
TABLE_SPARK), Theme.warning())),
+                    Cell.from(Line.from(effect(r))),
+                    Cell.from(Span.styled(note(r), Theme.muted()))));
+        }
+        if (rows.isEmpty()) {
+            frame.renderWidget(Paragraph.builder()
+                    .text(Text.from(Line.from(Span.styled(
+                            " No chaos rules: add one with camel cmd chaos-add 
" + (name != null ? name : "<name>")
+                                                          + " --rule='...'",
+                            Theme.muted()))))
+                    .block(block(" Rules [0] "))
+                    .build(), area);
+            return;
+        }
+        Style bold = Style.EMPTY.bold();
+        Table table = Table.builder()
+                .rows(rows)
+                .header(Row.from(
+                        Cell.from(Span.styled("ID", bold)),
+                        Cell.from(Span.styled("STATE", bold)),
+                        Cell.from(Span.styled("TARGET", bold)),
+                        Cell.from(Span.styled("FAULT", bold)),
+                        Cell.from(Span.styled("RATE", bold)),
+                        rightCell("FIRED", 5, bold),
+                        rightCell("LAST", 6, bold),
+                        Cell.from(Span.styled("FAULTS/S", bold)),
+                        Cell.from(Span.styled("EFFECT", bold)),
+                        Cell.from(Span.styled("NOTE", bold))))
+                .widths(
+                        Constraint.length(6),
+                        Constraint.length(8),
+                        Constraint.fill(7),
+                        Constraint.fill(5),
+                        Constraint.length(16),
+                        Constraint.length(5),
+                        Constraint.length(6),
+                        Constraint.length(TABLE_SPARK),
+                        Constraint.length(12),
+                        Constraint.fill(4))
+                .highlightStyle(Theme.selectionBg())
+                .highlightSpacing(Table.HighlightSpacing.ALWAYS)
+                .block(block(" Rules [" + chaos.rules.size() + "] "))
+                .build();
+        lastTableArea = area;
+        frame.renderStatefulWidget(table, area, tableState);
+        renderScrollbar(frame, table, chaos.rules.size());
+    }
+
+    /**
+     * The target of a rule for the table: the URI pattern of an {@code 
endpoint=} target (the most common one), else
+     * the kind and the pattern, such as {@code route poller}.
+     */
+    static String target(ChaosInfo.Rule r) {
+        String t = r.target();
+        if (t.startsWith("endpoint=")) {
+            return t.substring(9);
+        }
+        int eq = t.indexOf('=');
+        return eq > 0 ? t.substring(0, eq) + " " + t.substring(eq + 1) : t;
+    }
+
+    /**
+     * The observed rate next to the rate of the rule, such as {@code 24% of 
25%}, {@code 1 in 4} or {@code 5s in 30s}.
+     */
+    static String rate(ChaosInfo.Rule r) {
+        if (r.lifecycleAction != null) {
+            return r.lifecycleRepeat > 1 ? "round " + r.lifecycleRound + " of 
" + r.lifecycleRepeat : "";
+        }
+        String option = r.option("rate");
+        String target;
+        if (option != null && option.startsWith("burst:")) {
+            target = option.substring(6).replace("/", " in ");
+        } else if (option != null && option.startsWith("every:")) {
+            target = "1 in " + option.substring(6);
+        } else if (r.rate >= 0 && r.rate < 1) {
+            target = percent(r.rate);
+        } else {
+            return "all";
+        }
+        return r.observedRate >= 0 ? percent(r.observedRate) + " of " + target 
: target;
+    }
+
+    /** The effect of the faults of a rule, compact: completed, recovered, 
handled, failed and in flight. */
+    static List<Span> effect(ChaosInfo.Rule r) {
+        List<Span> spans = new ArrayList<>();
+        if (r.faulted <= 0) {
+            return spans;
+        }
+        addCount(spans, r.completed, "✓", Theme.success());
+        addCount(spans, r.recovered, "↩", Theme.warning());
+        addCount(spans, r.handled, "⇩", Theme.error());
+        addCount(spans, r.failed, TuiIcons.CROSS, Theme.error().bold());
+        addCount(spans, r.inFlight, "…", Theme.muted());
+        return spans;
+    }
+
+    private static void addCount(List<Span> spans, long count, String mark, 
Style style) {
+        if (count > 0) {
+            if (!spans.isEmpty()) {
+                spans.add(Span.raw(" "));
+            }
+            spans.add(Span.styled(count + mark, style));
+        }
+    }
+
+    /** What a person should know about a rule besides its counts: when it 
starts or ends, its bursts, its route. */
+    static String note(ChaosInfo.Rule r) {
+        if (r.lifecycleState != null) {
+            // the route is the target of the rule; between the rounds of 
repeat= the route is back
+            if (r.lifecycleRound > 0 && r.lifecycleActsIn >= 0) {
+                return "next round in " + shortTime(r.lifecycleActsIn);
+            }
+            StringBuilder sb = new StringBuilder(r.lifecycleState);
+            if (r.lifecycleActsIn >= 0) {
+                sb.append(", acts in ").append(shortTime(r.lifecycleActsIn));
+            } else if (r.lifecycleBackIn >= 0) {
+                sb.append(", back in ").append(shortTime(r.lifecycleBackIn));
+            }
+            if (r.lifecycleReason != null) {
+                sb.append(": ").append(r.lifecycleReason);
+            }
+            return sb.toString();
+        }
+        List<String> parts = new ArrayList<>();
+        if (r.startsIn >= 0) {
+            parts.add("starts in " + shortTime(r.startsIn));
+        }
+        if (r.burstEndsIn >= 0) {
+            parts.add("burst, " + shortTime(r.burstEndsIn) + " left");
+        } else if (r.nextBurstIn >= 0) {
+            parts.add("next burst in " + shortTime(r.nextBurstIn));
+        }
+        if (r.nextFireIn > 0) {
+            // a count of matches (rate=every:N), not a time
+            parts.add("fires again in " + r.nextFireIn + (r.nextFireIn == 1 ? 
" match" : " matches"));
+        }
+        if (r.endsIn >= 0) {
+            parts.add("ends in " + shortTime(r.endsIn));
+        }
+        if (r.skipped > 0) {
+            parts.add("skipped " + r.skipped);
+        }
+        if (r.lostAtDlq > 0) {
+            parts.add(r.lostAtDlq + " lost at the DLQ");
+        }
+        return String.join(", ", parts);
+    }
+
+    // ---- the selected rule ----
+
+    private static void renderDetail(Frame frame, Rect area, ChaosInfo chaos, 
ChaosInfo.Rule r) {
+        if (r == null) {
+            
frame.renderWidget(Paragraph.builder().text(Text.from(Line.from(Span.raw(""))))
+                    .block(block(" Rule ")).build(), area);
+            return;
+        }
+        Style label = Theme.muted();
+        List<Line> lines = new ArrayList<>();
+        int sparkWidth = Math.max(10, Math.min(60, area.width() - 20));
+        lines.add(Line.from(Span.styled(" Faults/s  ", label),
+                Span.styled(sparkline(r.faultsPerSecond, sparkWidth), 
Theme.warning()),
+                Span.styled("  " + r.faultsLastMinute + " in 60s", label)));
+        List<Span> rate = new ArrayList<>();
+        rate.add(Span.styled(" Rate      ", label));
+        rate.add(Span.raw(rate(r)));
+        if (r.lastFiredAgo >= 0) {
+            rate.add(Span.styled("   last fired ", label));
+            rate.add(Span.raw(shortTime(r.lastFiredAgo) + " ago"));
+        }
+        rate.add(Span.styled("   matches ", label));
+        rate.add(Span.raw(String.valueOf(r.matches)));
+        String note = note(r);
+        if (!note.isEmpty()) {
+            rate.add(Span.styled("   " + note, label));
+        }
+        lines.add(Line.from(rate));
+        if (r.faulted > 0) {
+            lines.add(effectBar(r, Math.max(10, Math.min(40, area.width() - 
40))));
+            lines.add(Line.from(effectWords(r)));
+        }
+        if (r.expectRoute != null) {
+            String spec = "completed".equals(r.expectKind) && 
">=".equals(r.expectOp)
+                    ? r.expectRoute + ":" + r.expectCount
+                    : r.expectRoute + ":" + r.expectKind + r.expectOp + 
r.expectCount;
+            Style outcome = switch (r.expectOutcome != null ? r.expectOutcome 
: "") {
+                case "met" -> Theme.success().bold();
+                case "unmet" -> Theme.error().bold();
+                case "pending" -> Theme.warning();
+                default -> Theme.muted();
+            };
+            List<Span> expect = new ArrayList<>();
+            expect.add(Span.styled(" Expect    ", label));
+            expect.add(Span.raw(spec + "  "));
+            expect.add(Span.styled(r.expectOutcome != null ? 
r.expectOutcome.toUpperCase(Locale.ENGLISH) : "-", outcome));
+            expect.add(Span.raw("  " + r.expectValue + " " + r.expectKind));
+            if (r.expectTimeLeft >= 0 && "pending".equals(r.expectOutcome)) {
+                expect.add(Span.styled("  " + shortTime(r.expectTimeLeft) + " 
left", label));
+            }
+            lines.add(Line.from(expect));
+        }
+        for (Map.Entry<String, Long> skip : r.skippedReasons.entrySet()) {
+            lines.add(Line.from(Span.styled(" Skipped   ", label),
+                    Span.styled(skip.getValue() + ": " + skip.getKey(), 
Theme.warning())));
+        }
+        boolean first = true;
+        for (ChaosInfo.RecentFault f : chaos.recentFaults) {
+            if (r.id == null || !r.id.equals(f.rule())) {
+                continue;
+            }
+            lines.add(Line.from(
+                    Span.styled(first ? " Recent    " : "           ", label),
+                    Span.styled(String.format("%-6s", f.ago() >= 0 ? 
shortTime(f.ago()) : ""), label),
+                    Span.styled("🎲 ", Theme.warning()),
+                    Span.raw(f.fault() != null ? f.fault() : ""),
+                    Span.styled(where(f), label)));
+            first = false;
+        }
+        frame.renderWidget(Paragraph.builder()
+                .text(Text.from(lines))
+                .block(block(" " + r.id + " ─ " + r.text + " "))
+                .build(), area);
+    }
+
+    private static String where(ChaosInfo.RecentFault f) {
+        StringBuilder sb = new StringBuilder();
+        if (f.hook() != null) {
+            sb.append(" at ").append(f.hook());
+            if (f.where() != null) {
+                sb.append(' ').append(f.where());
+            }
+        }
+        if (f.route() != null) {
+            sb.append(" · route ").append(f.route());
+        }
+        if (f.exchangeId() != null) {
+            String id = f.exchangeId();
+            // the start and the end of the id, as the Activity tab shortens it
+            sb.append(" · ").append(id.length() > 16 ? id.substring(0, 6) + 
"…" + id.substring(id.length() - 4) : id);
+        }
+        return sb.toString();
+    }
+
+    /** The effect as a bar: completed, recovered, handled, failed, and in 
flight, in proportion. */
+    private static Line effectBar(ChaosInfo.Rule r, int width) {
+        long total = Math.max(1, r.faulted);
+        List<Span> spans = new ArrayList<>();
+        spans.add(Span.styled(" Effect    ", Theme.muted()));
+        int used = 0;
+        long[] counts = { r.completed, r.recovered, r.handled, r.failed, 
r.inFlight };
+        Style[] styles = { Theme.success(), Theme.warning(), Theme.error(), 
Theme.error().bold(), Theme.muted() };
+        for (int i = 0; i < counts.length; i++) {
+            int n = (int) Math.round((double) counts[i] * width / total);
+            if (counts[i] > 0 && n == 0) {
+                n = 1;
+            }
+            n = Math.min(n, width - used);
+            if (n > 0) {
+                spans.add(Span.styled((i == counts.length - 1 ? "░" : 
"█").repeat(n), styles[i]));
+                used += n;
+            }
+        }
+        spans.add(Span.styled("  " + r.faulted + (r.faulted == 1 ? " exchange" 
: " exchanges"), Theme.muted()));
+        return Line.from(spans);
+    }
+
+    private static List<Span> effectWords(ChaosInfo.Rule r) {
+        List<Span> spans = new ArrayList<>();
+        spans.add(Span.raw("           "));
+        addWords(spans, r.completed, "completed", Theme.success());
+        addWords(spans, r.recovered, "recovered by redelivery", 
Theme.warning());
+        addWords(spans, r.handled, "handled by the error handler", 
Theme.error());
+        addWords(spans, r.failed, "failed", Theme.error().bold());
+        addWords(spans, r.inFlight, "in flight", Theme.muted());
+        return spans;
+    }
+
+    private static void addWords(List<Span> spans, long count, String words, 
Style style) {
+        if (count > 0) {
+            if (spans.size() > 1) {
+                spans.add(Span.styled(" · ", Theme.muted()));
+            }
+            spans.add(Span.styled(count + " " + words, style));
+        }
+    }
+
+    // ---- the routes ----
+
+    private static void renderRoutes(Frame frame, Rect area, ChaosInfo chaos) {
+        List<Row> rows = new ArrayList<>();
+        for (ChaosInfo.RouteCounts c : chaos.routes) {
+            rows.add(Row.from(
+                    Cell.from(Span.styled(c.route() != null ? c.route() : "", 
Style.EMPTY.fg(Theme.accent()))),
+                    rightCell(String.valueOf(c.completed()), 6),
+                    rightCell(String.valueOf(c.failed()), 5, c.failed() > 0 ? 
Theme.error() : Style.EMPTY),
+                    rightCell(String.valueOf(c.failedKept()), 5),
+                    rightCell(String.valueOf(c.handled()), 5, c.handled() > 0 
? Theme.warning() : Style.EMPTY),
+                    rightCell(String.valueOf(c.redeliveries()), 6)));
+        }
+        if (rows.isEmpty()) {
+            frame.renderWidget(Paragraph.builder()
+                    .text(Text.from(Line.from(Span.styled(" No routes counted 
yet", Theme.muted()))))
+                    .block(block(" Routes "))
+                    .build(), area);
+            return;
+        }
+        Style bold = Style.EMPTY.bold();
+        Table table = Table.builder()
+                .rows(rows)
+                .header(Row.from(
+                        Cell.from(Span.styled("ROUTE", bold)),
+                        rightCell("DONE", 6, bold),
+                        rightCell("FAIL", 5, bold),
+                        rightCell("KEPT", 5, bold),
+                        rightCell("HAND", 5, bold),
+                        rightCell("REDEL", 6, bold)))
+                .widths(
+                        Constraint.fill(),
+                        Constraint.length(6),
+                        Constraint.length(5),
+                        Constraint.length(5),
+                        Constraint.length(5),
+                        Constraint.length(6))
+                .block(block(" Routes "))
+                .build();
+        frame.renderStatefulWidget(table, area, new TableState());
+    }
+
+    // ---- the badge ----
+
+    /**
+     * The badge of the chaos screen, in the More menu and the title bar: how 
many rules inject faults now, and FAIL
+     * when the verdict failed; null when chaos is off or there is nothing to 
say.
+     */
+    static String badge(IntegrationInfo info) {
+        if (info == null || info.chaos == null || !info.chaos.enabled) {
+            return null;
+        }
+        long active = info.chaos.activeRules();
+        boolean fail = "FAIL".equals(info.chaos.verdict);
+        if (active == 0 && !fail) {
+            return null;
+        }
+        String rules = active == 1 ? "1 rule" : active + " rules";
+        return active > 0 ? rules + (fail ? " · FAIL" : "") : "FAIL";
+    }
+
+    /**
+     * The badge in the title bar, on every screen: faults are injected on 
purpose, so a failure made by chaos is not
+     * debugged as a real one. Empty when there is no badge.
+     */
+    static List<Span> headerBadge(IntegrationInfo info) {
+        String badge = badge(info);
+        if (badge == null) {
+            return List.of();
+        }
+        Style style = (badge.endsWith("FAIL") ? Theme.error() : 
Theme.warning()).bold();
+        return List.of(Span.styled(" " + TuiIcons.TAB_CHAOS + " chaos " + 
badge + " ", style), Span.raw(" "));
+    }
+
+    // ---- helpers ----
+
+    /**
+     * The values as a sparkline of the width: several values share a column 
(the largest of them), and the height is
+     * relative to the largest value; a column with no fault is blank.
+     */
+    static String sparkline(long[] values, int width) {
+        if (values == null || values.length == 0 || width <= 0) {
+            return "";
+        }
+        int per = Math.max(1, (int) Math.ceil((double) values.length / width));
+        int columns = (values.length + per - 1) / per;
+        long[] cols = new long[columns];
+        long max = 0;
+        for (int i = 0; i < values.length; i++) {
+            cols[i / per] = Math.max(cols[i / per], values[i]);
+            max = Math.max(max, values[i]);
+        }
+        StringBuilder sb = new StringBuilder();
+        for (long v : cols) {
+            if (v <= 0 || max == 0) {
+                sb.append(' ');
+            } else {
+                int level = (int) Math.min(SPARK.length() - 1, Math.max(0, (v 
* SPARK.length() - 1) / max));
+                sb.append(SPARK.charAt(level));
+            }
+        }
+        return sb.toString();
+    }
+
+    /** A time for a column: 800ms, 2s, 1m4s, 2h3m. */
+    static String shortTime(long ms) {
+        if (ms < 1000) {
+            return ms + "ms";
+        }
+        long s = ms / 1000;
+        if (s < 60) {
+            return s + "s";
+        }
+        if (s < 3600) {
+            return (s / 60) + "m" + (s % 60 > 0 ? (s % 60) + "s" : "");
+        }
+        return (s / 3600) + "h" + ((s % 3600) / 60 > 0 ? ((s % 3600) / 60) + 
"m" : "");
+    }
+
+    private static String percent(double share) {
+        double p = share * 100;
+        return p >= 10 || p == Math.rint(p)
+                ? String.format(Locale.US, "%.0f%%", p) : 
String.format(Locale.US, "%.1f%%", p);
+    }
+
+    private static Style stateStyle(String state) {
+        return switch (state != null ? state : "") {
+            case "active" -> Theme.warning();
+            case "waiting" -> Theme.info();
+            default -> Theme.muted();
+        };
+    }
+
+    private static Block block(String title) {
+        return 
Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).build();
+    }
+
+    @Override
+    public void renderFooter(List<Span> spans) {
+        hint(spans, "Esc", "back");
+    }
+
+    @Override
+    public SelectionContext getSelectionContext() {
+        IntegrationInfo info = ctx.findSelectedIntegration();
+        if (info == null || info.chaos == null || info.chaos.rules.isEmpty()) {
+            return null;
+        }
+        List<String> items = info.chaos.rules.stream().map(r -> r.id + ": " + 
r.text).toList();
+        Integer sel = tableState.selected();
+        return new SelectionContext("table", items, sel != null ? sel : -1, 
items.size(), "Chaos rules");
+    }
+
+    @Override
+    public String description() {
+        return "The chaos rules of camel-chaos with their live state, and the 
report";
+    }
+
+    @Override
+    public String getHelpText() {
+        return DocHelper.loadHelpText("chaos");
+    }
+
+    @Override
+    public JsonObject getTableDataAsJson() {
+        IntegrationInfo info = ctx.findSelectedIntegration();
+        if (info == null || info.chaos == null) {
+            return null;
+        }
+        ChaosInfo chaos = info.chaos;
+        JsonObject result = new JsonObject();
+        result.put("tab", "Chaos");
+        result.put("enabled", chaos.enabled);
+        if (chaos.reason != null) {
+            result.put("reason", chaos.reason);
+        }
+        result.put("verdict", chaos.verdict);
+        result.put("final", chaos.isFinal);
+        result.put("lost", chaos.lost);
+        result.put("reasons", new JsonArray(chaos.reasons));
+        JsonArray rows = new JsonArray();
+        for (ChaosInfo.Rule r : chaos.rules) {
+            JsonObject row = new JsonObject();
+            row.put("id", r.id);
+            row.put("version", r.version);
+            row.put("rule", r.text);
+            row.put("state", r.state);
+            row.put("rate", rate(r));
+            row.put("matches", r.matches);
+            row.put("fired", r.fired);
+            row.put("faultsLastMinute", r.faultsLastMinute);
+            if (r.lastFiredAgo >= 0) {
+                row.put("lastFiredAgo", r.lastFiredAgo);
+            }
+            if (r.faulted > 0) {
+                JsonObject effect = new JsonObject();
+                effect.put("faulted", r.faulted);
+                effect.put("completed", r.completed);
+                effect.put("recovered", r.recovered);
+                effect.put("handled", r.handled);
+                effect.put("failed", r.failed);
+                effect.put("inFlight", r.inFlight);
+                row.put("effect", effect);
+            }
+            if (r.expectOutcome != null) {
+                row.put("expectation", r.expectOutcome);
+            }
+            row.put("note", note(r));
+            rows.add(row);
+        }
+        result.put("rows", rows);
+        result.put("totalRows", chaos.rules.size());
+        Integer sel = tableState.selected();
+        result.put("selectedIndex", sel != null ? sel : -1);
+        return result;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
index 77743bb194db..5c4f89ed2091 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
@@ -106,6 +106,8 @@ class IntegrationInfo {
     final List<EndpointInfo> endpoints = new ArrayList<>();
     final List<ServiceInfo> services = new ArrayList<>();
     final List<CircuitBreakerInfo> circuitBreakers = new ArrayList<>();
+    // the chaos rules and report (camel-chaos), or null without camel-chaos
+    ChaosInfo chaos;
     final List<KafkaConsumerInfo> kafkaConsumers = new ArrayList<>();
     int errorCount;
     volatile List<ErrorInfo> errors = new ArrayList<>();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
index daa072555132..e9a2b0cb9d2e 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
@@ -704,6 +704,12 @@ class PopupManager {
                     badge = " " + cbOpenCount + " OPEN";
                     badgeStyle = Theme.error();
                 }
+                // the chaos rules that inject faults now, and a failed verdict
+                String chaosBadge = "Chaos".equals(tab.name()) ? 
ChaosTab.badge(sel) : null;
+                if (chaosBadge != null) {
+                    badge = " " + chaosBadge;
+                    badgeStyle = chaosBadge.endsWith("FAIL") ? Theme.error() : 
Theme.warning();
+                }
 
                 List<Span> spans = new ArrayList<>();
                 if (keyPos >= 0 && keyPos < name.length()) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
index 5766eadbd6e4..a5e2ab416314 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusParser.java
@@ -107,6 +107,7 @@ final class StatusParser {
         parseServices(info, root);
         parseInternalTasks(info, root);
         parseCircuitBreakers(info, root);
+        info.chaos = ChaosInfo.parse(root.get("chaos") instanceof JsonObject 
chaos ? chaos : null);
 
         // Parse Kafka consumers
         parseKafkaSection(root, info);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java
index 3709b5d25e20..807a2740071c 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java
@@ -73,6 +73,7 @@ class TabRegistry {
     private HealthTab healthTab;
     private HistoryTab historyTab;
     private CircuitBreakerTab circuitBreakerTab;
+    private ChaosTab chaosTab;
     private ErrorsTab errorsTab;
     private ActivityTab activityTab;
     private MetricsTab metricsTab;
@@ -141,6 +142,7 @@ class TabRegistry {
         healthTab = new HealthTab(ctx);
         historyTab = new HistoryTab(ctx, dataService.traces(), 
dataService.traceFilePositions());
         circuitBreakerTab = new CircuitBreakerTab(ctx, dataService.metrics());
+        chaosTab = new ChaosTab(ctx);
         errorsTab = new ErrorsTab(ctx);
         activityTab = new ActivityTab(ctx);
         metricsTab = new MetricsTab(ctx);
@@ -190,6 +192,9 @@ class TabRegistry {
                         "Routing",
                         List.of(), info -> 
"SupervisingRouteController".equals(info.routeControllerType)),
                 // Observability
+                new MoreTab(
+                        TuiIcons.TAB_CHAOS, "Chaos", "C&haos", chaosTab, 
"Observability",
+                        List.of("chaos")),
                 new MoreTab(
                         TuiIcons.TAB_CIRCUIT_BREAKER, "Circuit Breaker", 
"&Circuit Breaker", circuitBreakerTab,
                         "Observability",
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
index ff4cac037eb7..29821e0c3dba 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
@@ -145,6 +145,7 @@ final class TuiIcons {
     static final String TAB_BEANS = JAVA;
     static final String TAB_BROWSE = MESSAGE;
     static final String TAB_CATALOG = "📖";
+    static final String TAB_CHAOS = "🎲";
     static final String TAB_CIRCUIT_BREAKER = "⚡";
     static final String TAB_CLASSPATH = BUNDLED;
     static final String TAB_CONFIGURATION = DOCUMENT;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
index b13294f2b517..070491a70878 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolGroups.java
@@ -123,7 +123,7 @@ final class TuiToolGroups {
             // injected on purpose, so it does not hunt for a bug behind them
             case CHAOS -> f.chaosOn()
                     ? "Chaos: camel-chaos is on, so some failures and delays 
may be injected on purpose; tui_get_status"
-                      + " section 'chaos' has the rules."
+                      + " section 'chaos' has the rules, tui_get_table tab 
'Chaos' their live state and effect."
                     : "Chaos: camel-chaos is in the integration but off, so it 
injects no faults.";
         };
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
new file mode 100644
index 000000000000..fa746aed9035
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/chaos.md
@@ -0,0 +1,54 @@
+# Chaos
+
+camel-chaos turns on faults in a running integration during development:
+"fail 25% of the calls to the stock service", "stall the poller route
+for 20 seconds". This screen shows the rules, how often they fire, what
+their faults do to the exchanges, and the verdict of the report.
+
+Chaos is on with `camel run --chaos` (profile `dev`). Add, change and
+remove rules with the Camel CLI:
+
+```
+camel cmd chaos-add myapp --rule='endpoint=http:stock* error status=503 
rate=0.25 until=10m'
+camel cmd chaos-remove myapp --id=r1
+camel cmd chaos-clear myapp --reset
+```
+
+While a rule injects faults, the title bar shows a badge on every
+screen (`🎲 chaos 3 rules · FAIL`), so a failure made on purpose is not
+debugged as a real one.
+
+## The verdict
+
+The top line has the verdict of the report: PASS, or FAIL with the
+reason, such as an expectation that is unmet or messages lost at the
+dead letter channel. It also shows how many messages were lost, the
+expectations (met, unmet, pending), the faults of all rules in the last
+minute, and the seed of the random draws.
+
+## Rules
+
+- **ID** — The id of the rule (r1, r2, ...), with its version when it was 
changed
+- **STATE** — `active`, `waiting` (it starts later, `from=`) or `ended`
+- **TARGET** — What the rule matches: `endpoint=`, `component=`, `route=` or 
`step=`
+- **FAULT** — What it does, with its parameters, such as `error status=503`
+- **RATE** — The share of the matches it fired at, next to its own: `24% of 
25%`, `20% of 1 in 5`, `16% of 5s in 30s`; for a flapping route the round
+- **FIRED** — How many faults it fired
+- **LAST** — How long ago it last fired
+- **FAULTS/S** — Its faults per second over the last minute, as a sparkline
+- **EFFECT** — What its faults did to the exchanges: `✓` completed, `↩` 
recovered by redelivery, `⇩` handled by the error handler (such as the dead 
letter channel), `✗` failed, `…` in flight
+- **NOTE** — When it ends or starts, its bursts, what a stall or lifecycle 
rule does to its route, and the faults it skipped
+
+## The selected rule
+
+The panel under the table shows the selected rule in full: its faults
+per second, rate, the effect as a bar with the counts in words, its
+expectation with the time left, why it skipped faults, and its recent
+faults: when, the fault, where (the endpoint or step), the route and the
+exchange.
+
+## Routes
+
+The counts of the report per route: completed (DONE), failed (FAIL),
+failed and kept by the consumer to read again (KEPT), handled by the
+error handler (HAND) and the redeliveries (REDEL).
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
index 838ed0a3d0d0..666fe4879c02 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelToolGroupsTest.java
@@ -243,7 +243,8 @@ class AiPanelToolGroupsTest {
         assertEquals(List.of(ToolGroup.CHAOS), on.groups());
         assertTrue(on.tools().isEmpty(), on.tools().toString());
         assertEquals(List.of("Chaos: camel-chaos is on, so some failures and 
delays may be injected on purpose;"
-                             + " tui_get_status section 'chaos' has the 
rules."),
+                             + " tui_get_status section 'chaos' has the rules, 
tui_get_table tab 'Chaos' their live"
+                             + " state and effect."),
                 on.guidance());
 
         TuiToolGroups.Selection off = TuiToolGroups.select(
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
new file mode 100644
index 000000000000..52e87bd580f6
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTabRenderTest.java
@@ -0,0 +1,239 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The chaos screen ({@link ChaosTab}) rendered from the chaos section of a 
status, as the chaos dev console of
+ * camel-chaos writes it.
+ */
+class ChaosTabRenderTest {
+
+    // the chaos section of a status: three rules (an error with an 
expectation, a waiting burst, a flapping stall), the
+    // report with a failed expectation, the counts per route and the recent 
faults
+    private static final String CHAOS = """
+            {
+              "enabled": true,
+              "rules": [
+                {
+                  "id": "r1", "version": 1, "state": "active",
+                  "rule": "endpoint=http://localhost:8765/stock* error 
status=503 rate=0.25 until=10m expect=orders:handled<=0",
+                  "hooks": ["send"], "matches": 170, "fired": {"send": 41}, 
"skipped": 0, "endsIn": 432000,
+                  "rate": 0.25, "observedRate": 0.2412, "lastFiredAgo": 2000, 
"faultsLastMinute": 12,
+                  "faultsPerSecond": 
[0,0,1,0,0,2,0,0,1,0,0,0,1,0,0,0,0,3,0,0,1,0,0,0,0,1,0,0,0,0,
+                                      
0,1,0,0,0,2,0,0,0,1,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0],
+                  "effect": {"faulted": 38, "completed": 0, "recovered": 33, 
"handled": 2, "failed": 0, "inFlight": 3},
+                  "expectation": {"route": "orders", "kind": "handled", "op": 
"<=", "count": 0, "within": 120000,
+                                  "value": 2, "outcome": "unmet"}
+                },
+                {
+                  "id": "r2", "version": 2, "state": "waiting",
+                  "rule": "endpoint=seda:shipping* drop rate=burst:5s/30s 
from=+30s",
+                  "hooks": ["send"], "matches": 0, "fired": {"send": 0}, 
"skipped": 0, "startsIn": 25000,
+                  "rate": 0.1667, "faultsLastMinute": 0
+                },
+                {
+                  "id": "r3", "version": 1, "state": "active",
+                  "rule": "route=poller stall duration=20s repeat=3 every=1m",
+                  "hooks": [], "matches": 1, "fired": {"lifecycle": 1}, 
"skipped": 0, "lastFiredAgo": 12000,
+                  "faultsLastMinute": 1,
+                  "lifecycle": {"action": "suspend", "route": "poller", 
"duration": 20000, "state": "down",
+                                "repeat": 3, "every": 60000, "round": 1, 
"downAt": 5, "backIn": 8000}
+                }
+              ],
+              "report": {
+                "verdict": "FAIL", "final": true, "seed": 42, "seedGiven": 
true, "lost": 0,
+                "reasons": ["route orders: 2 exchanges handled by the error 
handler within 2m0s, at most 0 expected (rule r1)"],
+                "routes": [
+                  {"route": "orders", "completed": 97, "failed": 0, 
"failedKeptByConsumer": 0, "handled": 2,
+                   "redeliveries": 44},
+                  {"route": "stock", "completed": 141, "failed": 0, 
"failedKeptByConsumer": 0, "handled": 0,
+                   "redeliveries": 0}
+                ],
+                "recentFaults": [
+                  {"timestamp": 1000, "ago": 2000, "rule": "r1", "version": 1, 
"fault": "error status 503",
+                   "hook": "send", "route": "orders", "endpoint": 
"http://localhost:8765/stock";,
+                   "exchangeId": "3F2A-1E"},
+                  {"timestamp": 900, "ago": 12000, "rule": "r3", "version": 1, 
"fault": "stall 20s",
+                   "route": "poller"}
+                ]
+              }
+            }
+            """;
+
+    private MonitorContext ctx;
+    private IntegrationInfo info;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        Theme.resetForTesting();
+        info = new IntegrationInfo();
+        info.pid = "1234";
+        info.name = "chaosdemo";
+        AtomicReference<List<IntegrationInfo>> data = new 
AtomicReference<>(List.of(info));
+        ctx = new MonitorContext(data, new AtomicReference<>(List.of()));
+        ctx.selectedPid = "1234";
+    }
+
+    private void chaos(String json) throws Exception {
+        info.chaos = ChaosInfo.parse((JsonObject) Jsoner.deserialize(json));
+    }
+
+    @Test
+    void theStatusParserReadsTheChaosSection() throws Exception {
+        JsonObject root = new JsonObject();
+        root.put("context", new JsonObject(Map.of("name", "chaosdemo")));
+        IntegrationInfo without = 
StatusParser.parseIntegration(ProcessHandle.current(), root);
+        assertThat(without.chaos).isNull();
+        root.put("chaos", Jsoner.deserialize(CHAOS));
+        IntegrationInfo parsed = 
StatusParser.parseIntegration(ProcessHandle.current(), root);
+        assertThat(parsed.chaos).isNotNull();
+        assertThat(parsed.chaos.rules).hasSize(3);
+        assertThat(parsed.chaos.verdict).isEqualTo("FAIL");
+    }
+
+    @Test
+    void theVerdictTheRulesAndTheRoutes() throws Exception {
+        chaos(CHAOS);
+        String screen = TuiTestHelper.renderToString(new ChaosTab(ctx), 160, 
40);
+
+        assertThat(screen).contains("FAIL").contains("expectations 0 met · 1 
unmet · 0 pending")
+                .contains("faults 13 in the last minute").contains("rules 2 
active · 1 waiting")
+                .contains("route orders: 2 exchanges handled by the error 
handler");
+        // the rules: target, fault, observed rate next to the rate, effect, 
note
+        
assertThat(screen).contains("http://localhost:8765/stock*";).contains("error 
status=503")
+                .contains("24% of 
25%").contains("33↩").contains("2⇩").contains("ends in 7m12s");
+        assertThat(screen).contains("r2 
v2").contains("waiting").contains("starts in 25s");
+        assertThat(screen).contains("round 1 of 3").contains("route 
poller").contains("down, back in 8s");
+        // the routes
+        
assertThat(screen).contains("ROUTE").contains("orders").contains("141");
+    }
+
+    @Test
+    void theSelectedRuleInDetail() throws Exception {
+        chaos(CHAOS);
+        String screen = TuiTestHelper.renderToString(new ChaosTab(ctx), 160, 
40);
+
+        assertThat(screen).contains("33 recovered by redelivery").contains("2 
handled by the error handler")
+                .contains("3 in flight").contains("38 exchanges");
+        assertThat(screen).contains("orders:handled<=0").contains("UNMET");
+        assertThat(screen).contains("error status 503 at send 
http://localhost:8765/stock";).contains("3F2A-1E");
+    }
+
+    @Test
+    void withoutCamelChaosOrWhenItIsOff() throws Exception {
+        String none = TuiTestHelper.renderToString(new ChaosTab(ctx), 120, 15);
+        assertThat(none).contains("camel-chaos is not in this integration: run 
it with camel run --chaos");
+
+        chaos("{\"enabled\": false, \"reason\": \"Chaos is off: it stays off 
with profile prod\"}");
+        assertThat(TuiTestHelper.renderToString(new ChaosTab(ctx), 120, 15))
+                .contains("Chaos is off: it stays off with profile prod");
+    }
+
+    @Test
+    void withoutRulesItSaysHowToAddOne() throws Exception {
+        chaos("{\"enabled\": true, \"rules\": [], \"report\": {\"verdict\": 
\"PASS\", \"final\": true, \"seed\": 7}}");
+        String screen = TuiTestHelper.renderToString(new ChaosTab(ctx), 160, 
20);
+        assertThat(screen).contains("PASS").contains("No chaos rules: add one 
with camel cmd chaos-add chaosdemo");
+    }
+
+    @Test
+    void theBadgeOfTheTitleBarAndTheMoreMenu() throws Exception {
+        assertThat(ChaosTab.badge(info)).isNull();
+        chaos(CHAOS);
+        assertThat(ChaosTab.badge(info)).isEqualTo("3 rules · FAIL");
+        chaos("{\"enabled\": true, \"rules\": [{\"id\": \"r1\", \"state\": 
\"active\", \"rule\": \"step=a error\"}],"
+              + " \"report\": {\"verdict\": \"PASS\"}}");
+        assertThat(ChaosTab.badge(info)).isEqualTo("1 rule");
+        chaos("{\"enabled\": true, \"rules\": [{\"id\": \"r1\", \"state\": 
\"ended\", \"rule\": \"step=a error\"}],"
+              + " \"report\": {\"verdict\": \"PASS\"}}");
+        assertThat(ChaosTab.badge(info)).isNull();
+        chaos("{\"enabled\": false}");
+        assertThat(ChaosTab.badge(info)).isNull();
+        chaos(CHAOS);
+        assertThat(ChaosTab.headerBadge(info).get(0).content()).isEqualTo(" 🎲 
chaos 3 rules · FAIL ");
+    }
+
+    @Test
+    void theTableAsJsonForTheTools() throws Exception {
+        chaos(CHAOS);
+        JsonObject json = new ChaosTab(ctx).getTableDataAsJson();
+        assertThat(json.getString("verdict")).isEqualTo("FAIL");
+        JsonArray rows = json.getCollection("rows");
+        assertThat(rows).hasSize(3);
+        JsonObject r1 = (JsonObject) rows.get(0);
+        assertThat(r1.getString("rate")).isEqualTo("24% of 25%");
+        assertThat(((Map<?, ?>) 
r1.get("effect")).get("recovered")).isEqualTo(33L);
+        assertThat(r1.getString("expectation")).isEqualTo("unmet");
+    }
+
+    @Test
+    void theNoteOfAnEveryNRuleCountsMatches() {
+        ChaosInfo.Rule every = new ChaosInfo.Rule();
+        every.text = "endpoint=x error rate=every:5";
+        every.nextFireIn = 3;
+        assertThat(ChaosTab.note(every)).isEqualTo("fires again in 3 matches");
+        every.nextFireIn = 1;
+        assertThat(ChaosTab.note(every)).isEqualTo("fires again in 1 match");
+    }
+
+    @Test
+    void aRuleWithoutAnIdIsShownWithoutFailing() throws Exception {
+        chaos("{\"enabled\": true, \"rules\": [{\"state\": \"active\", 
\"rule\": \"step=a error\"}],"
+              + " \"report\": {\"verdict\": \"PASS\", \"recentFaults\": 
[{\"rule\": \"r1\", \"fault\": \"error\"}]}}");
+        assertThat(TuiTestHelper.renderToString(new ChaosTab(ctx), 160, 
30)).contains("step a");
+    }
+
+    @Test
+    void sparklineAndRates() {
+        long[] perSecond = new long[60];
+        perSecond[0] = 1;
+        perSecond[59] = 4;
+        String spark = ChaosTab.sparkline(perSecond, 20);
+        assertThat(spark).hasSize(20).startsWith("▂").endsWith("█");
+        assertThat(spark.substring(1, 19).isBlank()).isTrue();
+        assertThat(ChaosTab.sparkline(new long[0], 20)).isEmpty();
+
+        assertThat(ChaosTab.shortTime(800)).isEqualTo("800ms");
+        assertThat(ChaosTab.shortTime(64_000)).isEqualTo("1m4s");
+        assertThat(ChaosTab.shortTime(120_000)).isEqualTo("2m");
+
+        ChaosInfo.Rule every = new ChaosInfo.Rule();
+        every.text = "endpoint=x error rate=every:5";
+        every.rate = 0.2;
+        every.observedRate = 0.2;
+        assertThat(ChaosTab.rate(every)).isEqualTo("20% of 1 in 5");
+        ChaosInfo.Rule all = new ChaosInfo.Rule();
+        all.text = "step=a delay duration='1s'";
+        all.rate = 1;
+        assertThat(ChaosTab.rate(all)).isEqualTo("all");
+        assertThat(all.target()).isEqualTo("step=a");
+        assertThat(ChaosTab.target(all)).isEqualTo("step a");
+        assertThat(all.fault()).isEqualTo("delay duration='1s'");
+    }
+}

Reply via email to