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 2167f428a41a CAMEL-26695: camel-jbang - chaos from where you look: !
on an endpoint, a consumer or a diagram node (#29348)
2167f428a41a is described below
commit 2167f428a41a6b3cb0db6bf3d63b52c311ef8d33
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Nov 6 17:19:32 2026 +0100
CAMEL-26695: camel-jbang - chaos from where you look: ! on an endpoint, a
consumer or a diagram node (#29348)
Signed-off-by: Claus Ibsen <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../ROOT/images/jbang/camel-tui-chaos-here.png | Bin 0 -> 349301 bytes
.../ROOT/pages/camel-jbang-tui-observe.adoc | 6 +
.../dsl/jbang/core/commands/tui/ChaosControl.java | 24 ++-
.../dsl/jbang/core/commands/tui/ChaosHere.java | 151 ++++++++++++++++++
.../jbang/core/commands/tui/ChaosRecipeForm.java | 73 ++++++++-
.../dsl/jbang/core/commands/tui/ChaosTab.java | 5 +
.../dsl/jbang/core/commands/tui/ConsumersTab.java | 24 +++
.../dsl/jbang/core/commands/tui/DiagramTab.java | 23 +++
.../dsl/jbang/core/commands/tui/EndpointsTab.java | 17 ++
.../jbang/core/commands/tui/MonitorContext.java | 3 +
.../dsl/jbang/core/commands/tui/TabRegistry.java | 17 +-
.../src/main/resources/tui/help/chaos.md | 13 ++
.../dsl/jbang/core/commands/tui/ChaosHereTest.java | 176 +++++++++++++++++++++
13 files changed, 524 insertions(+), 8 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-here.png
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-here.png
new file mode 100644
index 000000000000..15b1eab2c04e
Binary files /dev/null and
b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-chaos-here.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 c2b2a32e5b12..37467664d7db 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
@@ -272,6 +272,12 @@ values start with a default, and the rule is shown as it
is built. The last entr
+
image::jbang/camel-tui-chaos-recipes.png[Adding a chaos rule from the known
rules: the HTTP endpoint of the route is picked]
* *e* changes the selected rule, starting from the rule as it is.
+* *!* on the Endpoint tab, the Consumers tab or a node of the Diagram opens
the known rules for what is selected, with
+it filled in: for a `to` or an endpoint the routes send to, the faults of that
endpoint (an HTTP status only for an
+HTTP endpoint) and of the step; for the `from` of a route, a consumer or an
endpoint a route consumes, the faults of
+the route, and of its polls when it polls.
++
+image::jbang/camel-tui-chaos-here.png[! on the to callStock of the Diagram:
the known rules for its HTTP endpoint and its step, with the endpoint filled in]
* *x* removes the selected rule, *c* removes all rules (the report keeps what
they did), and *R* starts a new experiment:
no rules and a new report, with the same seed.
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
index 701cb2e422ed..56218a28bc28 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosControl.java
@@ -203,6 +203,28 @@ final class ChaosControl {
return false;
}
+ /**
+ * Opens the known rules for a target ({@code !} on an endpoint, a
consumer or a node of the diagram), or the rule
+ * typed as one line, starting with the target, when the catalog has no
known rules.
+ */
+ void openAt(IntegrationInfo info, ChaosHere.Target target) {
+ if (!ChaosHere.available(info)) {
+ return;
+ }
+ pid = info.pid;
+ name = info.name;
+ id = null;
+ command = Command.ADD;
+ ChaosRecipes known = knownRules();
+ ChaosRecipeForm targeted = known != null ? new ChaosRecipeForm(known,
info, target) : null;
+ if (targeted != null && !targeted.recipes().isEmpty()) {
+ form = targeted;
+ step = Step.RECIPES;
+ } else {
+ openInput(Command.ADD, target.rulePrefix());
+ }
+ }
+
private ChaosRecipes knownRules() {
if (!recipesLoaded) {
recipes = recipeSource.get();
@@ -214,7 +236,7 @@ final class ChaosControl {
private boolean handleRecipeKey(KeyEvent ke) {
switch (form.handleKey(ke)) {
case CLOSED -> close();
- case TYPE_RULE -> openInput(Command.ADD, "");
+ case TYPE_RULE -> openInput(Command.ADD, form.target() != null ?
form.target().rulePrefix() : "");
case RULE -> {
rule = form.rule();
ask(Command.ADD);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosHere.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosHere.java
new file mode 100644
index 000000000000..53872d6f11c2
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosHere.java
@@ -0,0 +1,151 @@
+/*
+ * 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 dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyEvent;
+
+/**
+ * Chaos from where you look ({@code !}): on the Endpoint tab, the Consumers
tab and a node of the Diagram, the key
+ * opens the known chaos rules (the Chaos screen, {@link ChaosRecipeForm}) for
what is selected, with it filled in: the
+ * endpoint a step sends to, the endpoint a route polls, the route, or the
step. Only when chaos is on in the
+ * integration.
+ */
+final class ChaosHere {
+
+ /**
+ * What is selected, as targets of a chaos rule.
+ *
+ * @param label what the user sees, for the title (such as {@code to
http://localhost:8765/stock})
+ * @param endpoint the endpoint a step sends to, without its query, or null
+ * @param polled the endpoint a route consumes with a scheduled poll,
without its query, or null
+ * @param route the route, or null
+ * @param step the id of the step, or null
+ */
+ record Target(String label, String endpoint, String polled, String route,
String step) {
+
+ /** The target as the start of a rule typed as one line. */
+ String rulePrefix() {
+ if (step != null) {
+ return "step=" + step + " ";
+ }
+ if (endpoint != null) {
+ return "endpoint=" + endpoint + " ";
+ }
+ if (polled != null) {
+ return "endpoint=" + polled + " ";
+ }
+ return route != null ? "route=" + route + " " : "";
+ }
+ }
+
+ private ChaosHere() {
+ }
+
+ /** Whether {@code !} works for the integration: chaos is on. */
+ static boolean available(IntegrationInfo info) {
+ return info != null && info.chaos != null && info.chaos.enabled;
+ }
+
+ /**
+ * Handles {@code !}: opens the Chaos screen for the target.
+ *
+ * @return whether the key was used
+ */
+ static boolean handleKey(KeyEvent ke, MonitorContext ctx, IntegrationInfo
info, Target target) {
+ if (!ke.isChar('!') || !available(info) || target == null ||
ctx.chaosHere == null) {
+ return false;
+ }
+ ctx.chaosHere.accept(target);
+ return true;
+ }
+
+ /** The {@code !} key in the footer, when it works. */
+ static void hint(List<Span> spans, IntegrationInfo info) {
+ if (available(info)) {
+ TuiHelper.hint(spans, "!", "chaos");
+ }
+ }
+
+ /** An endpoint of the Endpoint tab: what its routes send to, or what a
route consumes from it. */
+ static Target forEndpoint(EndpointInfo ep, IntegrationInfo info) {
+ if (ep == null || ep.uri == null) {
+ return null;
+ }
+ String uri = withoutQuery(ep.uri);
+ if ("in".equalsIgnoreCase(ep.direction)) {
+ return new Target("from " + uri, null, polled(info, ep.uri),
ep.routeId, null);
+ }
+ return new Target("to " + uri, uri, null, null, null);
+ }
+
+ /** A consumer of the Consumers tab: its route, and its endpoint when it
polls. */
+ static Target forConsumer(ConsumerInfo consumer) {
+ if (consumer == null) {
+ return null;
+ }
+ String uri = consumer.uri != null ? withoutQuery(consumer.uri) : null;
+ return new Target(
+ "route " + consumer.id + (uri != null ? " (" + uri + ")" :
""), null,
+ consumer.scheduled ? uri : null, consumer.id, null);
+ }
+
+ /**
+ * A node of a route on the Diagram: the step, and the endpoint it sends
to (the uri of the node, else the producer
+ * of the step); the {@code from} of a route is the route, and its
endpoint when it polls.
+ */
+ static Target forDiagramNode(String nodeId, String type, String uri,
String routeId, IntegrationInfo info) {
+ if ("from".equals(type) || nodeId == null) {
+ if (routeId == null) {
+ return null;
+ }
+ String polled = null;
+ for (ConsumerInfo c : info.consumers) {
+ if (routeId.equals(c.id) && c.scheduled && c.uri != null) {
+ polled = withoutQuery(c.uri);
+ }
+ }
+ return new Target("route " + routeId, null, polled, routeId, null);
+ }
+ String endpoint = uri != null && !uri.isBlank() ? withoutQuery(uri) :
null;
+ for (ProducerInfo p : endpoint == null ? info.producers :
List.<ProducerInfo> of()) {
+ if (nodeId.equals(p.stepId) && p.uri != null) {
+ endpoint = withoutQuery(p.uri);
+ }
+ }
+ // a step offers the faults of its endpoint and its own; the route is
the target of the from of the route
+ return new Target(
+ type + " " + nodeId + (endpoint != null ? " (" + endpoint +
")" : ""), endpoint, null, null,
+ nodeId);
+ }
+
+ private static String polled(IntegrationInfo info, String uri) {
+ for (ConsumerInfo c : info.consumers) {
+ if (c.scheduled && uri.equals(c.uri)) {
+ return withoutQuery(uri);
+ }
+ }
+ return null;
+ }
+
+ static String withoutQuery(String uri) {
+ int q = uri.indexOf('?');
+ return q > 0 ? uri.substring(0, q) : uri;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipeForm.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipeForm.java
index 4459cae0814e..6fef3adeb031 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipeForm.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosRecipeForm.java
@@ -98,6 +98,8 @@ final class ChaosRecipeForm {
private final List<ChaosRecipes.Recipe> recipes;
private final IntegrationInfo info;
+ // what the form was opened for ({@code !} on an endpoint, a consumer or a
node of the diagram), or null
+ private final ChaosHere.Target target;
// the index in the list (the recipes, then the entry that types the rule)
private int selected;
private boolean inForm;
@@ -106,8 +108,64 @@ final class ChaosRecipeForm {
private String rule;
ChaosRecipeForm(ChaosRecipes recipes, IntegrationInfo info) {
- this.recipes = recipes.recipes();
+ this(recipes, info, null);
+ }
+
+ /** The known rules for a target: only the recipes it fills in, with it
filled in. */
+ ChaosRecipeForm(ChaosRecipes recipes, IntegrationInfo info,
ChaosHere.Target target) {
+ this.recipes = target == null
+ ? recipes.recipes()
+ : recipes.recipes().stream().filter(r -> applies(r,
target)).toList();
this.info = info;
+ this.target = target;
+ }
+
+ ChaosHere.Target target() {
+ return target;
+ }
+
+ List<ChaosRecipes.Recipe> recipes() {
+ return recipes;
+ }
+
+ /** Whether the target fills in every placeholder of the recipe that names
an endpoint, a route or a step. */
+ static boolean applies(ChaosRecipes.Recipe recipe, ChaosHere.Target
target) {
+ boolean any = false;
+ for (ChaosRecipes.Placeholder p : recipe.placeholders()) {
+ if (isTargetKind(p)) {
+ if (preset(p, target) == null) {
+ return false;
+ }
+ any = true;
+ }
+ }
+ return any;
+ }
+
+ private static boolean isTargetKind(ChaosRecipes.Placeholder p) {
+ String from = p.pickFrom();
+ return "producers".equals(from) || "pollingConsumers".equals(from) ||
"routes".equals(from)
+ || "steps".equals(from);
+ }
+
+ /** The value of the target for a placeholder, or null when the target
does not fill it in. */
+ static String preset(ChaosRecipes.Placeholder p, ChaosHere.Target target) {
+ if (target == null || p.pickFrom() == null) {
+ return null;
+ }
+ return switch (p.pickFrom()) {
+ case "producers" -> target.endpoint() != null &&
ofScheme(target.endpoint(), p.schemes())
+ ? target.endpoint() : null;
+ case "pollingConsumers" -> target.polled();
+ case "routes" -> target.route();
+ case "steps" -> target.step();
+ default -> null;
+ };
+ }
+
+ private static boolean ofScheme(String uri, List<String> schemes) {
+ int colon = uri.indexOf(':');
+ return schemes.isEmpty() || colon > 0 &&
schemes.contains(uri.substring(0, colon));
}
boolean inForm() {
@@ -151,7 +209,15 @@ final class ChaosRecipeForm {
private void openForm(ChaosRecipes.Recipe recipe) {
fields.clear();
for (ChaosRecipes.Placeholder p : recipe.placeholders()) {
- fields.add(new Field(p, ChaosRecipes.candidates(p, info)));
+ Field field = new Field(p, ChaosRecipes.candidates(p, info));
+ String preset = preset(p, target);
+ if (preset != null) {
+ // what the form was opened for
+ field.input.setText(preset);
+ field.input.moveCursorToEnd();
+ field.choice = field.choices.indexOf(preset);
+ }
+ fields.add(field);
}
focus = 0;
inForm = true;
@@ -219,7 +285,8 @@ final class ChaosRecipeForm {
Rect popup = DialogHelper.centered(area, width, height);
frame.renderWidget(Clear.INSTANCE, popup);
Block outer =
Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).borderStyle(Theme.warning())
- .title(" Add a chaos rule to " + info.name + " ").build();
+ .title(" Add a chaos rule to " + info.name + (target != null ?
" at " + target.label() : "") + " ")
+ .build();
frame.renderWidget(outer, popup);
Rect inner = outer.inner(popup);
List<Rect> cols = Layout.horizontal()
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
index 38a4e4ae630e..2127e8154b48 100644
---
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
@@ -72,6 +72,11 @@ class ChaosTab extends AbstractTableTab {
return control;
}
+ /** Opens the known rules for a target ({@code !} on another screen). */
+ void openAt(ChaosHere.Target target) {
+ control.openAt(ctx.findSelectedIntegration(), target);
+ }
+
@Override
protected boolean handleTabKeyEvent(KeyEvent ke) {
IntegrationInfo info = ctx.findSelectedIntegration();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumersTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumersTab.java
index edcdb8744e22..4f504c25bc0c 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumersTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumersTab.java
@@ -26,6 +26,7 @@ import dev.tamboui.layout.Rect;
import dev.tamboui.style.Style;
import dev.tamboui.terminal.Frame;
import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyEvent;
import dev.tamboui.tui.event.MouseEvent;
import dev.tamboui.widgets.block.Block;
import dev.tamboui.widgets.block.BorderType;
@@ -297,6 +298,29 @@ class ConsumersTab extends AbstractTableTab {
return new SelectionContext("table", items, sel != null ? sel : -1,
items.size(), "Consumers");
}
+ @Override
+ protected boolean handleTabKeyEvent(KeyEvent ke) {
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ return ke.isChar('!') && ChaosHere.handleKey(ke, ctx, info,
ChaosHere.forConsumer(selectedConsumer(info)));
+ }
+
+ @Override
+ public void renderFooter(List<Span> spans) {
+ super.renderFooter(spans);
+ ChaosHere.hint(spans, ctx.findSelectedIntegration());
+ }
+
+ /** The consumer selected in the table, as sorted. */
+ private ConsumerInfo selectedConsumer(IntegrationInfo info) {
+ Integer sel = tableState.selected();
+ if (info == null || sel == null) {
+ return null;
+ }
+ List<ConsumerInfo> sorted = new ArrayList<>(info.consumers);
+ sorted.sort(this::sortConsumer);
+ return sel >= 0 && sel < sorted.size() ? sorted.get(sel) : null;
+ }
+
@Override
public String description() {
return "Consumer statistics (polling and event-driven consumers)";
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
index b643834187ca..535779af9f0e 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
@@ -38,6 +38,7 @@ import dev.tamboui.widgets.block.BorderType;
import dev.tamboui.widgets.block.Borders;
import dev.tamboui.widgets.paragraph.Paragraph;
import org.apache.camel.dsl.jbang.core.commands.ai.ProjectCapabilities;
+import org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
@@ -80,6 +81,11 @@ class DiagramTab extends AbstractDiagramTab {
return true;
}
+ // !: the known chaos rules for the selected node (or route)
+ if (diagram.isShowDiagram() && ke.isChar('!') && handleChaosKey(ke)) {
+ return true;
+ }
+
// v moves through the zoom levels: architecture, topology, route
(CAMEL-25147)
if (diagram.isShowDiagram() && ke.isCharIgnoreCase('v')) {
cycleLevel();
@@ -681,6 +687,22 @@ class DiagramTab extends AbstractDiagramTab {
area);
}
+ /** {@code !} on the diagram: the selected node of a route, or the
selected route of the topology. */
+ private boolean handleChaosKey(KeyEvent ke) {
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ if (!ChaosHere.available(info)) {
+ return false;
+ }
+ RouteDiagramWidget.EipNodeBox box = topologyMode ? null :
diagram.getSelectedEipNodeBox();
+ // the endpoint of a to, from the model of the route
+ String uri = box != null && box.layoutNode() != null &&
box.layoutNode().treeNode != null
+ ? box.layoutNode().treeNode.info.uri : null;
+ ChaosHere.Target target = ChaosHere.forDiagramNode(
+ box != null ? box.nodeId() : null, box != null ? box.type() :
null, uri, diagram.getSelectedRouteId(),
+ info);
+ return ChaosHere.handleKey(ke, ctx, info, target);
+ }
+
@Override
public void renderFooter(List<Span> spans) {
if (renderGotoNodeFooter(spans)) {
@@ -716,6 +738,7 @@ class DiagramTab extends AbstractDiagramTab {
} else {
diagram.renderFooterHints(spans);
}
+ ChaosHere.hint(spans, ctx.findSelectedIntegration());
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTab.java
index c2a5483c2a0a..b8b8135cc2b8 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTab.java
@@ -119,6 +119,10 @@ class EndpointsTab extends AbstractTableTab {
@Override
protected boolean handleTabKeyEvent(KeyEvent ke) {
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ if (ke.isChar('!') && ChaosHere.handleKey(ke, ctx, info,
ChaosHere.forEndpoint(selectedEndpoint(info), info))) {
+ return true;
+ }
if (ke.isKey(KeyCode.TAB) && panelMode == PANEL_DETAIL) {
detailFocused = !detailFocused;
return true;
@@ -418,6 +422,19 @@ class EndpointsTab extends AbstractTableTab {
if (panelMode == PANEL_DETAIL) {
hint(spans, "Tab", detailFocused ? "table" : "detail");
}
+ ChaosHere.hint(spans, ctx.findSelectedIntegration());
+ }
+
+ /** The endpoint selected in the table, as filtered and sorted. */
+ private EndpointInfo selectedEndpoint(IntegrationInfo info) {
+ Integer sel = tableState.selected();
+ if (info == null || sel == null) {
+ return null;
+ }
+ List<EndpointInfo> sorted = new ArrayList<>(info.endpoints);
+ sorted.removeIf(ep -> !matchesFilter(ep));
+ sorted.sort(this::sortEndpoint);
+ return sel >= 0 && sel < sorted.size() ? sorted.get(sel) : null;
}
int getFilter() {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
index 28cda19a146b..d89ad2bb68bf 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
@@ -30,6 +30,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import java.util.function.BooleanSupplier;
+import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -91,6 +92,8 @@ class MonitorContext {
/** Collector behind the Ollama tab and the tui_get_ollama tool; set by
CamelMonitor at startup. */
OllamaMonitor ollamaMonitor;
BiConsumer<String, Boolean> notificationCallback;
+ // opens the Chaos screen with the known rules for a target ({@code !} on
an endpoint, a consumer or a diagram node)
+ Consumer<ChaosHere.Target> chaosHere;
BiConsumer<String, String> openMarkdownCallback;
/** Opens markdown in the doc viewer scrolled to a heading: title,
markdown, heading (may be null). */
MarkdownOpener openMarkdownAtCallback;
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 807a2740071c..e8bf01021d52 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
@@ -166,10 +166,7 @@ class TabRegistry {
ctx, dataService.metrics(), dataService.stoppingPids(),
resetIntegrationTabState);
- sqlTraceTab.setEditSqlAction(sql -> {
- selectMoreTab(moreTabIndex("SQL Query"));
- sqlQueryTab.setInputValue("sql", sql);
- });
+ wireCrossTabActions(ctx);
// Single source of truth for the More submenu: icon, programmatic
name, mnemonic label, tab instance, and group.
// Tabs are ordered by group (Routing, Observability, AI, Data, JVM,
Project), alphabetically within each group.
@@ -332,6 +329,18 @@ class TabRegistry {
return true;
}
+ /** The actions of a tab that open another tab: SQL Trace edits its query
in SQL Query, ! opens Chaos. */
+ private void wireCrossTabActions(MonitorContext ctx) {
+ ctx.chaosHere = target -> {
+ selectMoreTab(moreTabIndex("Chaos"));
+ chaosTab.openAt(target);
+ };
+ sqlTraceTab.setEditSqlAction(sql -> {
+ selectMoreTab(moreTabIndex("SQL Query"));
+ sqlQueryTab.setInputValue("sql", sql);
+ });
+ }
+
void selectMoreTab(int index) {
callbacks.selectMorePopupEntry(index);
activeMoreTab = index >= 0 && index < moreTabs.size() ?
moreTabs.get(index).tab() : null;
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
index 456b4aa87446..47bbbdb4d9fc 100644
---
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
@@ -72,3 +72,16 @@ the rule as `camel.chaos.rules` is checked: a rule that is
not valid comes
back with the reason, and a rule that matches nothing in the integration
(often a typo) with a warning. Press **e** in that dialog to change the
rule.
+
+## Chaos from where you look
+
+Press **!** on the Endpoint tab, the Consumers tab or a node of the
+Diagram to open the known rules for what is selected, with it filled in:
+
+- a `to` (or an endpoint the routes send to): the faults of that endpoint,
+ such as an HTTP status for an HTTP endpoint, and the faults of the step;
+- the `from` of a route, a consumer, or an endpoint a route consumes: the
+ route stalls, flaps or gets corrupt messages, and the polls fail when it
+ polls.
+
+Only when chaos is on in the integration.
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosHereTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosHereTest.java
new file mode 100644
index 000000000000..f53b4a8608b9
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosHereTest.java
@@ -0,0 +1,176 @@
+/*
+ * 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.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.apache.camel.util.json.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;
+
+/**
+ * Chaos from where you look ({@link ChaosHere}): the target of {@code !} on
an endpoint, a consumer and a node of the
+ * diagram, and the known rules the Chaos screen offers for it, filled in.
+ */
+class ChaosHereTest {
+
+ private IntegrationInfo info;
+ private MonitorContext ctx;
+ private ChaosRecipes recipes;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ Theme.resetForTesting();
+ info = ChaosRecipesTest.chaosdemo();
+ info.chaos = ChaosInfo.parse((JsonObject) Jsoner.deserialize("""
+ {"enabled": true, "rules": [], "report": {"verdict": "PASS",
"final": true, "seed": 42}}
+ """));
+ info.producers.get(0).stepId = "callStock";
+ ctx = new MonitorContext(new AtomicReference<>(List.of(info)), new
AtomicReference<>(List.of()));
+ ctx.selectedPid = "1234";
+ recipes = ChaosRecipes.parse(ChaosRecipesTest.MODEL);
+ }
+
+ private static List<String> ids(ChaosRecipeForm form) {
+ return form.recipes().stream().map(ChaosRecipes.Recipe::id).toList();
+ }
+
+ @Test
+ void theTargetOfAnEndpointAConsumerAndADiagramNode() {
+ EndpointInfo out = new EndpointInfo();
+ out.uri = "http://localhost:8765/stock?bridgeEndpoint=true";
+ out.direction = "out";
+ ChaosHere.Target send = ChaosHere.forEndpoint(out, info);
+ assertThat(send.endpoint()).isEqualTo("http://localhost:8765/stock");
+
assertThat(send.rulePrefix()).isEqualTo("endpoint=http://localhost:8765/stock
");
+
+ EndpointInfo in = new EndpointInfo();
+ in.uri = "file://inbox?delay=1000";
+ in.direction = "in";
+ in.routeId = "poller";
+ ChaosHere.Target from = ChaosHere.forEndpoint(in, info);
+ assertThat(from.route()).isEqualTo("poller");
+ assertThat(from.polled()).isEqualTo("file://inbox");
+
+ ConsumerInfo timer = info.consumers.get(0);
+ timer.id = "orders";
+ ChaosHere.Target route = ChaosHere.forConsumer(timer);
+ assertThat(route.route()).isEqualTo("orders");
+ assertThat(route.polled()).isNull();
+
+ // a to of the diagram: its step, and the endpoint it sends to (the
producer of the step)
+ ChaosHere.Target node = ChaosHere.forDiagramNode("callStock", "to",
null, "orders", info);
+ assertThat(node.step()).isEqualTo("callStock");
+ assertThat(node.endpoint()).isEqualTo("http://localhost:8765/stock");
+ assertThat(node.route()).isNull();
+ assertThat(node.label()).isEqualTo("to callStock
(http://localhost:8765/stock)");
+ // the uri of the node, when the diagram has it
+ assertThat(ChaosHere.forDiagramNode("toShipping", "to",
"seda://shipping?size=10", "orders", info).endpoint())
+ .isEqualTo("seda://shipping");
+ // the from of a route is the route
+ assertThat(ChaosHere.forDiagramNode(null, "from", null, "orders",
info).route()).isEqualTo("orders");
+ }
+
+ @Test
+ void theKnownRulesThatFitTheTargetWithItFilledIn() {
+ ChaosHere.Target http = new ChaosHere.Target("to stock",
"http://localhost:8765/stock", null, null, null);
+ ChaosRecipeForm form = new ChaosRecipeForm(recipes, info, http);
+ // the HTTP status and the missing header fit an HTTP endpoint; the
route and step recipes do not
+ assertThat(ids(form)).containsExactly("http-error", "missing-header");
+
+ ChaosHere.Target seda = new ChaosHere.Target("to shipping",
"seda://shipping", null, null, null);
+ // no HTTP status for seda
+ assertThat(ids(new ChaosRecipeForm(recipes, info,
seda))).containsExactly("missing-header");
+
+ ChaosHere.Target route = new ChaosHere.Target("route orders", null,
null, "orders", null);
+ assertThat(ids(new ChaosRecipeForm(recipes, info,
route))).containsExactly("stall", "corrupt-entry");
+
+ ChaosHere.Target polled = new ChaosHere.Target("route poller", null,
"file://inbox", "poller", null);
+ assertThat(ids(new ChaosRecipeForm(recipes, info,
polled))).containsExactly("stall", "corrupt-entry",
+ "poll-error");
+
+ ChaosHere.Target step = new ChaosHere.Target("to callStock",
"http://localhost:8765/stock", null, null, "callStock");
+ assertThat(ids(new ChaosRecipeForm(recipes, info,
step))).containsExactly("http-error", "missing-header",
+ "step-error");
+ }
+
+ @Test
+ void bangOnTheEndpointTabOpensTheChaosScreenForTheEndpoint() {
+ EndpointInfo out = new EndpointInfo();
+ out.uri = "http://localhost:8765/stock";
+ out.direction = "out";
+ info.endpoints.add(out);
+ List<ChaosHere.Target> opened = new ArrayList<>();
+ ctx.chaosHere = opened::add;
+ EndpointsTab endpoints = new EndpointsTab(ctx, new MetricsCollector());
+ TuiTestHelper.renderToString(endpoints, 160, 30);
+ endpoints.navigateDown();
+ endpoints.handleKeyEvent(KeyEvent.ofChar('!', KeyModifiers.NONE));
+
+ assertThat(opened).hasSize(1);
+
assertThat(opened.get(0).endpoint()).isEqualTo("http://localhost:8765/stock");
+ List<Span> footer = new ArrayList<>();
+ endpoints.renderFooter(footer);
+ assertThat(footer.stream().map(Span::content).reduce("",
String::concat)).contains("chaos");
+
+ // without chaos there is no !
+ info.chaos = null;
+ endpoints.handleKeyEvent(KeyEvent.ofChar('!', KeyModifiers.NONE));
+ assertThat(opened).hasSize(1);
+ }
+
+ @Test
+ void theChaosScreenOpensTheFormFilledInForTheTarget() {
+ List<JsonObject> requests = new ArrayList<>();
+ ctx.confirmActions = true;
+ ChaosTab tab = new ChaosTab(ctx, new ChaosControl(ctx, request -> {
+ requests.add(request);
+ return new JsonObject();
+ }, () -> recipes));
+ tab.openAt(new ChaosHere.Target("route orders", null, null, "orders",
null));
+ assertThat(TuiTestHelper.renderToString(tab, 160, 40)).contains("Add a
chaos rule to chaosdemo at route orders")
+ .contains("The route stalls").doesNotContain("Returns an
error");
+
+ tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+ tab.handleKeyEvent(KeyEvent.ofChar('y', KeyModifiers.NONE));
+ assertThat(requests.get(0).getString("rule")).isEqualTo("route=orders
stall duration=20s");
+ }
+
+ @Test
+ void withoutAKnownRuleForTheTargetTheRuleIsTypedFromIt() {
+ ChaosTab tab = new ChaosTab(ctx, new ChaosControl(ctx, request -> new
JsonObject(), () -> recipes));
+ // no recipe has a placeholder for a component pattern: the typed line
starts with the target
+ tab.openAt(new ChaosHere.Target("to log", "log:audit", null, null,
null));
+ assertThat(tab.control().step()).isEqualTo(ChaosControl.Step.RECIPES);
+
assertThat(ids(tab.control().form())).containsExactly("missing-header");
+
+ ChaosTab plain = new ChaosTab(ctx, new ChaosControl(ctx, request ->
new JsonObject()));
+ plain.openAt(new ChaosHere.Target("step enrich", null, null, null,
"enrich"));
+ assertThat(plain.control().step()).isEqualTo(ChaosControl.Step.INPUT);
+ assertThat(TuiTestHelper.renderToString(plain, 160,
40)).contains("step=enrich");
+ }
+}