This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch tui-not-responding in repository https://gitbox.apache.org/repos/asf/camel.git
commit c35c076297003f064fa475e483e898191b8da23a Author: Claus Ibsen <[email protected]> AuthorDate: Mon Nov 9 23:36:04 2026 +0100 chore: camel-jbang - an integration that wrote no status for 15 seconds says so, instead of running at its last rate An integration writes its status file every second. When the JVM is stuck (a full heap where the garbage collector runs all the time, a debugger that suspended it) or its CLI connector stopped (CAMEL-26794), the TUI kept showing it as Running at the rate of that moment, and the charts went on with that rate. New StatusAge: the time the status file was last written is kept on the integration; one older than 15 seconds while the process runs is stale. Then the Overview STATUS is "No status" and MSG/S "-", the About header says "no status", and its Watch line since when, with what the CPU of the process since says (busy: the garbage collector; idle: a debugger), and F10 to stop or kill it. The charts and the Health strip add nothing from a status of then. For tools: the Overview row has noStatusSince and cpuPercent and no throughput, and camel_control stop says the stop may not reach it. camel_control stop-all says the user confirms it in the dialog, as it does not stop anything itself. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../ROOT/pages/camel-jbang-tui-observe.adoc | 4 + .../dsl/jbang/core/commands/tui/AboutView.java | 26 ++-- .../core/commands/tui/DataRefreshService.java | 33 +++-- .../jbang/core/commands/tui/IntegrationInfo.java | 7 + .../dsl/jbang/core/commands/tui/McpFacade.java | 6 +- .../dsl/jbang/core/commands/tui/OverviewTab.java | 17 +-- .../dsl/jbang/core/commands/tui/StatusAge.java | 141 ++++++++++++++++++++ .../src/main/resources/tui/help/overview.md | 8 +- .../dsl/jbang/core/commands/tui/AboutViewTest.java | 19 +++ .../commands/tui/DataRefreshMembershipTest.java | 20 ++- .../dsl/jbang/core/commands/tui/StatusAgeTest.java | 145 +++++++++++++++++++++ 11 files changed, 395 insertions(+), 31 deletions(-) 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 e72d184e60fc..cf0afee894e0 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 @@ -36,6 +36,10 @@ over the last 5 minutes, coloured as on the Timeline, with when the worst happen allows, and on a tall screen up to two rows high, with room around the panels. *+* and *-* zoom between 5, 2 and 1 minutes (zooming in centres on the last trouble, so you see what the other routes did then), and *Left* and *Right* move a zoomed window. *4* opens the Timeline for each second. +* *Watch* has the errors to look at. An integration that wrote no status for 15 seconds, while its process runs, says +since when instead: its numbers are of then. Its CPU tells why: busy when its heap is full and the garbage collector runs +all the time, idle when a debugger holds it. A stop may not reach it, and *F10* kills it. The Overview table shows it +as `No status`. `camel get about` shows the same from the command line (see xref:camel-jbang-diagnostics.adoc#_about[Diagnostics]). diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java index 9d83e720e76d..630723f681ac 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java @@ -596,8 +596,9 @@ final class AboutView { String p = purpose; String pFrom = purposeFrom; List<Line> head = new ArrayList<>(); - head.add(Line.from(Span.raw(" "), Span.raw(meta(info, o)), - Span.styled(info.vanishing ? " ○ stopped" : " ● running", + head.add(Line.from(Span.raw(" "), Span.raw(meta(info, o)), StatusAge.stale(info) + ? Span.styled(" ✗ no status", Theme.error()) + : Span.styled(info.vanishing ? " ○ stopped" : " ● running", info.vanishing ? Theme.muted() : Theme.success()))); if (p != null) { for (String l : wrap(p, Math.max(20, width - 8), 2)) { @@ -959,7 +960,7 @@ final class AboutView { // the exchanges of the route it starts over the last minutes, and the rate now long[] perSecond = e.route() != null ? routePerSecond(info, e.route()) : null; if (perSecond != null) { - in.add(sparkLine(" ", perSecond, spark)); + in.add(sparkLine(" ", perSecond, spark, StatusAge.stale(info))); inTargets.add(null); } } @@ -1004,7 +1005,7 @@ final class AboutView { if (perSecond != null) { // the sparkline in what " from <route> " and the rate leave of the column int room = outWidth - 9 - s.route().length() - 2 - 8; - from.addAll(sparkLine(" ", perSecond, Math.max(4, Math.min(spark, room))) + from.addAll(sparkLine(" ", perSecond, Math.max(4, Math.min(spark, room)), StatusAge.stale(info)) .spans()); } out.add(Line.from(from)); @@ -1036,13 +1037,16 @@ final class AboutView { return null; } - /** The values as a sparkline and the rate now, or only idle when there was nothing. */ - private static Line sparkLine(String indent, long[] values, int width) { + /** + * The values as a sparkline and the rate now, or only idle when there was nothing; the rate is not known when the + * integration wrote no status of late. + */ + private static Line sparkLine(String indent, long[] values, int width, boolean noStatus) { if (Arrays.stream(values).allMatch(v -> v <= 0)) { - return Line.from(Span.raw(indent), Span.styled("idle", Theme.muted())); + return Line.from(Span.raw(indent), Span.styled(noStatus ? "-" : "idle", Theme.muted())); } return Line.from(Span.raw(indent), Span.styled(spark(values, width), Theme.info()), - Span.styled(" " + rate(values), Theme.muted())); + Span.styled(" " + (noStatus ? "-" : rate(values)), Theme.muted())); } /** The sparklines of In fill its column, between {@value #SPARK_WIDTH} and {@value #MAX_SPARK_WIDTH} wide. */ @@ -1118,7 +1122,11 @@ final class AboutView { lines.add(happened(info, width)); List<Span> watch = new ArrayList<>(); watch.add(Span.styled(" Watch ", Theme.muted())); - if (info.errorCount > 0) { + List<Span> noStatus = StatusAge.watch(info); + if (!noStatus.isEmpty()) { + // its numbers are of when it stopped writing its status + watch.addAll(noStatus); + } else if (info.errorCount > 0) { // errors that stopped a minute ago are history: the line says so, and calms down. A minute, not the 5 of // Last 5m and the strips (which still show them): a fix that reloads should not stay red for long long lastError = lastError(info); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java index a1a8f13fe86b..8c2910d0c956 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java @@ -323,9 +323,12 @@ class DataRefreshService { continue; } IntegrationInfo info = StatusParser.parseIntegration(ph, root); + if (info != null) { + info.statusWrittenAt = writtenAt(pid); + } if (info != null && ctx != null) { ctx.chaosMarks.record(info); - ctx.timeline.record(info, writtenAt(pid)); + ctx.timeline.record(info, info.statusWrittenAt); mergeTimelineFile(pid); timelineLog.read(info.pid, logFile(info), ctx.timeline); } @@ -334,16 +337,10 @@ class DataRefreshService { if (prev != null) { info.errors = prev.errors; } + StatusAge.observeCpu(info, prev, ph, now); rates.fill(info, now); infos.add(info); - metrics.updateThroughputHistory(info); - metrics.updateEndpointHistory(info); - metrics.updateServiceHistory(info); - metrics.updateCbHistory(info); - metrics.updateHeapHistory(info); - if (ctx != null) { - ctx.healthHistory.record(info, System.currentTimeMillis() / 1000); - } + recordHistories(info); metrics.updateLoadMetrics(ph, info); detectReload(info); } @@ -911,6 +908,24 @@ class DataRefreshService { return msg; } + /** + * The charts and strips of the integration go on by a second: from a status of late only, as one of then would draw + * its last rate on and on. + */ + void recordHistories(IntegrationInfo info) { + if (StatusAge.stale(info)) { + return; + } + metrics.updateThroughputHistory(info); + metrics.updateEndpointHistory(info); + metrics.updateServiceHistory(info); + metrics.updateCbHistory(info); + metrics.updateHeapHistory(info); + if (ctx != null) { + ctx.healthHistory.record(info, System.currentTimeMillis() / 1000); + } + } + private List<Long> findPids(String name) { return TuiHelper.findPids(name, statusFileResolver); } 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 bc5304001ff8..a559de4b30ce 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 @@ -98,6 +98,13 @@ class IntegrationInfo { int threadCount; int peakThreadCount; boolean vanishing; + // when it last wrote its status file (epoch millis), 0 when not known; StatusAge tells one that stopped writing it + long statusWrittenAt; + // while it writes no status: the CPU time of its process, when it was read (a MonotonicClock time), and the CPU + // since the read before in percent of one core (-1 not known yet) + long cpuMillis = -1; + long cpuReadAt; + double cpuPercent = -1; // when it was found gone, a MonotonicClock time: it fades out from then long vanishStart; final List<RouteInfo> routes = new ArrayList<>(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java index 2486b66e6d78..76c4295defea 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java @@ -1726,7 +1726,8 @@ class McpFacade { } if ("stop-all".equals(action)) { bridge.stopAll(); - return "Stopping all processes"; + // the user answers the dialog: the processes stop when they confirm it + return "Asked the user to stop all processes: they confirm it in the Stop All dialog"; } if (nameOrPid != null && !nameOrPid.isBlank()) { String pid = findPidByNameOrPid(nameOrPid.trim()); @@ -1801,7 +1802,8 @@ class McpFacade { } case "stop" -> { bridge.stopProcess(false); - yield "Stopping " + name; + String stuck = StatusAge.stopAnswer(ctx.findSelectedIntegration()); + yield stuck != null ? stuck : "Stopping " + name; } case "kill" -> { bridge.stopProcess(true); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java index aaad494296c4..01f9c21a214d 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java @@ -526,7 +526,7 @@ class OverviewTab extends AbstractTab { } Line nameLine = Line.from(nameSpans); Style failStyle = info.failed > 0 ? Theme.error().bold() : Style.EMPTY; - String throughputDisplay = info.throughput; + String throughputDisplay = StatusAge.stale(info) ? "-" : info.throughput; if (throughputDisplay == null || "0.00".equals(throughputDisplay)) { LinkedList<Long> tpHist = throughputHistory.get(info.pid); if (tpHist != null && !tpHist.isEmpty()) { @@ -678,13 +678,12 @@ class OverviewTab extends AbstractTab { } else if ("Running".equals(stateText) && info.routeStarted == 0 && info.routeTotal > 0) { stateText = "Stopped"; } - if (info.reloadError != null && !"Stopping".equals(stateText)) { - // its last reload failed, as camel ps shows it; the info panel says why - stateText = "Error"; - } + // its last reload failed, as camel ps shows it (the info panel says why); or it wrote no status of late + stateText = StatusAge.state(info, + info.reloadError != null && !"Stopping".equals(stateText) ? "Error" : stateText); Style statusStyle = switch (stateText) { case "Started", "Running" -> Theme.success(); - case "Stopped", "Error" -> Theme.error(); + case "Stopped", "Error", StatusAge.NO_STATUS -> Theme.error(); default -> Theme.warning(); }; @@ -709,7 +708,7 @@ class OverviewTab extends AbstractTab { nameSpans.add(Span.styled(" " + TuiIcons.README, Style.EMPTY)); } Line nameLine = TuiHelper.fitLine(nameSpans, nameColumn); - String throughputDisplay = info.throughput; + String throughputDisplay = StatusAge.stale(info) ? "-" : info.throughput; if (throughputDisplay == null || "0.00".equals(throughputDisplay)) { LinkedList<Long> tpHist = throughputHistory.get(info.pid); if (tpHist != null && !tpHist.isEmpty()) { @@ -1633,6 +1632,7 @@ class OverviewTab extends AbstractTab { if (info.reloadError != null) { row.put("reloadError", info.reloadError); } + StatusAge.describe(info, row); row.put("ready", info.ready); // info.uptime holds the process start time (epoch millis), which the screen shows as an elapsed // duration; export the same duration plus the raw values with unambiguous names so an AI reading @@ -1645,7 +1645,8 @@ class OverviewTab extends AbstractTab { row.put("exchangesTotal", info.exchangesTotal); row.put("failed", info.failed); row.put("inflight", info.inflight); - row.put("throughput", info.throughput); + // its rate now is not known without a status of late + row.put("throughput", StatusAge.stale(info) ? null : info.throughput); row.put("routeStarted", info.routeStarted); row.put("routeTotal", info.routeTotal); row.put("meanTime", info.meanTime); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusAge.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusAge.java new file mode 100644 index 000000000000..df9888bbc817 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusAge.java @@ -0,0 +1,141 @@ +/* + * 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.time.Duration; +import java.time.Instant; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import dev.tamboui.text.Span; +import org.apache.camel.util.TimeUtils; +import org.apache.camel.util.json.JsonObject; + +/** + * An integration that wrote no status of late. It writes its status file every second; when it has not for a while, and + * its process is still there, the numbers the TUI has are those of that moment: the JVM is stuck (a heap that is full, + * where the garbage collector runs all the time; a debugger that suspended it), or its CLI connector stopped + * (CAMEL-26794). The screens then say so instead of showing it running at its last rate. + */ +final class StatusAge { + + /** The state of an integration without a status of late, in the STATUS column. */ + static final String NO_STATUS = "No status"; + + // the status is written every second (100 ms while debugging), and a pause of the garbage collector takes seconds + static final long AFTER_MILLIS = 15_000; + // the CPU of a JVM that is busy without a status: the garbage collector, or a loop + private static final double BUSY_CPU_PERCENT = 50; + private static final DateTimeFormatter CLOCK = DateTimeFormatter.ofPattern("HH:mm:ss", Locale.US); + + private StatusAge() { + } + + /** Whether the integration wrote no status for a while, at the time now (epoch millis). */ + static boolean stale(IntegrationInfo info, long now) { + return !info.phantom && !info.vanishing && info.statusWrittenAt > 0 && now - info.statusWrittenAt > AFTER_MILLIS; + } + + static boolean stale(IntegrationInfo info) { + return stale(info, System.currentTimeMillis()); + } + + /** The state to show: {@link #NO_STATUS} when it wrote no status of late, else the state it has. */ + static String state(IntegrationInfo info, String state) { + return stale(info) ? NO_STATUS : state; + } + + /** + * The CPU the process used since the last read, in percent of one core, while it writes no status: the JVM that is + * busy (the garbage collector) is told from the one that is held (a debugger, a deadlock). + * + * @param now a MonotonicClock time + */ + static void observeCpu(IntegrationInfo info, IntegrationInfo previous, ProcessHandle process, long now) { + if (!stale(info)) { + return; + } + info.cpuMillis = process.info().totalCpuDuration().map(Duration::toMillis).orElse(-1L); + info.cpuReadAt = now; + if (previous == null) { + return; + } + if (info.cpuMillis >= 0 && previous.cpuMillis >= 0 && now > previous.cpuReadAt) { + info.cpuPercent = 100.0 * (info.cpuMillis - previous.cpuMillis) / (now - previous.cpuReadAt); + } else { + info.cpuPercent = previous.cpuPercent; + } + } + + /** The Watch line of About for an integration that wrote no status of late, else none. */ + static List<Span> watch(IntegrationInfo info) { + List<Span> spans = new ArrayList<>(); + if (!stale(info)) { + return spans; + } + spans.add(Span.styled("✗ no status since " + clock(info.statusWrittenAt), Theme.error())); + spans.add(Span.styled(" (" + TimeUtils.printSince(info.statusWrittenAt) + " ago)", Theme.muted())); + String why = why(info.cpuPercent); + if (why != null) { + spans.add(Span.styled(": " + why, Theme.error())); + } + spans.add(Span.styled(" · its numbers are of then, and a stop may not reach it", Theme.muted())); + spans.add(Span.raw(" ")); + spans.add(Span.styled(" F10 ", Theme.hintKey())); + spans.add(Span.raw(" stop or kill")); + return spans; + } + + /** What the CPU of a JVM without a status says, or null when it is not known yet. */ + static String why(double cpuPercent) { + if (cpuPercent < 0) { + return null; + } + String cpu = String.format(Locale.US, "%.0f%% CPU", cpuPercent); + return cpuPercent >= BUSY_CPU_PERCENT + ? "busy at " + cpu + ", as when its heap is full and the garbage collector runs all the time" + : "idle at " + cpu + ", as when a debugger holds it"; + } + + /** For the row of the integration that a tool reads: since when it wrote no status, and its CPU. */ + static void describe(IntegrationInfo info, JsonObject row) { + if (!stale(info)) { + return; + } + row.put("noStatusSince", Instant.ofEpochMilli(info.statusWrittenAt).toString()); + if (info.cpuPercent >= 0) { + row.put("cpuPercent", Math.round(info.cpuPercent)); + } + } + + /** The answer of a stop of an integration that wrote no status of late, else null. */ + static String stopAnswer(IntegrationInfo info) { + if (info == null || !stale(info)) { + return null; + } + return "Stopping " + info.name + ", but it wrote no status for " + TimeUtils.printSince(info.statusWrittenAt) + + ", so it may not read the stop either: kill stops it"; + } + + private static String clock(long epochMillis) { + return LocalTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()).format(CLOCK); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md index 2ea670c691f2..3e134ad35ee7 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/overview.md @@ -35,7 +35,11 @@ happened to it, with no AI: when the last one did, routes not started, exchanges in flight. Errors that stopped more than a minute ago are history: the line says there were none in the last minute, with the earlier ones dimmed (Last 5m and - What happened still show them). + What happened still show them). An integration that wrote no status + for 15 seconds says since when instead, and what its CPU says: busy + when its heap is full and the garbage collector runs all the time, + idle when a debugger holds it. Its numbers are of then, and a stop + may not reach it; `F10` kills it. - **Next** — the keys that lead deeper: **4** the Timeline, **5** the Diagram, **2** the source. - **What happened** — a strip per route over the last 5 minutes (as wide @@ -70,7 +74,7 @@ Each row represents one running Camel integration: - **PID** — Process ID of the JVM running this integration - **NAME** — Name of the integration (from the route file or application configuration). Example: `camel-demo`, `my-app` - **VERSION** — Camel version the integration is running on (e.g., `4.21.0`) -- **STATUS** — Current lifecycle state: `Running` (processing messages), `Started` (ready), or `Stopped` +- **STATUS** — Current lifecycle state: `Running` (processing messages), `Started` (ready), or `Stopped`; `No status` when it wrote no status for 15 seconds while its process runs (its MSG/S is then `-`) - **AGE** — How long the integration has been running (e.g., `2m30s`, `1h15m`) - **MSG/S** or **MSG/M** — Messages processed per second or per minute (current throughput). The rate unit can be configured in settings. This is the overall rate across all routes - **TOTAL** — Total number of exchanges (messages) processed since the integration started diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java index 245204ed0ee2..0bdb12d6c214 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutViewTest.java @@ -156,6 +156,25 @@ class AboutViewTest { assertThat(render(new AboutView(ctx), info, 200, 40)).contains("last 5m, a column is 2s"); } + @Test + void anIntegrationWithoutAStatusOfLateSaysSoInsteadOfRunning() { + IntegrationInfo info = new IntegrationInfo(); + info.pid = "1234"; + info.errorCount = 2; + MonitorContext ctx = new MonitorContext(new AtomicReference<>(List.of(info)), new AtomicReference<>(List.of())); + info.statusWrittenAt = System.currentTimeMillis(); + assertThat(render(new AboutView(ctx), info)).contains("● running").contains("✗ 2 errors"); + + // a timer-log held with kill -STOP: its last status 30 seconds ago, and no CPU since + info.statusWrittenAt = System.currentTimeMillis() - 30_000; + info.cpuPercent = 0; + String rendered = render(new AboutView(ctx), info); + assertThat(rendered).contains("✗ no status").doesNotContain("● running") + .contains("(30s ago): idle at 0% CPU, as when a debugger holds it") + // what is to watch now; its errors are of then + .doesNotContain("2 errors"); + } + @Test void watchCalmsDownWhenTheErrorsStopped() { IntegrationInfo info = new IntegrationInfo(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshMembershipTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshMembershipTest.java index 7f59653f8e34..ac61da9bcc27 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshMembershipTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshMembershipTest.java @@ -31,7 +31,8 @@ import static org.assertj.core.api.Assertions.assertThat; /** * A new or stopped integration is noticed on every tab: the cheap look at the status files in ~/.camel asks for a full - * scan when they change, at most once a second. Lookups that wait for the diagram retry until found. + * scan when they change, at most once a second. Lookups that wait for the diagram retry until found. A status that is + * not of late adds nothing to the charts. */ @Isolated class DataRefreshMembershipTest { @@ -109,6 +110,23 @@ class DataRefreshMembershipTest { assertThat(calls.get()).isEqualTo(1); } + @Test + void aStatusOfThenAddsNothingToTheCharts() { + DataRefreshService service = newService(); + IntegrationInfo info = new IntegrationInfo(); + info.pid = "562838"; + info.throughput = "1.00"; + info.statusWrittenAt = System.currentTimeMillis(); + service.recordHistories(info); + assertThat(service.metrics().getThroughputHistory().get("562838")).hasSize(1); + + // held with kill -STOP a minute ago: its status still says 1 msg/s, and the chart does not take it on + info.statusWrittenAt = System.currentTimeMillis() - 60_000; + service.metrics().getThroughputHistory().clear(); + service.recordHistories(info); + assertThat(service.metrics().getThroughputHistory()).doesNotContainKey("562838"); + } + @Test void anExampleIsSelectedByTheNameItGivesItself() { assertThat(DataRefreshService.isLaunched("fail-well/circuit-breaker", "circuit-breaker")).isTrue(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusAgeTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusAgeTest.java new file mode 100644 index 000000000000..db6145dcca8a --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/StatusAgeTest.java @@ -0,0 +1,145 @@ +/* + * 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.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import dev.tamboui.text.Span; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The memory-leak example after a few hours: its heap is full, the garbage collector runs all the time, and it wrote + * its status file the last time at 18:36. The TUI showed it running at 8.52 msg/s since; now it says it has no status. + */ +class StatusAgeTest { + + private IntegrationInfo info; + private MonitorContext ctx; + + @BeforeEach + void setUp() { + Theme.resetForTesting(); + info = new IntegrationInfo(); + info.pid = "500874"; + info.name = "memory-leak"; + info.state = 5; + info.throughput = "8.52"; + info.statusWrittenAt = System.currentTimeMillis(); + ctx = new MonitorContext(new AtomicReference<>(List.of(info)), new AtomicReference<>(List.of())); + ctx.selectedPid = info.pid; + } + + @AfterEach + void tearDown() { + Theme.resetForTesting(); + } + + @Test + void aStatusOfAFewSecondsAgoIsNoReason() { + long now = System.currentTimeMillis(); + info.statusWrittenAt = now - 5_000; + assertThat(StatusAge.stale(info, now)).isFalse(); + info.statusWrittenAt = now - StatusAge.AFTER_MILLIS - 1; + assertThat(StatusAge.stale(info, now)).isTrue(); + // not known, or an opened project that does not run + info.statusWrittenAt = 0; + assertThat(StatusAge.stale(info, now)).isFalse(); + info.statusWrittenAt = now - 60_000; + info.phantom = true; + assertThat(StatusAge.stale(info, now)).isFalse(); + } + + @Test + void theOverviewSaysItHasNoStatusInsteadOfItsLastRate() { + stuckFor(4 * 60_000); + + OverviewTab tab = new OverviewTab(ctx, new MetricsCollector(), new HashSet<>(), () -> { + }).showIntegrations(); + String row = TuiTestHelper.renderToString(tab, 160, 30).lines() + .filter(l -> l.contains("memory-leak")).findFirst().orElseThrow(); + assertThat(row).contains(StatusAge.NO_STATUS).doesNotContain("Running").doesNotContain("8.52"); + + JsonObject json = (JsonObject) ((JsonArray) tab.getTableDataAsJson().get("rows")).get(0); + assertThat(json).containsEntry("noStatusSince", Instant.ofEpochMilli(info.statusWrittenAt).toString()) + .containsEntry("cpuPercent", 642L).containsEntry("throughput", null); + } + + @Test + void theWatchLineSaysSinceWhenAndWhatItsCpuSays() { + stuckFor(4 * 60_000); + + String watch = text(StatusAge.watch(info)); + assertThat(watch).startsWith("✗ no status since ").contains("(4m") + .contains("busy at 642% CPU, as when its heap is full") + .contains("F10 stop or kill"); + + info.cpuPercent = 0.4; + assertThat(text(StatusAge.watch(info))).contains("idle at 0% CPU, as when a debugger holds it"); + // its CPU is not known before the second read + info.cpuPercent = -1; + assertThat(text(StatusAge.watch(info))).doesNotContain("CPU"); + // and a running integration has no such line + info.statusWrittenAt = System.currentTimeMillis(); + assertThat(StatusAge.watch(info)).isEmpty(); + } + + @Test + void aStopSaysItMayNotReachIt() { + assertThat(StatusAge.stopAnswer(info)).isNull(); + stuckFor(4 * 60_000); + assertThat(StatusAge.stopAnswer(info)) + .isEqualTo("Stopping memory-leak, but it wrote no status for 4m0s, so it may not read the stop either:" + + " kill stops it"); + } + + @Test + void theCpuIsMeasuredBetweenTwoReadsWhileItHasNoStatus() { + ProcessHandle self = ProcessHandle.current(); + StatusAge.observeCpu(info, null, self, 1_000); + // it wrote its status: nothing to measure + assertThat(info.cpuMillis).isEqualTo(-1); + + info.statusWrittenAt = System.currentTimeMillis() - 60_000; + IntegrationInfo previous = new IntegrationInfo(); + previous.cpuMillis = 0; + previous.cpuReadAt = 1_000; + StatusAge.observeCpu(info, previous, self, 2_000); + assertThat(info.cpuReadAt).isEqualTo(2_000); + // the CPU of this JVM since it started, over one second + assertThat(info.cpuPercent).isGreaterThan(0); + } + + private void stuckFor(long millis) { + info.statusWrittenAt = System.currentTimeMillis() - millis; + info.cpuPercent = 642; + } + + private static String text(List<Span> spans) { + StringBuilder sb = new StringBuilder(); + spans.forEach(s -> sb.append(s.content())); + return sb.toString(); + } +}
