davsclaus commented on code in PR #29338: URL: https://github.com/apache/camel/pull/29338#discussion_r4493720635
########## dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java: ########## @@ -0,0 +1,675 @@ +/* + * 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) { + parts.add("fires again in " + r.nextFireIn); Review Comment: Thanks for the review. `nextFireIn` is not milliseconds: camel-chaos reports it as a count of matches ("The matches until a rule with rate=every:N fires again"), so `shortTime()` would print a wrong time. The bare number was unclear, though. It now reads `fires again in 3 matches` (`1 match`), fixed in 9cbcaa68363f with a test. _Claude Code on behalf of Claus Ibsen_ ########## dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ChaosTab.java: ########## @@ -0,0 +1,675 @@ +/* + * 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) { + parts.add("fires again in " + r.nextFireIn); + } + 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.equals(f.rule())) { Review Comment: Good catch, a rule without an id would throw here. Fixed as suggested in 9cbcaa68363f, with a test that renders such a rule together with a recent fault. _Claude Code on behalf of Claus Ibsen_ -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
