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'");
+ }
+}