From df9143e6717488ea4cf9a3535d23d0b10c57f8a5 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 23 Mar 2026 15:21:51 +0100 Subject: [PATCH 01/10] CAMEL-23226: Add TUI dashboards for Camel JBang - Add `camel monitor` full-screen TUI dashboard with tabs for overview, routes, health checks, endpoints, and log viewer - Add `camel top tui` htop-style route performance dashboard with sortable columns and live refresh - Add `camel log tui` scrollable log viewer with level filtering, follow mode, and grep support - Add `camel trace tui` exchange trace viewer with detail panel showing headers, body, and exchange properties - Add `camel health` TUI health check dashboard with UP/DOWN indicators and memory gauge - Add `camel catalog tui` interactive component catalog browser with search/filter and component detail view - Upgrade JLine from 4.0.7 to 4.0.9 - Add TamboUI dependencies for terminal UI rendering - Use TamboUI's built-in JLine 3 backend (compatible with JLine 4.0.9) Co-Authored-By: Claude Opus 4.6 --- dsl/camel-jbang/camel-jbang-core/pom.xml | 28 + .../jbang/core/commands/CamelJBangMain.java | 15 +- .../core/commands/tui/CamelCatalogTui.java | 697 +++++++++ .../core/commands/tui/CamelHealthTui.java | 498 +++++++ .../jbang/core/commands/tui/CamelLogTui.java | 498 +++++++ .../jbang/core/commands/tui/CamelMonitor.java | 1301 +++++++++++++++++ .../jbang/core/commands/tui/CamelTopTui.java | 488 +++++++ .../core/commands/tui/CamelTraceTui.java | 635 ++++++++ parent/pom.xml | 2 +- 9 files changed, 4157 insertions(+), 5 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java create mode 100644 dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java create mode 100644 dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java create mode 100644 dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java create mode 100644 dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java create mode 100644 dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java diff --git a/dsl/camel-jbang/camel-jbang-core/pom.xml b/dsl/camel-jbang/camel-jbang-core/pom.xml index 31dccd48d50da..58241f214dc08 100644 --- a/dsl/camel-jbang/camel-jbang-core/pom.xml +++ b/dsl/camel-jbang/camel-jbang-core/pom.xml @@ -175,8 +175,36 @@ ${awaitility-version} test + + dev.tamboui + tamboui-tui + 0.2.0-SNAPSHOT + + + dev.tamboui + tamboui-widgets + 0.2.0-SNAPSHOT + + + dev.tamboui + tamboui-jline3-backend + 0.2.0-SNAPSHOT + + + + sonatype-snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + true + + + false + + + + diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java index bd03af9ee39c3..92febdab46595 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java @@ -49,6 +49,7 @@ import org.apache.camel.dsl.jbang.core.commands.plugin.PluginDelete; import org.apache.camel.dsl.jbang.core.commands.plugin.PluginGet; import org.apache.camel.dsl.jbang.core.commands.process.*; +import org.apache.camel.dsl.jbang.core.commands.tui.*; import org.apache.camel.dsl.jbang.core.commands.update.UpdateCommand; import org.apache.camel.dsl.jbang.core.commands.update.UpdateList; import org.apache.camel.dsl.jbang.core.commands.update.UpdateRun; @@ -96,7 +97,8 @@ public void execute(String... args) { .addSubcommand("kamelet", new CommandLine(new CatalogKamelet(this))) .addSubcommand("transformer", new CommandLine(new CatalogTransformer(this))) .addSubcommand("language", new CommandLine(new CatalogLanguage(this))) - .addSubcommand("other", new CommandLine(new CatalogOther(this)))) + .addSubcommand("other", new CommandLine(new CatalogOther(this))) + .addSubcommand("tui", new CommandLine(new CamelCatalogTui(this)))) .addSubcommand("cmd", new CommandLine(new CamelAction(this)) .addSubcommand("browse", new CommandLine(new CamelBrowseAction(this))) .addSubcommand("disable-processor", new CommandLine(new CamelProcessorDisableAction(this))) @@ -177,7 +179,9 @@ public void execute(String... args) { .addSubcommand("stop", new CommandLine(new InfraStop(this)))) .addSubcommand("init", new CommandLine(new Init(this))) .addSubcommand("jolokia", new CommandLine(new Jolokia(this))) - .addSubcommand("log", new CommandLine(new CamelLogAction(this))) + .addSubcommand("health", new CommandLine(new CamelHealthTui(this))) + .addSubcommand("log", new CommandLine(new CamelLogAction(this)) + .addSubcommand("tui", new CommandLine(new CamelLogTui(this)))) .addSubcommand("nano", new CommandLine(new Nano(this))) .addSubcommand("plugin", new CommandLine(new PluginCommand(this)) .addSubcommand("add", new CommandLine(new PluginAdd(this))) @@ -187,6 +191,7 @@ public void execute(String... args) { .addSubcommand("run", new CommandLine(new Run(this))) .addSubcommand("sbom", new CommandLine(new SBOMGenerator(this))) .addSubcommand("script", new CommandLine(new Script(this))) + .addSubcommand("monitor", new CommandLine(new CamelMonitor(this))) .addSubcommand("shell", new CommandLine(new Shell(this))) .addSubcommand("stop", new CommandLine(new StopProcess(this))) .addSubcommand("top", new CommandLine(new CamelTop(this)) @@ -194,8 +199,10 @@ public void execute(String... args) { .addSubcommand("group", new CommandLine(new CamelRouteGroupTop(this))) .addSubcommand("processor", new CommandLine(new CamelProcessorTop(this))) .addSubcommand("route", new CommandLine(new CamelRouteTop(this))) - .addSubcommand("source", new CommandLine(new CamelSourceTop(this)))) - .addSubcommand("trace", new CommandLine(new CamelTraceAction(this))) + .addSubcommand("source", new CommandLine(new CamelSourceTop(this))) + .addSubcommand("tui", new CommandLine(new CamelTopTui(this)))) + .addSubcommand("trace", new CommandLine(new CamelTraceAction(this)) + .addSubcommand("tui", new CommandLine(new CamelTraceTui(this)))) .addSubcommand("transform", new CommandLine(new TransformCommand(this)) .addSubcommand("dataweave", new CommandLine(new TransformDataWeave(this))) .addSubcommand("message", new CommandLine(new TransformMessageAction(this))) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java new file mode 100644 index 0000000000000..2c85d1c113a11 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java @@ -0,0 +1,697 @@ +/* + * 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.Collections; +import java.util.List; +import java.util.Map; + +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +import dev.tamboui.style.Overflow; +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.tui.TuiRunner; +import dev.tamboui.tui.event.Event; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +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.catalog.CamelCatalog; +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.apache.camel.dsl.jbang.core.commands.CamelCommand; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import picocli.CommandLine.Command; + +@Command(name = "catalog-tui", + description = "Interactive TUI catalog browser", + sortOptions = false) +public class CamelCatalogTui extends CamelCommand { + + private static final int FOCUS_LIST = 0; + private static final int FOCUS_DETAILS = 1; + + // Catalog data + private List allComponents = Collections.emptyList(); + private List filteredComponents = Collections.emptyList(); + + // UI state + private final TableState listTableState = new TableState(); + private final TableState optionsTableState = new TableState(); + private int focus = FOCUS_LIST; + private boolean searching; + private final StringBuilder searchText = new StringBuilder(); + private int detailScroll; + + // Selected component details + private ComponentDetail selectedDetail; + + public CamelCatalogTui(CamelJBangMain main) { + super(main); + } + + @Override + public Integer doCall() throws Exception { + try { + Class.forName("dev.tamboui.tui.event.KeyModifiers"); + Class.forName("dev.tamboui.tui.event.KeyEvent"); + Class.forName("dev.tamboui.tui.event.KeyCode"); + Class.forName("picocli.CommandLine$IExitCodeGenerator"); + } catch (ClassNotFoundException e) { + // ignore + } + + loadCatalog(); + + try (var tui = TuiRunner.create()) { + sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit()); + tui.run(this::handleEvent, this::render); + } + return 0; + } + + // ---- Catalog Loading ---- + + @SuppressWarnings("unchecked") + private void loadCatalog() { + CamelCatalog catalog = new DefaultCamelCatalog(); + List names = catalog.findComponentNames(); + Collections.sort(names); + + allComponents = new ArrayList<>(); + for (String name : names) { + try { + String json = catalog.componentJSonSchema(name); + if (json == null) { + continue; + } + JsonObject root = (JsonObject) Jsoner.deserialize(json); + JsonObject component = (JsonObject) root.get("component"); + if (component == null) { + continue; + } + + ComponentInfo info = new ComponentInfo(); + info.name = name; + info.title = component.getStringOrDefault("title", name); + info.description = component.getStringOrDefault("description", ""); + info.label = component.getStringOrDefault("label", ""); + info.groupId = component.getStringOrDefault("groupId", ""); + info.artifactId = component.getStringOrDefault("artifactId", ""); + info.version = component.getStringOrDefault("version", ""); + info.scheme = component.getStringOrDefault("scheme", name); + info.syntax = component.getStringOrDefault("syntax", ""); + info.deprecated = component.getBooleanOrDefault("deprecated", false); + + // Parse component properties + JsonObject properties = (JsonObject) root.get("properties"); + if (properties != null) { + for (Map.Entry entry : properties.entrySet()) { + JsonObject prop = (JsonObject) entry.getValue(); + OptionInfo opt = parseOption(entry.getKey(), prop); + opt.kind = "property"; + info.componentOptions.add(opt); + } + } + + // Parse endpoint properties (headers in newer catalogs, but "properties" is the standard) + // The endpoint options are typically under "properties" for the endpoint + // In the catalog JSON, component-level options have "kind":"property" + // and endpoint options have "kind":"path" or "kind":"parameter" + // So we re-scan and separate them + info.componentOptions.clear(); + info.endpointOptions.clear(); + if (properties != null) { + for (Map.Entry entry : properties.entrySet()) { + JsonObject prop = (JsonObject) entry.getValue(); + OptionInfo opt = parseOption(entry.getKey(), prop); + String kind = prop.getStringOrDefault("kind", "parameter"); + opt.kind = kind; + if ("property".equals(kind)) { + info.componentOptions.add(opt); + } else { + info.endpointOptions.add(opt); + } + } + } + + allComponents.add(info); + } catch (Exception e) { + // skip unparseable components + } + } + + applyFilter(); + if (!filteredComponents.isEmpty()) { + listTableState.select(0); + updateSelectedDetail(); + } + } + + private OptionInfo parseOption(String name, JsonObject prop) { + OptionInfo opt = new OptionInfo(); + opt.name = name; + opt.type = prop.getStringOrDefault("type", ""); + opt.required = prop.getBooleanOrDefault("required", false); + opt.defaultValue = prop.getStringOrDefault("defaultValue", ""); + opt.description = prop.getStringOrDefault("description", ""); + opt.group = prop.getStringOrDefault("group", ""); + opt.enumValues = ""; + Object enums = prop.get("enum"); + if (enums instanceof JsonArray arr) { + List vals = new ArrayList<>(); + for (Object v : arr) { + vals.add(String.valueOf(v)); + } + opt.enumValues = String.join(", ", vals); + } + return opt; + } + + private void applyFilter() { + String filter = searchText.toString().toLowerCase(); + if (filter.isEmpty()) { + filteredComponents = new ArrayList<>(allComponents); + } else { + filteredComponents = new ArrayList<>(); + for (ComponentInfo c : allComponents) { + if (c.name.toLowerCase().contains(filter) + || c.title.toLowerCase().contains(filter) + || c.description.toLowerCase().contains(filter) + || c.label.toLowerCase().contains(filter)) { + filteredComponents.add(c); + } + } + } + // Reset selection + if (!filteredComponents.isEmpty()) { + listTableState.select(0); + } else { + listTableState.clearSelection(); + } + updateSelectedDetail(); + } + + private void updateSelectedDetail() { + Integer sel = listTableState.selected(); + if (sel != null && sel >= 0 && sel < filteredComponents.size()) { + ComponentInfo info = filteredComponents.get(sel); + selectedDetail = new ComponentDetail(info); + detailScroll = 0; + } else { + selectedDetail = null; + } + } + + // ---- Event Handling ---- + + private boolean handleEvent(Event event, TuiRunner runner) { + if (event instanceof KeyEvent ke) { + // Search mode handling + if (searching) { + if (ke.isKey(KeyCode.ESCAPE)) { + if (searchText.isEmpty()) { + searching = false; + } else { + searchText.setLength(0); + applyFilter(); + } + return true; + } + if (ke.isKey(KeyCode.ENTER)) { + searching = false; + return true; + } + if (ke.isKey(KeyCode.BACKSPACE)) { + if (!searchText.isEmpty()) { + searchText.deleteCharAt(searchText.length() - 1); + applyFilter(); + } + return true; + } + if (ke.isUp()) { + listTableState.selectPrevious(); + updateSelectedDetail(); + return true; + } + if (ke.isDown()) { + listTableState.selectNext(filteredComponents.size()); + updateSelectedDetail(); + return true; + } + // Typed character + if (ke.code() == KeyCode.CHAR) { + searchText.append(ke.character()); + applyFilter(); + return true; + } + return true; + } + + // Normal mode + if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) { + runner.quit(); + return true; + } + + if (ke.isChar('/')) { + searching = true; + return true; + } + + if (ke.isKey(KeyCode.TAB)) { + focus = (focus == FOCUS_LIST) ? FOCUS_DETAILS : FOCUS_LIST; + return true; + } + + if (ke.isUp()) { + if (focus == FOCUS_LIST) { + listTableState.selectPrevious(); + updateSelectedDetail(); + } else { + detailScroll = Math.max(0, detailScroll - 1); + } + return true; + } + if (ke.isDown()) { + if (focus == FOCUS_LIST) { + listTableState.selectNext(filteredComponents.size()); + updateSelectedDetail(); + } else { + detailScroll++; + } + return true; + } + if (ke.isKey(KeyCode.PAGE_UP)) { + if (focus == FOCUS_DETAILS) { + detailScroll = Math.max(0, detailScroll - 20); + } + return true; + } + if (ke.isKey(KeyCode.PAGE_DOWN)) { + if (focus == FOCUS_DETAILS) { + detailScroll += 20; + } + return true; + } + if (ke.isKey(KeyCode.ENTER)) { + if (focus == FOCUS_LIST) { + updateSelectedDetail(); + focus = FOCUS_DETAILS; + } + return true; + } + } + return false; + } + + // ---- Rendering ---- + + private void render(Frame frame) { + Rect area = frame.area(); + + // Layout: header (3 rows) + content (fill) + footer (1 row) + List mainChunks = Layout.vertical() + .constraints( + Constraint.length(3), + Constraint.fill(), + Constraint.length(1)) + .split(area); + + renderHeader(frame, mainChunks.get(0)); + renderContent(frame, mainChunks.get(1)); + renderFooter(frame, mainChunks.get(2)); + } + + private void renderHeader(Frame frame, Rect area) { + String searchStatus; + if (searching) { + searchStatus = " Search: " + searchText + "_"; + } else if (!searchText.isEmpty()) { + searchStatus = " Filter: \"" + searchText + "\""; + } else { + searchStatus = ""; + } + + Line titleLine = Line.from( + Span.styled(" Camel Catalog", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()), + Span.raw(" "), + Span.styled(filteredComponents.size() + "/" + allComponents.size() + " components", + Style.create().fg(Color.CYAN)), + Span.styled(searchStatus, Style.create().fg(Color.YELLOW))); + + Block headerBlock = Block.builder() + .borderType(BorderType.ROUNDED) + .title(" Apache Camel ") + .build(); + + frame.renderWidget( + Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(), + area); + } + + private void renderContent(Frame frame, Rect area) { + // Split horizontally: left panel (30%) + right panel (70%) + List chunks = Layout.horizontal() + .constraints( + Constraint.percentage(30), + Constraint.percentage(70)) + .split(area); + + renderComponentList(frame, chunks.get(0)); + renderComponentDetails(frame, chunks.get(1)); + } + + private void renderComponentList(Frame frame, Rect area) { + List rows = new ArrayList<>(); + for (ComponentInfo comp : filteredComponents) { + Style nameStyle = comp.deprecated + ? Style.create().fg(Color.RED).dim() + : Style.create().fg(Color.CYAN); + String label = comp.name; + if (comp.deprecated) { + label = label + " (deprecated)"; + } + rows.add(Row.from(Cell.from(Span.styled(label, nameStyle)))); + } + + if (rows.isEmpty()) { + rows.add(Row.from(Cell.from(Span.styled("No matching components", Style.create().dim())))); + } + + Style borderStyle = focus == FOCUS_LIST + ? Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)) + : Style.create(); + + Table table = Table.builder() + .rows(rows) + .widths(Constraint.fill()) + .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .block(Block.builder() + .borderType(BorderType.ROUNDED) + .borderStyle(borderStyle) + .title(" Components ") + .build()) + .build(); + + frame.renderStatefulWidget(table, area, listTableState); + } + + private void renderComponentDetails(Frame frame, Rect area) { + Style borderStyle = focus == FOCUS_DETAILS + ? Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)) + : Style.create(); + + if (selectedDetail == null) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from( + Span.styled(" Select a component from the list", + Style.create().dim())))) + .block(Block.builder() + .borderType(BorderType.ROUNDED) + .borderStyle(borderStyle) + .title(" Details ") + .build()) + .build(), + area); + return; + } + + ComponentInfo comp = selectedDetail.info; + + // Split: info area (top) + options table (bottom) + List chunks = Layout.vertical() + .constraints( + Constraint.length(9), + Constraint.fill()) + .split(area); + + // Component info panel + List infoLines = new ArrayList<>(); + infoLines.add(Line.from( + Span.styled(" Title: ", Style.create().bold()), + Span.styled(comp.title, Style.create().fg(Color.CYAN)))); + infoLines.add(Line.from( + Span.styled(" Scheme: ", Style.create().bold()), + Span.raw(comp.scheme))); + infoLines.add(Line.from( + Span.styled(" Syntax: ", Style.create().bold()), + Span.raw(comp.syntax))); + infoLines.add(Line.from( + Span.styled(" Label: ", Style.create().bold()), + Span.styled(comp.label, Style.create().fg(Color.GREEN)))); + infoLines.add(Line.from( + Span.styled(" Maven: ", Style.create().bold()), + Span.raw(comp.groupId + ":" + comp.artifactId + ":" + comp.version))); + if (comp.deprecated) { + infoLines.add(Line.from( + Span.styled(" Status: ", Style.create().bold()), + Span.styled("DEPRECATED", Style.create().fg(Color.RED).bold()))); + } + infoLines.add(Line.from(Span.raw(""))); + // Word-wrap description into the available width + int descWidth = Math.max(10, chunks.get(0).width() - 4); + String desc = comp.description; + if (desc.length() > descWidth) { + infoLines.add(Line.from( + Span.styled(" " + desc.substring(0, descWidth), Style.create().dim()))); + if (desc.length() > descWidth * 2) { + infoLines.add(Line.from( + Span.styled(" " + desc.substring(descWidth, descWidth * 2) + "...", Style.create().dim()))); + } else { + infoLines.add(Line.from( + Span.styled(" " + desc.substring(descWidth), Style.create().dim()))); + } + } else { + infoLines.add(Line.from(Span.styled(" " + desc, Style.create().dim()))); + } + + frame.renderWidget( + Paragraph.builder() + .text(Text.from(infoLines)) + .overflow(Overflow.CLIP) + .block(Block.builder() + .borderType(BorderType.ROUNDED) + .borderStyle(borderStyle) + .title(" " + comp.title + " ") + .build()) + .build(), + chunks.get(0)); + + // Options table + renderOptionsTable(frame, chunks.get(1), borderStyle); + } + + private void renderOptionsTable(Frame frame, Rect area, Style borderStyle) { + if (selectedDetail == null) { + return; + } + + ComponentInfo comp = selectedDetail.info; + + // Combine all options with section headers + List rows = new ArrayList<>(); + + if (!comp.endpointOptions.isEmpty()) { + rows.add(Row.from( + Cell.from(Span.styled("--- Endpoint Options ---", Style.create().fg(Color.YELLOW).bold())), + Cell.from(""), + Cell.from(""), + Cell.from(""), + Cell.from(""))); + for (OptionInfo opt : comp.endpointOptions) { + rows.add(optionToRow(opt)); + } + } + + if (!comp.componentOptions.isEmpty()) { + rows.add(Row.from( + Cell.from(Span.styled("--- Component Options ---", Style.create().fg(Color.YELLOW).bold())), + Cell.from(""), + Cell.from(""), + Cell.from(""), + Cell.from(""))); + for (OptionInfo opt : comp.componentOptions) { + rows.add(optionToRow(opt)); + } + } + + if (rows.isEmpty()) { + rows.add(Row.from( + Cell.from(Span.styled("No options", Style.create().dim())), + Cell.from(""), + Cell.from(""), + Cell.from(""), + Cell.from(""))); + } + + // Apply scrolling + int innerHeight = Math.max(1, area.height() - 3); // border + header + int maxScroll = Math.max(0, rows.size() - innerHeight); + if (detailScroll > maxScroll) { + detailScroll = maxScroll; + } + List visibleRows; + if (detailScroll > 0 && detailScroll < rows.size()) { + int end = Math.min(detailScroll + innerHeight, rows.size()); + visibleRows = rows.subList(detailScroll, end); + } else { + visibleRows = rows; + } + + Row header = Row.from( + Cell.from(Span.styled("NAME", Style.create().bold())), + Cell.from(Span.styled("TYPE", Style.create().bold())), + Cell.from(Span.styled("REQ", Style.create().bold())), + Cell.from(Span.styled("DEFAULT", Style.create().bold())), + Cell.from(Span.styled("DESCRIPTION", Style.create().bold()))); + + String scrollInfo = rows.size() > innerHeight + ? " [" + (detailScroll + 1) + "-" + Math.min(detailScroll + innerHeight, rows.size()) + + "/" + rows.size() + "] " + : " "; + + Table table = Table.builder() + .rows(visibleRows) + .header(header) + .widths( + Constraint.length(25), + Constraint.length(12), + Constraint.length(4), + Constraint.length(12), + Constraint.fill()) + .block(Block.builder() + .borderType(BorderType.ROUNDED) + .borderStyle(borderStyle) + .title(" Options" + scrollInfo) + .build()) + .build(); + + frame.renderStatefulWidget(table, area, optionsTableState); + } + + private Row optionToRow(OptionInfo opt) { + Style nameStyle = opt.required + ? Style.create().fg(Color.CYAN).bold() + : Style.create().fg(Color.CYAN); + + String desc = opt.description; + if (!opt.enumValues.isEmpty()) { + desc = desc + " [" + opt.enumValues + "]"; + } + + return Row.from( + Cell.from(Span.styled(opt.name, nameStyle)), + Cell.from(Span.styled(opt.type, Style.create().dim())), + Cell.from(opt.required + ? Span.styled("*", Style.create().fg(Color.RED).bold()) + : Span.raw("")), + Cell.from(Span.styled(opt.defaultValue, Style.create().dim())), + Cell.from(truncate(desc, 80))); + } + + private void renderFooter(Frame frame, Rect area) { + Line footer; + if (searching) { + footer = Line.from( + Span.styled(" Type", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" to filter "), + Span.styled("Enter", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" confirm "), + Span.styled("Esc", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" clear/cancel "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" navigate")); + } else { + footer = Line.from( + Span.styled(" q", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" quit "), + Span.styled("/", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" search "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" navigate "), + Span.styled("Tab", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" switch panel "), + Span.styled("Enter", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" select "), + Span.styled("PgUp/PgDn", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" scroll details")); + } + + frame.renderWidget(Paragraph.from(footer), area); + } + + // ---- Helpers ---- + + private static String truncate(String s, int max) { + if (s == null) { + return ""; + } + return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s; + } + + // ---- Data Classes ---- + + static class ComponentInfo { + String name; + String title; + String description; + String label; + String groupId; + String artifactId; + String version; + String scheme; + String syntax; + boolean deprecated; + final List componentOptions = new ArrayList<>(); + final List endpointOptions = new ArrayList<>(); + } + + static class OptionInfo { + String name; + String kind; + String type; + boolean required; + String defaultValue; + String description; + String group; + String enumValues; + } + + static class ComponentDetail { + final ComponentInfo info; + + ComponentDetail(ComponentInfo info) { + this.info = info; + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java new file mode 100644 index 0000000000000..e0e1d46548ed8 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java @@ -0,0 +1,498 @@ +/* + * 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.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +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.tui.TuiRunner; +import dev.tamboui.tui.event.Event; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.TickEvent; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.gauge.Gauge; +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.dsl.jbang.core.commands.CamelCommand; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.ProcessHelper; +import org.apache.camel.support.PatternHelper; +import org.apache.camel.util.FileUtil; +import org.apache.camel.util.TimeUtils; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command(name = "health-tui", + description = "TUI health check dashboard", + sortOptions = false) +public class CamelHealthTui extends CamelCommand { + + @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1") + String name = "*"; + + @CommandLine.Option(names = { "--refresh" }, + description = "Refresh interval in milliseconds (default: ${DEFAULT-VALUE})", + defaultValue = "1000") + long refreshInterval = 1000; + + // State + private final AtomicReference> data = new AtomicReference<>(Collections.emptyList()); + private final TableState tableState = new TableState(); + private boolean showOnlyDown; + private volatile long lastRefresh; + + // Memory info for the selected row's integration + private long selectedHeapUsed; + private long selectedHeapMax; + + public CamelHealthTui(CamelJBangMain main) { + super(main); + } + + @Override + public Integer doCall() throws Exception { + // Eagerly load classes used by the input reader thread and picocli + // post-processing to avoid ClassNotFoundException during shutdown + // when the Spring Boot LaunchedClassLoader may already be closing + try { + Class.forName("dev.tamboui.tui.event.KeyModifiers"); + Class.forName("dev.tamboui.tui.event.KeyEvent"); + Class.forName("dev.tamboui.tui.event.KeyCode"); + Class.forName("picocli.CommandLine$IExitCodeGenerator"); + } catch (ClassNotFoundException e) { + // ignore + } + + // Initial data load + refreshData(); + + try (var tui = TuiRunner.create()) { + sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit()); + tui.run( + this::handleEvent, + this::render); + } + return 0; + } + + // ---- Event Handling ---- + + private boolean handleEvent(Event event, TuiRunner runner) { + if (event instanceof KeyEvent ke) { + if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) { + runner.quit(); + return true; + } + if (ke.isChar('r')) { + refreshData(); + return true; + } + if (ke.isChar('d')) { + showOnlyDown = !showOnlyDown; + return true; + } + if (ke.isUp()) { + tableState.selectPrevious(); + return true; + } + if (ke.isDown()) { + List rows = getFilteredRows(); + tableState.selectNext(rows.size()); + return true; + } + } + if (event instanceof TickEvent) { + long now = System.currentTimeMillis(); + if (now - lastRefresh >= refreshInterval) { + refreshData(); + } + return true; + } + return false; + } + + // ---- Rendering ---- + + private void render(Frame frame) { + Rect area = frame.area(); + + // Layout: header (3 rows) + health table (fill) + memory gauge (3 rows) + footer (1 row) + List chunks = Layout.vertical() + .constraints( + Constraint.length(3), + Constraint.fill(), + Constraint.length(3), + Constraint.length(1)) + .split(area); + + renderHeader(frame, chunks.get(0)); + renderHealthTable(frame, chunks.get(1)); + renderMemoryGauge(frame, chunks.get(2)); + renderFooter(frame, chunks.get(3)); + } + + private void renderHeader(Frame frame, Rect area) { + List allRows = data.get(); + long upCount = allRows.stream().filter(r -> "UP".equals(r.state)).count(); + long downCount = allRows.stream().filter(r -> "DOWN".equals(r.state)).count(); + + Line titleLine = Line.from( + Span.styled(" Health Dashboard", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()), + Span.raw(" "), + Span.styled(upCount + " UP", Style.create().fg(Color.GREEN).bold()), + Span.raw(", "), + downCount > 0 + ? Span.styled(downCount + " DOWN", Style.create().fg(Color.RED).bold()) + : Span.styled("0 DOWN", Style.create().fg(Color.GREEN)), + showOnlyDown ? Span.styled(" [DOWN only]", Style.create().fg(Color.YELLOW)) : Span.raw("")); + + Block headerBlock = Block.builder() + .borderType(BorderType.ROUNDED) + .title(" Camel Health Checks ") + .build(); + + frame.renderWidget( + Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(), + area); + } + + private void renderHealthTable(Frame frame, Rect area) { + List rows = getFilteredRows(); + + // Update selected integration's memory info + Integer sel = tableState.selected(); + if (sel != null && sel >= 0 && sel < rows.size()) { + HealthRow selected = rows.get(sel); + selectedHeapUsed = selected.heapMemUsed; + selectedHeapMax = selected.heapMemMax; + } else if (!rows.isEmpty()) { + selectedHeapUsed = rows.get(0).heapMemUsed; + selectedHeapMax = rows.get(0).heapMemMax; + } else { + selectedHeapUsed = 0; + selectedHeapMax = 0; + } + + List tableRows = new ArrayList<>(); + for (HealthRow hr : rows) { + Style stateStyle; + String icon; + if ("UP".equals(hr.state)) { + stateStyle = Style.create().fg(Color.GREEN); + icon = "\u2714 "; + } else if ("DOWN".equals(hr.state)) { + stateStyle = Style.create().fg(Color.RED); + icon = "\u2716 "; + } else { + stateStyle = Style.create().fg(Color.YELLOW); + icon = "\u26A0 "; + } + + String rate = ""; + if (hr.readiness) { + rate += "R"; + } + if (hr.liveness) { + rate += rate.isEmpty() ? "L" : "/L"; + } + + tableRows.add(Row.from( + Cell.from(hr.pid), + Cell.from(Span.styled(truncate(hr.integrationName, 20), Style.create().fg(Color.CYAN))), + Cell.from(hr.group != null ? truncate(hr.group, 12) : ""), + Cell.from(Span.styled(truncate(hr.checkId, 25), Style.create().fg(Color.CYAN))), + Cell.from(Span.styled(icon + hr.state, stateStyle)), + Cell.from(rate), + Cell.from(hr.since != null ? hr.since : ""), + Cell.from(hr.message != null ? truncate(hr.message, 40) : ""))); + } + + if (tableRows.isEmpty()) { + tableRows.add(Row.from( + Cell.from(""), + Cell.from(Span.styled("No health checks found", Style.create().dim())), + Cell.from(""), + Cell.from(""), + Cell.from(""), + Cell.from(""), + Cell.from(""), + Cell.from(""))); + } + + Row header = Row.from( + Cell.from(Span.styled("PID", Style.create().bold())), + Cell.from(Span.styled("NAME", Style.create().bold())), + Cell.from(Span.styled("GROUP", Style.create().bold())), + Cell.from(Span.styled("CHECK", Style.create().bold())), + Cell.from(Span.styled("STATUS", Style.create().bold())), + Cell.from(Span.styled("RATE", Style.create().bold())), + Cell.from(Span.styled("SINCE", Style.create().bold())), + Cell.from(Span.styled("MESSAGE", Style.create().bold()))); + + Table table = Table.builder() + .rows(tableRows) + .header(header) + .widths( + Constraint.length(8), + Constraint.length(20), + Constraint.length(12), + Constraint.length(25), + Constraint.length(10), + Constraint.length(6), + Constraint.length(8), + Constraint.fill()) + .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(showOnlyDown ? " Health Checks [DOWN only] " : " Health Checks ").build()) + .build(); + + frame.renderStatefulWidget(table, area, tableState); + } + + private void renderMemoryGauge(Frame frame, Rect area) { + if (selectedHeapMax > 0) { + int pct = (int) (100.0 * selectedHeapUsed / selectedHeapMax); + Style gaugeStyle = pct > 80 ? Style.create().fg(Color.RED) + : pct > 60 ? Style.create().fg(Color.YELLOW) : Style.create().fg(Color.GREEN); + Gauge gauge = Gauge.builder() + .percent(pct) + .label(String.format("Heap: %s / %s (%d%%)", + formatBytes(selectedHeapUsed), formatBytes(selectedHeapMax), pct)) + .gaugeStyle(gaugeStyle) + .block(Block.builder().borderType(BorderType.ROUNDED).title(" Memory ").build()) + .build(); + frame.renderWidget(gauge, area); + } else { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from(Span.styled(" No memory data", Style.create().dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED).title(" Memory ").build()) + .build(), + area); + } + } + + private void renderFooter(Frame frame, Rect area) { + String refreshLabel = refreshInterval >= 1000 + ? (refreshInterval / 1000) + "s" + : refreshInterval + "ms"; + + Line footer = Line.from( + Span.styled(" q", Style.create().fg(Color.YELLOW).bold()), + Span.raw("/"), + Span.styled("Esc", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" quit "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" navigate "), + Span.styled("r", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" refresh "), + Span.styled("d", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" toggle DOWN "), + Span.styled("Refresh: " + refreshLabel, Style.create().dim())); + + frame.renderWidget(Paragraph.from(footer), area); + } + + // ---- Data Loading ---- + + private void refreshData() { + lastRefresh = System.currentTimeMillis(); + try { + List rows = new ArrayList<>(); + List pids = findPids(name); + ProcessHandle.allProcesses() + .filter(ph -> pids.contains(ph.pid())) + .forEach(ph -> { + JsonObject root = loadStatus(ph.pid()); + if (root != null) { + parseHealthRows(ph, root, rows); + } + }); + data.set(rows); + } catch (Exception e) { + // ignore refresh errors + } + } + + @SuppressWarnings("unchecked") + private void parseHealthRows(ProcessHandle ph, JsonObject root, List rows) { + JsonObject context = (JsonObject) root.get("context"); + if (context == null) { + return; + } + + String pid = Long.toString(ph.pid()); + String integrationName = context.getString("name"); + if ("CamelJBang".equals(integrationName)) { + integrationName = ProcessHelper.extractName(root, ph); + } + + String since = TimeUtils.printSince( + ph.info().startInstant().map(Instant::toEpochMilli).orElse(0L)); + + // Parse memory + long heapUsed = 0; + long heapMax = 0; + JsonObject mem = (JsonObject) root.get("memory"); + if (mem != null) { + heapUsed = mem.getLong("heapMemoryUsed"); + heapMax = mem.getLong("heapMemoryMax"); + } + + // Parse health checks + JsonObject healthChecks = (JsonObject) root.get("healthChecks"); + if (healthChecks != null) { + JsonArray checks = (JsonArray) healthChecks.get("checks"); + if (checks != null) { + for (Object c : checks) { + JsonObject cj = (JsonObject) c; + HealthRow hr = new HealthRow(); + hr.pid = pid; + hr.integrationName = integrationName; + hr.checkId = cj.getString("id"); + hr.group = cj.getString("group"); + hr.state = cj.getString("state"); + hr.readiness = cj.getBooleanOrDefault("readiness", false); + hr.liveness = cj.getBooleanOrDefault("liveness", false); + hr.since = since; + hr.heapMemUsed = heapUsed; + hr.heapMemMax = heapMax; + + // Extract failure message from details + JsonObject details = (JsonObject) cj.get("details"); + if (details != null && details.containsKey("failure.error.message")) { + hr.message = details.getString("failure.error.message"); + } + + rows.add(hr); + } + } + } + } + + // ---- Helpers ---- + + private List getFilteredRows() { + List allRows = data.get(); + if (showOnlyDown) { + return allRows.stream().filter(r -> "DOWN".equals(r.state)).toList(); + } + return allRows; + } + + private List findPids(String name) { + List pids = new ArrayList<>(); + final long cur = ProcessHandle.current().pid(); + String pattern = name; + if (!pattern.matches("\\d+") && !pattern.endsWith("*")) { + pattern = pattern + "*"; + } + final String pat = pattern; + ProcessHandle.allProcesses() + .filter(ph -> ph.pid() != cur) + .forEach(ph -> { + JsonObject root = loadStatus(ph.pid()); + if (root != null) { + String pName = ProcessHelper.extractName(root, ph); + pName = FileUtil.onlyName(pName); + if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { + pids.add(ph.pid()); + } else { + JsonObject context = (JsonObject) root.get("context"); + if (context != null) { + pName = context.getString("name"); + if ("CamelJBang".equals(pName)) { + pName = null; + } + if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { + pids.add(ph.pid()); + } + } + } + } + }); + return pids; + } + + private JsonObject loadStatus(long pid) { + try { + Path f = getStatusFile(Long.toString(pid)); + if (f != null && Files.exists(f)) { + String text = Files.readString(f); + return (JsonObject) Jsoner.deserialize(text); + } + } catch (Exception e) { + // ignore + } + return null; + } + + private static String truncate(String s, int max) { + if (s == null) { + return ""; + } + return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s; + } + + private static String formatBytes(long bytes) { + if (bytes < 1024) { + return bytes + "B"; + } + if (bytes < 1024 * 1024) { + return (bytes / 1024) + "K"; + } + return (bytes / (1024 * 1024)) + "M"; + } + + // ---- Data Class ---- + + static class HealthRow { + String pid; + String integrationName; + String group; + String checkId; + String state; + boolean readiness; + boolean liveness; + String since; + String message; + long heapMemUsed; + long heapMemMax; + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java new file mode 100644 index 0000000000000..1bede16a83960 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java @@ -0,0 +1,498 @@ +/* + * 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.io.IOException; +import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +import dev.tamboui.style.Overflow; +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.tui.TuiRunner; +import dev.tamboui.tui.event.Event; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.TickEvent; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.paragraph.Paragraph; +import org.apache.camel.dsl.jbang.core.commands.CamelCommand; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; +import org.apache.camel.dsl.jbang.core.common.ProcessHelper; +import org.apache.camel.support.PatternHelper; +import org.apache.camel.util.FileUtil; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command(name = "log-tui", + description = "TUI log viewer for Camel integrations", + sortOptions = false) +public class CamelLogTui extends CamelCommand { + + private static final int MAX_READ_BYTES = 64 * 1024; + + @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1") + String name = "*"; + + @CommandLine.Option(names = { "--refresh" }, + description = "Refresh interval in milliseconds (default: ${DEFAULT-VALUE})", + defaultValue = "500") + long refreshInterval = 500; + + @CommandLine.Option(names = { "--grep" }, + description = "Filter logs to matching lines") + String grep; + + @CommandLine.Option(names = { "--level" }, + description = "Filter by log level (INFO, WARN, ERROR, DEBUG)") + String level; + + // State + private final List logLines = new ArrayList<>(); + private final List filteredLines = new ArrayList<>(); + private int scrollOffset; + private boolean followMode = true; + + // Level toggle filters (all enabled by default) + private boolean showTrace = true; + private boolean showDebug = true; + private boolean showInfo = true; + private boolean showWarn = true; + private boolean showError = true; + + // Integration info + private String resolvedPid; + private String integrationName; + private Path logFilePath; + + private volatile long lastRefresh; + + public CamelLogTui(CamelJBangMain main) { + super(main); + } + + @Override + public Integer doCall() throws Exception { + // Eagerly load classes used by the input reader thread and picocli + // post-processing to avoid ClassNotFoundException during shutdown + try { + Class.forName("dev.tamboui.tui.event.KeyModifiers"); + Class.forName("dev.tamboui.tui.event.KeyEvent"); + Class.forName("dev.tamboui.tui.event.KeyCode"); + Class.forName("picocli.CommandLine$IExitCodeGenerator"); + } catch (ClassNotFoundException e) { + // ignore + } + + // Apply --level as initial level filter + if (level != null) { + String lv = level.toUpperCase(); + showTrace = "TRACE".equals(lv); + showDebug = "DEBUG".equals(lv) || "TRACE".equals(lv); + showInfo = "INFO".equals(lv) || "DEBUG".equals(lv) || "TRACE".equals(lv); + showWarn = "WARN".equals(lv) || "INFO".equals(lv) || "DEBUG".equals(lv) || "TRACE".equals(lv); + showError = true; // always show errors + } + + // Initial data load + resolveIntegration(); + refreshLogData(); + + try (var tui = TuiRunner.create()) { + sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit()); + tui.run( + this::handleEvent, + this::render); + } + return 0; + } + + // ---- Event Handling ---- + + private boolean handleEvent(Event event, TuiRunner runner) { + if (event instanceof KeyEvent ke) { + if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) { + runner.quit(); + return true; + } + if (ke.isUp()) { + followMode = false; + scrollOffset = Math.max(0, scrollOffset - 1); + return true; + } + if (ke.isDown()) { + scrollOffset++; + return true; + } + if (ke.isKey(KeyCode.PAGE_UP)) { + followMode = false; + scrollOffset = Math.max(0, scrollOffset - 20); + return true; + } + if (ke.isKey(KeyCode.PAGE_DOWN)) { + scrollOffset += 20; + return true; + } + if (ke.isChar('g')) { + followMode = false; + scrollOffset = 0; + return true; + } + if (ke.isChar('G')) { + followMode = true; + return true; + } + if (ke.isChar('f')) { + followMode = !followMode; + return true; + } + // Level toggles: 1=TRACE, 2=DEBUG, 3=INFO, 4=WARN, 5=ERROR + if (ke.isChar('1')) { + showTrace = !showTrace; + applyFilters(); + return true; + } + if (ke.isChar('2')) { + showDebug = !showDebug; + applyFilters(); + return true; + } + if (ke.isChar('3')) { + showInfo = !showInfo; + applyFilters(); + return true; + } + if (ke.isChar('4')) { + showWarn = !showWarn; + applyFilters(); + return true; + } + if (ke.isChar('5')) { + showError = !showError; + applyFilters(); + return true; + } + } + if (event instanceof TickEvent) { + long now = System.currentTimeMillis(); + if (now - lastRefresh >= refreshInterval) { + resolveIntegration(); + refreshLogData(); + } + return true; + } + return false; + } + + // ---- Rendering ---- + + private void render(Frame frame) { + Rect area = frame.area(); + + // Layout: header (3 rows) + log area (fill) + footer (1 row) + List mainChunks = Layout.vertical() + .constraints( + Constraint.length(3), + Constraint.fill(), + Constraint.length(1)) + .split(area); + + renderHeader(frame, mainChunks.get(0)); + renderLogArea(frame, mainChunks.get(1)); + renderFooter(frame, mainChunks.get(2)); + } + + private void renderHeader(Frame frame, Rect area) { + String titleText = integrationName != null ? integrationName : name; + String logInfo = logFilePath != null ? logFilePath.getFileName().toString() : "no log file"; + String lineCount = filteredLines.size() + " lines"; + + Line titleLine = Line.from( + Span.styled(" Log Viewer", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()), + Span.raw(" "), + Span.styled(titleText, Style.create().fg(Color.CYAN)), + Span.raw(" "), + Span.styled(logInfo, Style.create().dim()), + Span.raw(" "), + Span.styled(lineCount, Style.create().fg(Color.GREEN)), + followMode + ? Span.styled(" [FOLLOW]", Style.create().fg(Color.YELLOW).bold()) + : Span.raw("")); + + Block headerBlock = Block.builder() + .borderType(BorderType.ROUNDED) + .title(" Camel Log ") + .build(); + + frame.renderWidget( + Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(), + area); + } + + private void renderLogArea(Frame frame, Rect area) { + Block logBlock = Block.builder() + .borderType(BorderType.ROUNDED) + .title(buildLevelFilterTitle()) + .build(); + + int innerHeight = Math.max(1, area.height() - 2); + int totalLines = filteredLines.size(); + + int startLine; + if (followMode) { + startLine = Math.max(0, totalLines - innerHeight); + scrollOffset = startLine; + } else { + scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, totalLines - innerHeight))); + startLine = scrollOffset; + } + + List visibleLines = new ArrayList<>(); + for (int i = startLine; i < Math.min(startLine + innerHeight, totalLines); i++) { + visibleLines.add(colorizeLogLine(filteredLines.get(i))); + } + + // Fill remaining space + while (visibleLines.size() < innerHeight) { + visibleLines.add(Line.from(Span.raw(""))); + } + + Paragraph logParagraph = Paragraph.builder() + .text(Text.from(visibleLines)) + .overflow(Overflow.CLIP) + .block(logBlock) + .build(); + + frame.renderWidget(logParagraph, area); + } + + private String buildLevelFilterTitle() { + StringBuilder sb = new StringBuilder(" Levels: "); + sb.append(showTrace ? "[1:TRACE] " : "[1:trace] "); + sb.append(showDebug ? "[2:DEBUG] " : "[2:debug] "); + sb.append(showInfo ? "[3:INFO] " : "[3:info] "); + sb.append(showWarn ? "[4:WARN] " : "[4:warn] "); + sb.append(showError ? "[5:ERROR] " : "[5:error] "); + return sb.toString(); + } + + private void renderFooter(Frame frame, Rect area) { + Line footer = Line.from( + Span.styled(" q", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" quit "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" scroll "), + Span.styled("g", Style.create().fg(Color.YELLOW).bold()), + Span.raw("/"), + Span.styled("G", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" top/bottom "), + Span.styled("f", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" follow "), + Span.styled("PgUp/PgDn", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" page "), + Span.styled("1-5", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" toggle levels"), + grep != null + ? Span.styled(" grep:" + grep, Style.create().fg(Color.MAGENTA)) + : Span.raw("")); + + frame.renderWidget(Paragraph.from(footer), area); + } + + private Line colorizeLogLine(String line) { + if (line.contains(" ERROR ") || line.contains(" FATAL ")) { + return Line.from(Span.styled(line, Style.create().fg(Color.RED))); + } else if (line.contains(" WARN ")) { + return Line.from(Span.styled(line, Style.create().fg(Color.YELLOW))); + } else if (line.contains(" DEBUG ")) { + return Line.from(Span.styled(line, Style.create().dim())); + } else if (line.contains(" TRACE ")) { + return Line.from(Span.styled(line, Style.create().dim())); + } + return Line.from(Span.raw(line)); + } + + // ---- Data Loading ---- + + private void resolveIntegration() { + if (resolvedPid != null) { + // Check if still alive + try { + ProcessHandle.of(Long.parseLong(resolvedPid)).ifPresentOrElse( + ph -> { + if (!ph.isAlive()) { + resolvedPid = null; + integrationName = null; + logFilePath = null; + } + }, + () -> { + resolvedPid = null; + integrationName = null; + logFilePath = null; + }); + } catch (NumberFormatException e) { + // ignore + } + } + + if (resolvedPid == null) { + List pids = findPids(name); + if (!pids.isEmpty()) { + long pid = pids.get(0); + resolvedPid = Long.toString(pid); + logFilePath = CommandLineHelper.getCamelDir().resolve(resolvedPid + ".log"); + + // Load integration name + JsonObject root = loadStatus(pid); + if (root != null) { + JsonObject context = (JsonObject) root.get("context"); + if (context != null) { + integrationName = context.getString("name"); + if ("CamelJBang".equals(integrationName)) { + ProcessHandle.of(pid).ifPresent( + ph -> integrationName = ProcessHelper.extractName(root, ph)); + } + } + } + } + } + } + + private void refreshLogData() { + lastRefresh = System.currentTimeMillis(); + readLogFile(); + applyFilters(); + } + + private void readLogFile() { + logLines.clear(); + if (logFilePath == null || !Files.exists(logFilePath)) { + return; + } + try (RandomAccessFile raf = new RandomAccessFile(logFilePath.toFile(), "r")) { + long length = raf.length(); + long startPos = Math.max(0, length - MAX_READ_BYTES); + raf.seek(startPos); + if (startPos > 0) { + raf.readLine(); // skip partial line + } + byte[] remaining = new byte[(int) (length - raf.getFilePointer())]; + raf.readFully(remaining); + String content = new String(remaining, StandardCharsets.UTF_8); + String[] lines = content.split("\n", -1); + for (String line : lines) { + if (!line.isEmpty()) { + logLines.add(line); + } + } + } catch (IOException e) { + // ignore + } + } + + private void applyFilters() { + filteredLines.clear(); + for (String line : logLines) { + if (!matchesLevelFilter(line)) { + continue; + } + if (grep != null && !grep.isEmpty() && !line.contains(grep)) { + continue; + } + filteredLines.add(line); + } + } + + private boolean matchesLevelFilter(String line) { + if (line.contains(" ERROR ") || line.contains(" FATAL ")) { + return showError; + } else if (line.contains(" WARN ")) { + return showWarn; + } else if (line.contains(" DEBUG ")) { + return showDebug; + } else if (line.contains(" TRACE ")) { + return showTrace; + } + // Lines without a recognized level are treated as INFO + return showInfo; + } + + // ---- Helpers ---- + + private List findPids(String name) { + List pids = new ArrayList<>(); + final long cur = ProcessHandle.current().pid(); + String pattern = name; + if (!pattern.matches("\\d+") && !pattern.endsWith("*")) { + pattern = pattern + "*"; + } + final String pat = pattern; + ProcessHandle.allProcesses() + .filter(ph -> ph.pid() != cur) + .forEach(ph -> { + JsonObject root = loadStatus(ph.pid()); + if (root != null) { + String pName = ProcessHelper.extractName(root, ph); + pName = FileUtil.onlyName(pName); + if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { + pids.add(ph.pid()); + } else { + JsonObject context = (JsonObject) root.get("context"); + if (context != null) { + pName = context.getString("name"); + if ("CamelJBang".equals(pName)) { + pName = null; + } + if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { + pids.add(ph.pid()); + } + } + } + } + }); + return pids; + } + + private JsonObject loadStatus(long pid) { + try { + Path f = getStatusFile(Long.toString(pid)); + if (f != null && Files.exists(f)) { + String text = Files.readString(f); + return (JsonObject) Jsoner.deserialize(text); + } + } catch (Exception e) { + // ignore + } + return null; + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java new file mode 100644 index 0000000000000..df8d3dc0dfae3 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -0,0 +1,1301 @@ +/* + * 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.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +import dev.tamboui.style.Overflow; +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.tui.TuiRunner; +import dev.tamboui.tui.event.Event; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.TickEvent; +import dev.tamboui.widgets.barchart.Bar; +import dev.tamboui.widgets.barchart.BarChart; +import dev.tamboui.widgets.barchart.BarGroup; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.gauge.Gauge; +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 dev.tamboui.widgets.tabs.Tabs; +import dev.tamboui.widgets.tabs.TabsState; +import org.apache.camel.dsl.jbang.core.commands.CamelCommand; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; +import org.apache.camel.dsl.jbang.core.common.ProcessHelper; +import org.apache.camel.dsl.jbang.core.common.VersionHelper; +import org.apache.camel.support.PatternHelper; +import org.apache.camel.util.FileUtil; +import org.apache.camel.util.TimeUtils; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +import static org.apache.camel.dsl.jbang.core.common.CamelCommandHelper.extractState; + +@Command(name = "monitor", + description = "Live TUI dashboard for monitoring Camel integrations", + sortOptions = false) +public class CamelMonitor extends CamelCommand { + + private static final long VANISH_DURATION_MS = 6000; + private static final long DEFAULT_REFRESH_MS = 100; + private static final int MAX_SPARKLINE_POINTS = 60; + private static final int MAX_LOG_LINES = 200; + + // Tab indices + private static final int TAB_OVERVIEW = 0; + private static final int TAB_ROUTES = 1; + private static final int TAB_HEALTH = 2; + private static final int TAB_ENDPOINTS = 3; + private static final int TAB_LOG = 4; + + @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1") + String name = "*"; + + @CommandLine.Option(names = { "--refresh" }, + description = "Refresh interval in milliseconds (default: ${DEFAULT-VALUE})", + defaultValue = "100") + long refreshInterval = DEFAULT_REFRESH_MS; + + // State + private final AtomicReference> data = new AtomicReference<>(Collections.emptyList()); + private final Map vanishing = new ConcurrentHashMap<>(); + private final TableState overviewTableState = new TableState(); + private final TableState routeTableState = new TableState(); + private final TableState healthTableState = new TableState(); + private final TableState endpointTableState = new TableState(); + private final TabsState tabsState = new TabsState(TAB_OVERVIEW); + + // Sparkline: throughput history per PID (one point per second) + private final Map> throughputHistory = new ConcurrentHashMap<>(); + // Sliding window of [timestamp, exchangesTotal] samples for smoothing + private final Map> throughputSamples = new ConcurrentHashMap<>(); + // Track last time a sparkline point was recorded + private final Map previousExchangesTime = new ConcurrentHashMap<>(); + + // Log state + private final List logLines = new ArrayList<>(); + private int logScroll; + + // Selected integration for detail views + private String selectedPid; + + private volatile long lastRefresh; + + public CamelMonitor(CamelJBangMain main) { + super(main); + } + + @Override + public Integer doCall() throws Exception { + // Eagerly load classes used by the input reader thread and picocli + // post-processing to avoid ClassNotFoundException during shutdown + // when the Spring Boot LaunchedClassLoader may already be closing + try { + Class.forName("dev.tamboui.tui.event.KeyModifiers"); + Class.forName("dev.tamboui.tui.event.KeyEvent"); + Class.forName("dev.tamboui.tui.event.KeyCode"); + Class.forName("picocli.CommandLine$IExitCodeGenerator"); + } catch (ClassNotFoundException e) { + // ignore + } + + // Initial data load + refreshData(); + + try (var tui = TuiRunner.create()) { + // Intercept Ctrl+C: quit the TUI cleanly instead of letting + // the JVM tear down the classloader while we're still running + sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit()); + tui.run( + this::handleEvent, + this::render); + } + return 0; + } + + // ---- Event Handling ---- + + private boolean handleEvent(Event event, TuiRunner runner) { + if (event instanceof KeyEvent ke) { + // Global keys + if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) { + // If in a detail tab, go back to overview first + if (tabsState.selected() != TAB_OVERVIEW) { + tabsState.select(TAB_OVERVIEW); + selectedPid = null; + return true; + } + runner.quit(); + return true; + } + if (ke.isChar('r')) { + refreshData(); + return true; + } + + // Tab switching with number keys + if (ke.isChar('1')) { + tabsState.select(TAB_OVERVIEW); + selectedPid = null; + return true; + } + if (ke.isChar('2')) { + selectCurrentIntegration(); + tabsState.select(TAB_ROUTES); + return true; + } + if (ke.isChar('3')) { + selectCurrentIntegration(); + tabsState.select(TAB_HEALTH); + return true; + } + if (ke.isChar('4')) { + selectCurrentIntegration(); + tabsState.select(TAB_ENDPOINTS); + return true; + } + if (ke.isChar('5')) { + selectCurrentIntegration(); + tabsState.select(TAB_LOG); + logScroll = 0; + return true; + } + + // Tab cycling + if (ke.isKey(KeyCode.TAB)) { + int next = (tabsState.selected() + 1) % 5; + if (next != TAB_OVERVIEW) { + selectCurrentIntegration(); + } + tabsState.select(next); + if (next == TAB_LOG) { + logScroll = 0; + } + return true; + } + + // Navigation + if (ke.isUp()) { + navigateUp(); + return true; + } + if (ke.isDown()) { + navigateDown(); + return true; + } + if (ke.isKey(KeyCode.PAGE_UP)) { + if (tabsState.selected() == TAB_LOG) { + logScroll = Math.max(0, logScroll - 20); + } + return true; + } + if (ke.isKey(KeyCode.PAGE_DOWN)) { + if (tabsState.selected() == TAB_LOG) { + logScroll += 20; + } + return true; + } + + // Enter to drill into selected integration + if (ke.isKey(KeyCode.ENTER) && tabsState.selected() == TAB_OVERVIEW) { + selectCurrentIntegration(); + if (selectedPid != null) { + tabsState.select(TAB_ROUTES); + } + return true; + } + } + if (event instanceof TickEvent) { + long now = System.currentTimeMillis(); + if (now - lastRefresh >= refreshInterval) { + refreshData(); + } + return true; + } + return false; + } + + private void selectCurrentIntegration() { + if (selectedPid != null) { + return; + } + List infos = data.get().stream().filter(i -> !i.vanishing).toList(); + Integer sel = overviewTableState.selected(); + if (sel != null && sel >= 0 && sel < infos.size()) { + selectedPid = infos.get(sel).pid; + } else if (infos.size() == 1) { + selectedPid = infos.get(0).pid; + } + } + + private void navigateUp() { + switch (tabsState.selected()) { + case TAB_OVERVIEW -> overviewTableState.selectPrevious(); + case TAB_ROUTES -> routeTableState.selectPrevious(); + case TAB_HEALTH -> healthTableState.selectPrevious(); + case TAB_ENDPOINTS -> endpointTableState.selectPrevious(); + case TAB_LOG -> logScroll = Math.max(0, logScroll - 1); + } + } + + private void navigateDown() { + List infos = data.get().stream().filter(i -> !i.vanishing).toList(); + switch (tabsState.selected()) { + case TAB_OVERVIEW -> overviewTableState.selectNext(infos.size()); + case TAB_ROUTES -> { + IntegrationInfo info = findSelectedIntegration(); + routeTableState.selectNext(info != null ? info.routes.size() : 0); + } + case TAB_HEALTH -> { + IntegrationInfo info = findSelectedIntegration(); + healthTableState.selectNext(info != null ? info.healthChecks.size() : 0); + } + case TAB_ENDPOINTS -> { + IntegrationInfo info = findSelectedIntegration(); + endpointTableState.selectNext(info != null ? info.endpoints.size() : 0); + } + case TAB_LOG -> logScroll++; + } + } + + // ---- Rendering ---- + + private void render(Frame frame) { + Rect area = frame.area(); + + // Layout: header (3 rows) + tabs (2 rows) + content (fill) + footer (1 row) + List mainChunks = Layout.vertical() + .constraints( + Constraint.length(3), + Constraint.length(2), + Constraint.fill(), + Constraint.length(1)) + .split(area); + + renderHeader(frame, mainChunks.get(0)); + renderTabs(frame, mainChunks.get(1)); + renderContent(frame, mainChunks.get(2)); + renderFooter(frame, mainChunks.get(3)); + } + + private void renderHeader(Frame frame, Rect area) { + List infos = data.get(); + String camelVersion = VersionHelper.extractCamelVersion(); + long activeCount = infos.stream().filter(i -> !i.vanishing).count(); + + Line titleLine = Line.from( + Span.styled(" Camel Monitor", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()), + Span.raw(" "), + Span.styled(camelVersion != null ? "v" + camelVersion : "", Style.create().fg(Color.GREEN)), + Span.raw(" "), + Span.styled(activeCount + " integration(s)", Style.create().fg(Color.CYAN))); + + Block headerBlock = Block.builder() + .borderType(BorderType.ROUNDED) + .title(" Apache Camel ") + .build(); + + frame.renderWidget( + Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(), + area); + } + + private void renderTabs(Frame frame, Rect area) { + String sel = selectedPid != null ? " [" + selectedName() + "]" : ""; + Tabs tabs = Tabs.builder() + .titles( + " 1 Overview ", + " 2 Routes" + sel + " ", + " 3 Health" + sel + " ", + " 4 Endpoints" + sel + " ", + " 5 Log" + sel + " ") + .highlightStyle(Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()) + .divider(Span.styled(" | ", Style.create().dim())) + .build(); + + frame.renderStatefulWidget(tabs, area, tabsState); + } + + private void renderContent(Frame frame, Rect area) { + // Clear the content area to prevent artifacts when switching tabs + frame.buffer().clear(area); + switch (tabsState.selected()) { + case TAB_OVERVIEW -> renderOverview(frame, area); + case TAB_ROUTES -> renderRoutes(frame, area); + case TAB_HEALTH -> renderHealth(frame, area); + case TAB_ENDPOINTS -> renderEndpoints(frame, area); + case TAB_LOG -> renderLog(frame, area); + } + } + + // ---- Tab 1: Overview ---- + + private void renderOverview(Frame frame, Rect area) { + List infos = data.get(); + + // Split: table (fill) + sparkline (height 8) if we have data + boolean hasSparkline = !throughputHistory.isEmpty(); + List chunks; + if (hasSparkline) { + chunks = Layout.vertical() + .constraints(Constraint.fill(), Constraint.length(8)) + .split(area); + } else { + chunks = List.of(area); + } + + // Integration table + List rows = new ArrayList<>(); + for (IntegrationInfo info : infos) { + if (info.vanishing) { + long elapsed = System.currentTimeMillis() - info.vanishStart; + float fade = 1.0f - Math.min(1.0f, (float) elapsed / VANISH_DURATION_MS); + int gray = (int) (100 * fade); + Style dimStyle = Style.create().fg(Color.indexed(232 + Math.min(gray / 4, 23))); + + rows.add(Row.from( + Cell.from(Span.styled(info.pid, dimStyle)), + Cell.from(Span.styled(truncate(info.name, 25), dimStyle)), + Cell.from(Span.styled(info.platform != null ? info.platform : "", dimStyle)), + Cell.from(Span.styled("\u2716 Stopped", Style.create().fg(Color.RED).dim())), + Cell.from(Span.styled(info.ago != null ? info.ago : "", dimStyle)), + Cell.from(Span.styled("", dimStyle)), + Cell.from(Span.styled("", dimStyle)), + Cell.from(Span.styled("", dimStyle)))); + } else { + Style statusStyle = switch (extractState(info.state)) { + case "Started" -> Style.create().fg(Color.GREEN); + case "Stopped" -> Style.create().fg(Color.RED); + default -> Style.create().fg(Color.YELLOW); + }; + + rows.add(Row.from( + Cell.from(info.pid), + Cell.from(Span.styled(truncate(info.name, 25), Style.create().fg(Color.CYAN))), + Cell.from(info.platform != null ? info.platform : ""), + Cell.from(Span.styled(extractState(info.state), statusStyle)), + Cell.from(info.ago != null ? info.ago : ""), + Cell.from(info.throughput != null ? info.throughput : ""), + Cell.from(formatMemory(info.heapMemUsed, info.heapMemMax)), + Cell.from(formatThreads(info.threadCount, info.peakThreadCount)))); + } + } + + Row header = Row.from( + Cell.from(Span.styled("PID", Style.create().bold())), + Cell.from(Span.styled("NAME", Style.create().bold())), + Cell.from(Span.styled("PLATFORM", Style.create().bold())), + Cell.from(Span.styled("STATUS", Style.create().bold())), + Cell.from(Span.styled("AGE", Style.create().bold())), + Cell.from(Span.styled("THRUPUT", Style.create().bold())), + Cell.from(Span.styled("HEAP", Style.create().bold())), + Cell.from(Span.styled("THREADS", Style.create().bold()))); + + Table table = Table.builder() + .rows(rows) + .header(header) + .widths( + Constraint.length(8), + Constraint.fill(), + Constraint.length(10), + Constraint.length(10), + Constraint.length(8), + Constraint.length(10), + Constraint.length(15), + Constraint.length(12)) + .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .block(Block.builder().borderType(BorderType.ROUNDED).title(" Integrations ").build()) + .build(); + + frame.renderStatefulWidget(table, chunks.get(0), overviewTableState); + + // Bar chart for throughput + if (hasSparkline && chunks.size() > 1) { + // Merge all throughput histories for overview chart + LinkedList merged = new LinkedList<>(); + for (int i = 0; i < MAX_SPARKLINE_POINTS; i++) { + long sum = 0; + for (LinkedList hist : throughputHistory.values()) { + if (i < hist.size()) { + sum += hist.get(hist.size() - 1 - i); + } + } + merged.addFirst(sum); + } + + // Compute stats for title display + long maxTp = merged.stream().mapToLong(Long::longValue).max().orElse(0); + long curTp = merged.isEmpty() ? 0 : merged.get(merged.size() - 1); + String chartTitle = String.format(" Throughput: %d msg/s (peak: %d) ", curTp, maxTp); + + // Build bar groups — one bar per data point + List groups = new ArrayList<>(); + for (Long value : merged) { + groups.add(BarGroup.of(Bar.of(value))); + } + + BarChart barChart = BarChart.builder() + .data(groups) + .barWidth(1) + .barGap(0) + .barStyle(Style.create().fg(Color.GREEN)) + .block(Block.builder().borderType(BorderType.ROUNDED).title(chartTitle).build()) + .build(); + + frame.renderWidget(barChart, chunks.get(1)); + } + } + + // ---- Tab 2: Routes ---- + + private void renderRoutes(Frame frame, Rect area) { + IntegrationInfo info = findSelectedIntegration(); + if (info == null) { + renderNoSelection(frame, area); + return; + } + + // Split: routes table (top half) + processors table (bottom half) + List chunks = Layout.vertical() + .constraints(Constraint.percentage(45), Constraint.percentage(55)) + .split(area); + + // Routes table + List routeRows = new ArrayList<>(); + for (RouteInfo route : info.routes) { + Style stateStyle = "Started".equals(route.state) + ? Style.create().fg(Color.GREEN) + : Style.create().fg(Color.RED); + + routeRows.add(Row.from( + Cell.from(Span.styled(truncate(route.routeId, 12), Style.create().fg(Color.CYAN))), + Cell.from(truncate(route.from, 30)), + Cell.from(Span.styled(route.state, stateStyle)), + Cell.from(route.uptime != null ? route.uptime : ""), + Cell.from(route.throughput != null ? route.throughput : ""), + Cell.from(String.valueOf(route.total)), + Cell.from(route.failed > 0 + ? Span.styled(String.valueOf(route.failed), Style.create().fg(Color.RED)) + : Span.raw("0")), + Cell.from(route.meanTime + "/" + route.maxTime))); + } + + Table routeTable = Table.builder() + .rows(routeRows) + .header(Row.from( + Cell.from(Span.styled("ROUTE", Style.create().bold())), + Cell.from(Span.styled("FROM", Style.create().bold())), + Cell.from(Span.styled("STATE", Style.create().bold())), + Cell.from(Span.styled("UPTIME", Style.create().bold())), + Cell.from(Span.styled("THRUPUT", Style.create().bold())), + Cell.from(Span.styled("TOTAL", Style.create().bold())), + Cell.from(Span.styled("FAILED", Style.create().bold())), + Cell.from(Span.styled("MEAN/MAX", Style.create().bold())))) + .widths( + Constraint.length(12), + Constraint.fill(), + Constraint.length(10), + Constraint.length(8), + Constraint.length(10), + Constraint.length(8), + Constraint.length(8), + Constraint.length(12)) + .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" Routes [" + info.name + "] ").build()) + .build(); + + frame.renderStatefulWidget(routeTable, chunks.get(0), routeTableState); + + // Processors for selected route + Integer selectedRoute = routeTableState.selected(); + if (selectedRoute != null && selectedRoute >= 0 && selectedRoute < info.routes.size()) { + RouteInfo route = info.routes.get(selectedRoute); + renderProcessors(frame, chunks.get(1), route); + } else if (!info.routes.isEmpty()) { + renderProcessors(frame, chunks.get(1), info.routes.get(0)); + } else { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from(Span.styled("No routes", Style.create().dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED).title(" Processors ").build()) + .build(), + chunks.get(1)); + } + } + + private void renderProcessors(Frame frame, Rect area, RouteInfo route) { + List rows = new ArrayList<>(); + for (ProcessorInfo proc : route.processors) { + String indent = " ".repeat(proc.level); + Style nameStyle = proc.failed > 0 ? Style.create().fg(Color.RED) : Style.create().fg(Color.CYAN); + + rows.add(Row.from( + Cell.from(Span.styled(indent + proc.id, nameStyle)), + Cell.from(proc.processor), + Cell.from(String.valueOf(proc.total)), + Cell.from(proc.failed > 0 + ? Span.styled(String.valueOf(proc.failed), Style.create().fg(Color.RED)) + : Span.raw("0")), + Cell.from(proc.meanTime + "ms"), + Cell.from(proc.maxTime + "ms"), + Cell.from(proc.lastTime + "ms"))); + } + + Table table = Table.builder() + .rows(rows) + .header(Row.from( + Cell.from(Span.styled("PROCESSOR", Style.create().bold())), + Cell.from(Span.styled("TYPE", Style.create().bold())), + Cell.from(Span.styled("TOTAL", Style.create().bold())), + Cell.from(Span.styled("FAILED", Style.create().bold())), + Cell.from(Span.styled("MEAN", Style.create().bold())), + Cell.from(Span.styled("MAX", Style.create().bold())), + Cell.from(Span.styled("LAST", Style.create().bold())))) + .widths( + Constraint.fill(), + Constraint.length(15), + Constraint.length(8), + Constraint.length(8), + Constraint.length(8), + Constraint.length(8), + Constraint.length(8)) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" Processors [" + route.routeId + "] ").build()) + .build(); + + frame.renderStatefulWidget(table, area, new TableState()); + } + + // ---- Tab 3: Health ---- + + private void renderHealth(Frame frame, Rect area) { + IntegrationInfo info = findSelectedIntegration(); + if (info == null) { + renderNoSelection(frame, area); + return; + } + + // Split: health table (fill) + memory gauge (3 rows) + List chunks = Layout.vertical() + .constraints(Constraint.fill(), Constraint.length(3)) + .split(area); + + List rows = new ArrayList<>(); + for (HealthCheckInfo hc : info.healthChecks) { + Style stateStyle; + String icon; + if ("UP".equals(hc.state)) { + stateStyle = Style.create().fg(Color.GREEN); + icon = "\u2714 "; + } else if ("DOWN".equals(hc.state)) { + stateStyle = Style.create().fg(Color.RED); + icon = "\u2716 "; + } else { + stateStyle = Style.create().fg(Color.YELLOW); + icon = "\u26A0 "; + } + + rows.add(Row.from( + Cell.from(Span.styled(truncate(hc.group != null ? hc.group : "", 12), Style.create().dim())), + Cell.from(Span.styled(truncate(hc.name, 30), Style.create().fg(Color.CYAN))), + Cell.from(Span.styled(icon + hc.state, stateStyle)), + Cell.from(hc.message != null ? truncate(hc.message, 50) : ""))); + } + + if (rows.isEmpty()) { + rows.add(Row.from( + Cell.from(""), + Cell.from(Span.styled("No health checks registered", Style.create().dim())), + Cell.from(""), + Cell.from(""))); + } + + Table table = Table.builder() + .rows(rows) + .header(Row.from( + Cell.from(Span.styled("GROUP", Style.create().bold())), + Cell.from(Span.styled("NAME", Style.create().bold())), + Cell.from(Span.styled("STATUS", Style.create().bold())), + Cell.from(Span.styled("MESSAGE", Style.create().bold())))) + .widths( + Constraint.length(12), + Constraint.length(30), + Constraint.length(12), + Constraint.fill()) + .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" Health [" + info.name + "] ").build()) + .build(); + + frame.renderStatefulWidget(table, chunks.get(0), healthTableState); + + // Memory gauge + if (info.heapMemMax > 0) { + int pct = (int) (100.0 * info.heapMemUsed / info.heapMemMax); + Style gaugeStyle = pct > 80 ? Style.create().fg(Color.RED) + : pct > 60 ? Style.create().fg(Color.YELLOW) : Style.create().fg(Color.GREEN); + Gauge gauge = Gauge.builder() + .percent(pct) + .label(String.format("Heap: %s / %s (%d%%)", formatBytes(info.heapMemUsed), + formatBytes(info.heapMemMax), pct)) + .gaugeStyle(gaugeStyle) + .block(Block.builder().borderType(BorderType.ROUNDED).build()) + .build(); + + frame.renderWidget(gauge, chunks.get(1)); + } + } + + // ---- Tab 4: Endpoints ---- + + private void renderEndpoints(Frame frame, Rect area) { + IntegrationInfo info = findSelectedIntegration(); + if (info == null) { + renderNoSelection(frame, area); + return; + } + + List rows = new ArrayList<>(); + for (EndpointInfo ep : info.endpoints) { + Style dirStyle = switch (ep.direction) { + case "in" -> Style.create().fg(Color.GREEN); + case "out" -> Style.create().fg(Color.BLUE); + default -> Style.create().fg(Color.YELLOW); + }; + String arrow = switch (ep.direction) { + case "in" -> "\u2192 "; + case "out" -> "\u2190 "; + default -> "\u2194 "; + }; + + rows.add(Row.from( + Cell.from(Span.styled(ep.component, Style.create().fg(Color.CYAN))), + Cell.from(Span.styled(arrow + ep.direction, dirStyle)), + Cell.from(truncate(ep.uri, 60)), + Cell.from(ep.routeId != null ? ep.routeId : ""))); + } + + if (rows.isEmpty()) { + rows.add(Row.from( + Cell.from(""), + Cell.from(Span.styled("No endpoints", Style.create().dim())), + Cell.from(""), + Cell.from(""))); + } + + Table table = Table.builder() + .rows(rows) + .header(Row.from( + Cell.from(Span.styled("COMPONENT", Style.create().bold())), + Cell.from(Span.styled("DIR", Style.create().bold())), + Cell.from(Span.styled("URI", Style.create().bold())), + Cell.from(Span.styled("ROUTE", Style.create().bold())))) + .widths( + Constraint.length(15), + Constraint.length(8), + Constraint.fill(), + Constraint.length(12)) + .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" Endpoints [" + info.name + "] ").build()) + .build(); + + frame.renderStatefulWidget(table, area, endpointTableState); + } + + // ---- Tab 5: Log ---- + + private void renderLog(Frame frame, Rect area) { + IntegrationInfo info = findSelectedIntegration(); + if (info == null) { + renderNoSelection(frame, area); + return; + } + + // Read log lines + readLogFile(info.pid); + + Block logBlock = Block.builder().borderType(BorderType.ROUNDED) + .title(" Log [" + info.name + "] ") + .build(); + + int innerHeight = Math.max(1, area.height() - 2); // account for border + int totalLines = logLines.size(); + + // Auto-scroll to bottom if logScroll is 0 (default) + int startLine; + if (logScroll == 0) { + startLine = Math.max(0, totalLines - innerHeight); + } else { + startLine = Math.max(0, Math.min(logScroll, totalLines - innerHeight)); + } + + List visibleLines = new ArrayList<>(); + for (int i = startLine; i < Math.min(startLine + innerHeight, totalLines); i++) { + visibleLines.add(colorizeLogLine(logLines.get(i))); + } + + // Fill remaining space + while (visibleLines.size() < innerHeight) { + visibleLines.add(Line.from(Span.raw(""))); + } + + Paragraph logParagraph = Paragraph.builder() + .text(Text.from(visibleLines)) + .overflow(Overflow.CLIP) + .block(logBlock) + .build(); + + frame.renderWidget(logParagraph, area); + } + + private Line colorizeLogLine(String line) { + if (line.contains(" ERROR ") || line.contains(" FATAL ")) { + return Line.from(Span.styled(line, Style.create().fg(Color.RED))); + } else if (line.contains(" WARN ")) { + return Line.from(Span.styled(line, Style.create().fg(Color.YELLOW))); + } else if (line.contains(" DEBUG ") || line.contains(" TRACE ")) { + return Line.from(Span.styled(line, Style.create().dim())); + } + return Line.from(Span.raw(line)); + } + + private void readLogFile(String pid) { + logLines.clear(); + Path logFile = CommandLineHelper.getCamelDir().resolve(pid + ".log"); + if (!Files.exists(logFile)) { + return; + } + try (RandomAccessFile raf = new RandomAccessFile(logFile.toFile(), "r")) { + long length = raf.length(); + // Read last ~64KB to get recent lines + long startPos = Math.max(0, length - 64 * 1024); + raf.seek(startPos); + if (startPos > 0) { + raf.readLine(); // skip partial line + } + // Read remaining bytes and split into lines using proper encoding + byte[] remaining = new byte[(int) (length - raf.getFilePointer())]; + raf.readFully(remaining); + String content = new String(remaining, java.nio.charset.StandardCharsets.UTF_8); + String[] lines = content.split("\n", -1); + int start = Math.max(0, lines.length - MAX_LOG_LINES); + for (int i = start; i < lines.length; i++) { + String line = lines[i]; + if (!line.isEmpty()) { + logLines.add(line); + } + } + } catch (IOException e) { + // ignore + } + } + + // ---- Shared rendering ---- + + private void renderNoSelection(Frame frame, Rect area) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from( + Span.styled(" Select an integration from the Overview tab (press 1)", + Style.create().dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" No integration selected ").build()) + .build(), + area); + } + + private void renderFooter(Frame frame, Rect area) { + String refreshLabel = refreshInterval >= 1000 + ? (refreshInterval / 1000) + "s" + : refreshInterval + "ms"; + Line footer; + if (tabsState.selected() == TAB_OVERVIEW) { + footer = Line.from( + Span.styled(" q", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" quit "), + Span.styled("r", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" refresh "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" navigate "), + Span.styled("Enter", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" details "), + Span.styled("1-5", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" tabs "), + Span.styled("Refresh: " + refreshLabel, Style.create().dim())); + } else if (tabsState.selected() == TAB_LOG) { + footer = Line.from( + Span.styled(" Esc", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" back "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" scroll "), + Span.styled("PgUp/PgDn", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" page "), + Span.styled("1-5", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" tabs "), + Span.styled("Refresh: " + refreshLabel, Style.create().dim())); + } else { + footer = Line.from( + Span.styled(" Esc", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" back "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" navigate "), + Span.styled("1-5", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" tabs "), + Span.styled("Refresh: " + refreshLabel, Style.create().dim())); + } + + frame.renderWidget(Paragraph.from(footer), area); + } + + // ---- Data Loading ---- + + private void refreshData() { + lastRefresh = System.currentTimeMillis(); + try { + List infos = new ArrayList<>(); + List pids = findPids(name); + ProcessHandle.allProcesses() + .filter(ph -> pids.contains(ph.pid())) + .forEach(ph -> { + JsonObject root = loadStatus(ph.pid()); + if (root != null) { + IntegrationInfo info = parseIntegration(ph, root); + if (info != null) { + infos.add(info); + // Track throughput for sparkline + updateThroughputHistory(info); + } + } + }); + + // Detect disappeared integrations and start vanishing + Set livePids = infos.stream().map(i -> i.pid).collect(Collectors.toSet()); + List previous = data.get(); + for (IntegrationInfo prev : previous) { + if (!prev.vanishing && !livePids.contains(prev.pid) && !vanishing.containsKey(prev.pid)) { + vanishing.put(prev.pid, new VanishingInfo(prev, System.currentTimeMillis())); + } + } + + // Expire old vanishing entries + long now = System.currentTimeMillis(); + Iterator> it = vanishing.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + if (now - entry.getValue().startTime > VANISH_DURATION_MS) { + it.remove(); + throughputHistory.remove(entry.getKey()); + } else if (!livePids.contains(entry.getKey())) { + IntegrationInfo ghost = entry.getValue().info; + ghost.vanishing = true; + ghost.vanishStart = entry.getValue().startTime; + infos.add(ghost); + } else { + it.remove(); + } + } + + data.set(infos); + } catch (Exception e) { + // ignore refresh errors + } + } + + private void updateThroughputHistory(IntegrationInfo info) { + // Track exchangesTotal over a 1-second sliding window for stable throughput + long currentTotal = info.exchangesTotal; + long now = System.currentTimeMillis(); + + String pid = info.pid; + LinkedList samples = throughputSamples.computeIfAbsent(pid, k -> new LinkedList<>()); + samples.add(new long[] { now, currentTotal }); + + // Remove samples older than 1 second + while (!samples.isEmpty() && now - samples.get(0)[0] > 1000) { + samples.remove(0); + } + + // Compute throughput over the window + if (samples.size() >= 2) { + long[] oldest = samples.get(0); + long[] newest = samples.get(samples.size() - 1); + long deltaExchanges = newest[1] - oldest[1]; + long deltaTimeMs = newest[0] - oldest[0]; + long tp = deltaTimeMs > 0 ? (deltaExchanges * 1000) / deltaTimeMs : 0; + + LinkedList hist = throughputHistory.computeIfAbsent(pid, k -> new LinkedList<>()); + // Only add one point per second to keep the sparkline meaningful + Long lastTime = previousExchangesTime.get(pid); + if (lastTime == null || now - lastTime >= 1000) { + previousExchangesTime.put(pid, now); + hist.add(tp); + while (hist.size() > MAX_SPARKLINE_POINTS) { + hist.remove(0); + } + } + } + } + + @SuppressWarnings("unchecked") + private IntegrationInfo parseIntegration(ProcessHandle ph, JsonObject root) { + JsonObject context = (JsonObject) root.get("context"); + if (context == null) { + return null; + } + + IntegrationInfo info = new IntegrationInfo(); + info.name = context.getString("name"); + if ("CamelJBang".equals(info.name)) { + info.name = ProcessHelper.extractName(root, ph); + } + info.pid = Long.toString(ph.pid()); + info.uptime = extractSince(ph); + info.ago = TimeUtils.printSince(info.uptime); + info.state = context.getInteger("phase"); + + JsonObject runtime = (JsonObject) root.get("runtime"); + info.platform = runtime != null ? runtime.getString("platform") : null; + + Map stats = context.getMap("statistics"); + if (stats != null) { + Object thp = stats.get("exchangesThroughput"); + if (thp != null) { + info.throughput = thp.toString(); + } + info.exchangesTotal = objToLong(stats.get("exchangesTotal")); + } + + JsonObject mem = (JsonObject) root.get("memory"); + if (mem != null) { + info.heapMemUsed = mem.getLong("heapMemoryUsed"); + info.heapMemMax = mem.getLong("heapMemoryMax"); + } + + JsonObject threads = (JsonObject) root.get("threads"); + if (threads != null) { + info.threadCount = threads.getInteger("threadCount"); + info.peakThreadCount = threads.getInteger("peakThreadCount"); + } + + // Parse routes + JsonArray routes = (JsonArray) root.get("routes"); + if (routes != null) { + for (Object r : routes) { + JsonObject rj = (JsonObject) r; + RouteInfo ri = new RouteInfo(); + ri.routeId = rj.getString("routeId"); + ri.from = rj.getString("from"); + ri.state = rj.getString("state"); + ri.uptime = rj.getString("uptime"); + + Map rs = rj.getMap("statistics"); + if (rs != null) { + ri.throughput = objToString(rs.get("exchangesThroughput")); + ri.total = objToLong(rs.get("exchangesTotal")); + ri.failed = objToLong(rs.get("exchangesFailed")); + ri.meanTime = objToLong(rs.get("meanProcessingTime")); + ri.maxTime = objToLong(rs.get("maxProcessingTime")); + } + + // Parse processors + JsonArray procs = (JsonArray) rj.get("processors"); + if (procs != null) { + for (Object p : procs) { + JsonObject pj = (JsonObject) p; + ProcessorInfo pi = new ProcessorInfo(); + pi.id = pj.getString("id"); + pi.processor = pj.getString("processor"); + pi.level = pj.getIntegerOrDefault("level", 0); + + Map ps = pj.getMap("statistics"); + if (ps != null) { + pi.total = objToLong(ps.get("exchangesTotal")); + pi.failed = objToLong(ps.get("exchangesFailed")); + pi.meanTime = objToLong(ps.get("meanProcessingTime")); + pi.maxTime = objToLong(ps.get("maxProcessingTime")); + pi.lastTime = objToLong(ps.get("lastProcessingTime")); + } + + ri.processors.add(pi); + } + } + + info.routes.add(ri); + } + } + + // Parse health checks + JsonObject healthChecks = (JsonObject) root.get("healthChecks"); + if (healthChecks != null) { + JsonArray checks = (JsonArray) healthChecks.get("checks"); + if (checks != null) { + for (Object c : checks) { + JsonObject cj = (JsonObject) c; + HealthCheckInfo hc = new HealthCheckInfo(); + hc.group = cj.getString("group"); + hc.name = cj.getString("id"); + hc.state = cj.getString("state"); + // Extract message from details if available + JsonObject details = (JsonObject) cj.get("details"); + if (details != null && details.containsKey("failure.error.message")) { + hc.message = details.getString("failure.error.message"); + } + info.healthChecks.add(hc); + } + } + } + + // Parse endpoints (top-level "endpoints" is a JsonObject with nested "endpoints" array) + JsonObject endpointsObj = (JsonObject) root.get("endpoints"); + if (endpointsObj != null) { + JsonArray endpointList = (JsonArray) endpointsObj.get("endpoints"); + if (endpointList != null) { + for (Object e : endpointList) { + JsonObject ej = (JsonObject) e; + EndpointInfo ep = new EndpointInfo(); + ep.uri = ej.getString("uri"); + ep.direction = ej.getString("direction"); + ep.routeId = ej.getString("routeId"); + // Extract component from URI (e.g., "timer://tick" -> "timer") + if (ep.uri != null) { + int idx = ep.uri.indexOf(':'); + ep.component = idx > 0 ? ep.uri.substring(0, idx) : ep.uri; + } + info.endpoints.add(ep); + } + } + } + + return info; + } + + // ---- Helpers ---- + + private IntegrationInfo findSelectedIntegration() { + if (selectedPid == null) { + return null; + } + return data.get().stream() + .filter(i -> selectedPid.equals(i.pid) && !i.vanishing) + .findFirst().orElse(null); + } + + private String selectedName() { + IntegrationInfo info = findSelectedIntegration(); + return info != null ? truncate(info.name, 20) : "?"; + } + + private List findPids(String name) { + List pids = new ArrayList<>(); + final long cur = ProcessHandle.current().pid(); + String pattern = name; + if (!pattern.matches("\\d+") && !pattern.endsWith("*")) { + pattern = pattern + "*"; + } + final String pat = pattern; + ProcessHandle.allProcesses() + .filter(ph -> ph.pid() != cur) + .forEach(ph -> { + JsonObject root = loadStatus(ph.pid()); + if (root != null) { + String pName = ProcessHelper.extractName(root, ph); + pName = FileUtil.onlyName(pName); + if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { + pids.add(ph.pid()); + } else { + JsonObject context = (JsonObject) root.get("context"); + if (context != null) { + pName = context.getString("name"); + if ("CamelJBang".equals(pName)) { + pName = null; + } + if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { + pids.add(ph.pid()); + } + } + } + } + }); + return pids; + } + + private JsonObject loadStatus(long pid) { + try { + Path f = getStatusFile(Long.toString(pid)); + if (f != null && Files.exists(f)) { + String text = Files.readString(f); + return (JsonObject) Jsoner.deserialize(text); + } + } catch (Exception e) { + // ignore + } + return null; + } + + private static long extractSince(ProcessHandle ph) { + return ph.info().startInstant().map(Instant::toEpochMilli).orElse(0L); + } + + private static String truncate(String s, int max) { + if (s == null) { + return ""; + } + return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s; + } + + private static String formatMemory(long used, long max) { + if (used <= 0) { + return ""; + } + String u = formatBytes(used); + if (max > 0) { + return u + "/" + formatBytes(max); + } + return u; + } + + private static String formatBytes(long bytes) { + if (bytes < 1024) { + return bytes + "B"; + } + if (bytes < 1024 * 1024) { + return (bytes / 1024) + "K"; + } + return (bytes / (1024 * 1024)) + "M"; + } + + private static String formatThreads(int count, int peak) { + if (count <= 0) { + return ""; + } + return count + "/" + peak; + } + + private static String objToString(Object o) { + return o != null ? o.toString() : ""; + } + + private static long objToLong(Object o) { + if (o instanceof Number n) { + return n.longValue(); + } + if (o != null) { + try { + return Long.parseLong(o.toString()); + } catch (NumberFormatException e) { + // ignore + } + } + return 0; + } + + // ---- Data Classes ---- + + static class IntegrationInfo { + String pid; + String name; + String platform; + int state; + long uptime; + String ago; + String throughput; + long exchangesTotal; + long heapMemUsed; + long heapMemMax; + int threadCount; + int peakThreadCount; + boolean vanishing; + long vanishStart; + final List routes = new ArrayList<>(); + final List healthChecks = new ArrayList<>(); + final List endpoints = new ArrayList<>(); + } + + static class RouteInfo { + String routeId; + String from; + String state; + String uptime; + String throughput; + long total; + long failed; + long meanTime; + long maxTime; + final List processors = new ArrayList<>(); + } + + static class ProcessorInfo { + String id; + String processor; + int level; + long total; + long failed; + long meanTime; + long maxTime; + long lastTime; + } + + static class HealthCheckInfo { + String group; + String name; + String state; + String message; + } + + static class EndpointInfo { + String uri; + String component; + String direction; + String routeId; + } + + record VanishingInfo(IntegrationInfo info, long startTime) { + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java new file mode 100644 index 0000000000000..54305b18a31c1 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java @@ -0,0 +1,488 @@ +/* + * 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.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +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.tui.TuiRunner; +import dev.tamboui.tui.event.Event; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.TickEvent; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +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.dsl.jbang.core.commands.CamelCommand; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.ProcessHelper; +import org.apache.camel.support.PatternHelper; +import org.apache.camel.util.FileUtil; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command(name = "top-tui", + description = "Live TUI dashboard for route performance", + sortOptions = false) +public class CamelTopTui extends CamelCommand { + + private static final String[] SORT_COLUMNS = { "mean", "max", "total", "failed", "name" }; + + @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1") + String name = "*"; + + @CommandLine.Option(names = { "--refresh" }, + description = "Refresh interval in milliseconds (default: ${DEFAULT-VALUE})", + defaultValue = "500") + long refreshInterval = 500; + + @CommandLine.Option(names = { "--sort" }, + description = "Sort column: mean, max, total, failed, name (default: ${DEFAULT-VALUE})", + defaultValue = "mean") + String sort = "mean"; + + // State + private final AtomicReference> data = new AtomicReference<>(Collections.emptyList()); + private final TableState tableState = new TableState(); + private int sortIndex; + private volatile long lastRefresh; + + public CamelTopTui(CamelJBangMain main) { + super(main); + } + + @Override + public Integer doCall() throws Exception { + // Eagerly load classes used by the input reader thread and picocli + // post-processing to avoid ClassNotFoundException during shutdown + try { + Class.forName("dev.tamboui.tui.event.KeyModifiers"); + Class.forName("dev.tamboui.tui.event.KeyEvent"); + Class.forName("dev.tamboui.tui.event.KeyCode"); + Class.forName("picocli.CommandLine$IExitCodeGenerator"); + } catch (ClassNotFoundException e) { + // ignore + } + + // Resolve initial sort index + sortIndex = indexOfSort(sort); + + // Initial data load + refreshData(); + + try (var tui = TuiRunner.create()) { + sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit()); + tui.run(this::handleEvent, this::render); + } + return 0; + } + + // ---- Event Handling ---- + + private boolean handleEvent(Event event, TuiRunner runner) { + if (event instanceof KeyEvent ke) { + if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) { + runner.quit(); + return true; + } + if (ke.isChar('r')) { + refreshData(); + return true; + } + if (ke.isCharIgnoreCase('s')) { + // Cycle sort column + sortIndex = (sortIndex + 1) % SORT_COLUMNS.length; + sort = SORT_COLUMNS[sortIndex]; + sortData(); + return true; + } + if (ke.isUp()) { + tableState.selectPrevious(); + return true; + } + if (ke.isDown()) { + tableState.selectNext(data.get().size()); + return true; + } + } + if (event instanceof TickEvent) { + long now = System.currentTimeMillis(); + if (now - lastRefresh >= refreshInterval) { + refreshData(); + } + return true; + } + return false; + } + + // ---- Rendering ---- + + private void render(Frame frame) { + Rect area = frame.area(); + + // Layout: header (3 rows) + table (fill) + footer (1 row) + List chunks = Layout.vertical() + .constraints( + Constraint.length(3), + Constraint.fill(), + Constraint.length(1)) + .split(area); + + renderHeader(frame, chunks.get(0)); + renderTable(frame, chunks.get(1)); + renderFooter(frame, chunks.get(2)); + } + + private void renderHeader(Frame frame, Rect area) { + List rows = data.get(); + long totalExchanges = rows.stream().mapToLong(r -> r.total).sum(); + long totalFailed = rows.stream().mapToLong(r -> r.failed).sum(); + int routeCount = rows.size(); + long pidCount = rows.stream().map(r -> r.pid).distinct().count(); + + Line titleLine = Line.from( + Span.styled(" Camel Top", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()), + Span.raw(" "), + Span.styled(pidCount + " integration(s)", Style.create().fg(Color.CYAN)), + Span.raw(" "), + Span.styled(routeCount + " route(s)", Style.create().fg(Color.GREEN)), + Span.raw(" "), + Span.styled("total: " + totalExchanges, Style.create().fg(Color.WHITE)), + Span.raw(" "), + Span.styled("failed: " + totalFailed, + totalFailed > 0 ? Style.create().fg(Color.RED).bold() : Style.create().dim()), + Span.raw(" "), + Span.styled("sort: " + sort, Style.create().fg(Color.YELLOW))); + + Block headerBlock = Block.builder() + .borderType(BorderType.ROUNDED) + .title(" Apache Camel - Route Performance ") + .build(); + + frame.renderWidget( + Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(), + area); + } + + private void renderTable(Frame frame, Rect area) { + List rows = data.get(); + + List tableRows = new ArrayList<>(); + for (RouteRow r : rows) { + Style statusStyle = "Started".equals(r.state) + ? Style.create().fg(Color.GREEN) + : Style.create().fg(Color.RED); + + Style failStyle = r.failed > 0 + ? Style.create().fg(Color.RED).bold() + : Style.create(); + + tableRows.add(Row.from( + Cell.from(r.pid), + Cell.from(Span.styled(truncate(r.name, 20), Style.create().fg(Color.CYAN))), + Cell.from(Span.styled(truncate(r.routeId, 18), Style.create().fg(Color.WHITE))), + Cell.from(truncate(r.from, 30)), + Cell.from(Span.styled(r.state != null ? r.state : "", statusStyle)), + Cell.from(String.valueOf(r.total)), + Cell.from(Span.styled(String.valueOf(r.failed), failStyle)), + Cell.from(String.valueOf(r.inflight)), + Cell.from(r.mean >= 0 ? String.valueOf(r.mean) : ""), + Cell.from(r.min >= 0 ? String.valueOf(r.min) : ""), + Cell.from(r.max >= 0 ? String.valueOf(r.max) : ""), + Cell.from(r.last >= 0 ? String.valueOf(r.last) : ""), + Cell.from(r.throughput != null ? r.throughput : ""))); + } + + Row header = Row.from( + Cell.from(Span.styled("PID", Style.create().bold())), + Cell.from(Span.styled("NAME", Style.create().bold())), + Cell.from(Span.styled("ROUTE", Style.create().bold())), + Cell.from(Span.styled("FROM", Style.create().bold())), + Cell.from(Span.styled("STATUS", Style.create().bold())), + Cell.from(Span.styled("TOTAL", Style.create().bold())), + Cell.from(Span.styled("FAIL", Style.create().bold())), + Cell.from(Span.styled("INFLIGHT", Style.create().bold())), + Cell.from(Span.styled(sortLabel("MEAN", "mean"), sortStyle("mean"))), + Cell.from(Span.styled(sortLabel("MIN", "min"), sortStyle("min"))), + Cell.from(Span.styled(sortLabel("MAX", "max"), sortStyle("max"))), + Cell.from(Span.styled("LAST", Style.create().bold())), + Cell.from(Span.styled("THRUPUT", Style.create().bold()))); + + Table table = Table.builder() + .rows(tableRows) + .header(header) + .widths( + Constraint.length(8), // PID + Constraint.length(20), // NAME + Constraint.length(18), // ROUTE + Constraint.fill(), // FROM + Constraint.length(9), // STATUS + Constraint.length(8), // TOTAL + Constraint.length(6), // FAIL + Constraint.length(9), // INFLIGHT + Constraint.length(6), // MEAN + Constraint.length(6), // MIN + Constraint.length(6), // MAX + Constraint.length(6), // LAST + Constraint.length(9)) // THRUPUT + .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .block(Block.builder().borderType(BorderType.ROUNDED).title(" Routes ").build()) + .build(); + + frame.renderStatefulWidget(table, area, tableState); + } + + private void renderFooter(Frame frame, Rect area) { + String refreshLabel = refreshInterval >= 1000 + ? (refreshInterval / 1000) + "s" + : refreshInterval + "ms"; + + Line footer = Line.from( + Span.styled(" q", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" quit "), + Span.styled("s", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" sort "), + Span.styled("r", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" refresh "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" navigate "), + Span.styled("Refresh: " + refreshLabel, Style.create().dim())); + + frame.renderWidget(Paragraph.from(footer), area); + } + + // ---- Data Loading ---- + + @SuppressWarnings("unchecked") + private void refreshData() { + lastRefresh = System.currentTimeMillis(); + try { + List rows = new ArrayList<>(); + List pids = findPids(name); + ProcessHandle.allProcesses() + .filter(ph -> pids.contains(ph.pid())) + .forEach(ph -> { + JsonObject root = loadStatus(ph.pid()); + if (root != null) { + JsonObject context = (JsonObject) root.get("context"); + if (context == null) { + return; + } + String integrationName = context.getString("name"); + if ("CamelJBang".equals(integrationName)) { + integrationName = ProcessHelper.extractName(root, ph); + } + String pid = Long.toString(ph.pid()); + + JsonArray routesArray = (JsonArray) root.get("routes"); + if (routesArray != null) { + for (Object r : routesArray) { + JsonObject rj = (JsonObject) r; + RouteRow row = new RouteRow(); + row.pid = pid; + row.name = integrationName; + row.routeId = rj.getString("routeId"); + row.from = rj.getString("from"); + row.state = rj.getString("state"); + + Map stats = rj.getMap("statistics"); + if (stats != null) { + row.total = objToLong(stats.get("exchangesTotal")); + row.failed = objToLong(stats.get("exchangesFailed")); + row.inflight = objToLong(stats.get("exchangesInflight")); + row.mean = objToLong(stats.get("meanProcessingTime")); + row.min = objToLong(stats.get("minProcessingTime")); + row.max = objToLong(stats.get("maxProcessingTime")); + row.last = objToLong(stats.get("lastProcessingTime")); + Object thp = stats.get("exchangesThroughput"); + if (thp != null) { + row.throughput = thp.toString(); + } + } + rows.add(row); + } + } + } + }); + + // Sort + rows.sort(this::sortRow); + data.set(rows); + } catch (Exception e) { + // ignore refresh errors + } + } + + private void sortData() { + List rows = new ArrayList<>(data.get()); + rows.sort(this::sortRow); + data.set(rows); + } + + private int sortRow(RouteRow o1, RouteRow o2) { + switch (sort) { + case "mean": + return Long.compare(o2.mean, o1.mean); // highest first + case "max": + return Long.compare(o2.max, o1.max); + case "total": + return Long.compare(o2.total, o1.total); + case "failed": + return Long.compare(o2.failed, o1.failed); + case "name": + int c = o1.name != null && o2.name != null + ? o1.name.compareToIgnoreCase(o2.name) + : 0; + if (c == 0) { + c = o1.routeId != null && o2.routeId != null + ? o1.routeId.compareToIgnoreCase(o2.routeId) + : 0; + } + return c; + default: + return 0; + } + } + + // ---- Helpers ---- + + private List findPids(String name) { + List pids = new ArrayList<>(); + final long cur = ProcessHandle.current().pid(); + String pattern = name; + if (!pattern.matches("\\d+") && !pattern.endsWith("*")) { + pattern = pattern + "*"; + } + final String pat = pattern; + ProcessHandle.allProcesses() + .filter(ph -> ph.pid() != cur) + .forEach(ph -> { + JsonObject root = loadStatus(ph.pid()); + if (root != null) { + String pName = ProcessHelper.extractName(root, ph); + pName = FileUtil.onlyName(pName); + if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { + pids.add(ph.pid()); + } else { + JsonObject context = (JsonObject) root.get("context"); + if (context != null) { + pName = context.getString("name"); + if ("CamelJBang".equals(pName)) { + pName = null; + } + if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { + pids.add(ph.pid()); + } + } + } + } + }); + return pids; + } + + private JsonObject loadStatus(long pid) { + try { + Path f = getStatusFile(Long.toString(pid)); + if (f != null && Files.exists(f)) { + String text = Files.readString(f); + return (JsonObject) Jsoner.deserialize(text); + } + } catch (Exception e) { + // ignore + } + return null; + } + + private static int indexOfSort(String s) { + for (int i = 0; i < SORT_COLUMNS.length; i++) { + if (SORT_COLUMNS[i].equals(s)) { + return i; + } + } + return 0; + } + + private String sortLabel(String label, String column) { + return sort.equals(column) ? label + "\u25BC" : label; + } + + private Style sortStyle(String column) { + return sort.equals(column) + ? Style.create().fg(Color.YELLOW).bold() + : Style.create().bold(); + } + + private static String truncate(String s, int max) { + if (s == null) { + return ""; + } + return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s; + } + + private static long objToLong(Object o) { + if (o instanceof Number n) { + return n.longValue(); + } + if (o != null) { + try { + return Long.parseLong(o.toString()); + } catch (NumberFormatException e) { + // ignore + } + } + return 0; + } + + // ---- Data Class ---- + + static class RouteRow { + String pid; + String name; + String routeId; + String from; + String state; + long total; + long failed; + long inflight; + long mean; + long min; + long max; + long last; + String throughput; + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java new file mode 100644 index 0000000000000..05d56543dcb43 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java @@ -0,0 +1,635 @@ +/* + * 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.io.IOException; +import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +import dev.tamboui.style.Overflow; +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.tui.TuiRunner; +import dev.tamboui.tui.event.Event; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.TickEvent; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +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.dsl.jbang.core.commands.CamelCommand; +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; +import org.apache.camel.dsl.jbang.core.common.ProcessHelper; +import org.apache.camel.support.PatternHelper; +import org.apache.camel.util.FileUtil; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command(name = "trace-tui", + description = "TUI exchange trace viewer", + sortOptions = false) +public class CamelTraceTui extends CamelCommand { + + private static final int MAX_TRACES = 200; + + @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1") + String name = "*"; + + @CommandLine.Option(names = { "--refresh" }, + description = "Refresh interval in milliseconds (default: ${DEFAULT-VALUE})", + defaultValue = "200") + long refreshInterval = 200; + + @CommandLine.Option(names = { "--grep" }, + description = "Filter traces by text") + String grep; + + // State + private final AtomicReference> traces = new AtomicReference<>(Collections.emptyList()); + private final TableState traceTableState = new TableState(); + private final Map traceFilePositions = new LinkedHashMap<>(); + + private boolean showHeaders = true; + private boolean showBody = true; + private boolean followMode = true; + private volatile long lastRefresh; + + public CamelTraceTui(CamelJBangMain main) { + super(main); + } + + @Override + public Integer doCall() throws Exception { + // Eagerly load classes used by the input reader thread and picocli + // post-processing to avoid ClassNotFoundException during shutdown + try { + Class.forName("dev.tamboui.tui.event.KeyModifiers"); + Class.forName("dev.tamboui.tui.event.KeyEvent"); + Class.forName("dev.tamboui.tui.event.KeyCode"); + Class.forName("picocli.CommandLine$IExitCodeGenerator"); + } catch (ClassNotFoundException e) { + // ignore + } + + // Initial data load + refreshData(); + + try (var tui = TuiRunner.create()) { + sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit()); + tui.run( + this::handleEvent, + this::render); + } + return 0; + } + + // ---- Event Handling ---- + + private boolean handleEvent(Event event, TuiRunner runner) { + if (event instanceof KeyEvent ke) { + if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) { + runner.quit(); + return true; + } + if (ke.isUp()) { + followMode = false; + traceTableState.selectPrevious(); + return true; + } + if (ke.isDown()) { + List current = traces.get(); + traceTableState.selectNext(current.size()); + return true; + } + if (ke.isCharIgnoreCase('h')) { + showHeaders = !showHeaders; + return true; + } + if (ke.isCharIgnoreCase('b')) { + showBody = !showBody; + return true; + } + if (ke.isCharIgnoreCase('f')) { + followMode = !followMode; + if (followMode) { + List current = traces.get(); + if (!current.isEmpty()) { + traceTableState.select(current.size() - 1); + } + } + return true; + } + } + if (event instanceof TickEvent) { + long now = System.currentTimeMillis(); + if (now - lastRefresh >= refreshInterval) { + refreshData(); + } + return true; + } + return false; + } + + // ---- Rendering ---- + + private void render(Frame frame) { + Rect area = frame.area(); + + // Layout: header (3 rows) + trace list (50%) + detail panel (50%) + footer (1 row) + List mainChunks = Layout.vertical() + .constraints( + Constraint.length(3), + Constraint.percentage(50), + Constraint.fill(), + Constraint.length(1)) + .split(area); + + renderHeader(frame, mainChunks.get(0)); + renderTraceList(frame, mainChunks.get(1)); + renderDetailPanel(frame, mainChunks.get(2)); + renderFooter(frame, mainChunks.get(3)); + } + + private void renderHeader(Frame frame, Rect area) { + List current = traces.get(); + String filterInfo = grep != null ? " filter: " + grep : ""; + + Line titleLine = Line.from( + Span.styled(" Camel Trace Viewer", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()), + Span.raw(" "), + Span.styled(current.size() + " trace(s)", Style.create().fg(Color.CYAN)), + Span.raw(" "), + Span.styled(followMode ? "[FOLLOW]" : "[SCROLL]", + Style.create().fg(followMode ? Color.GREEN : Color.YELLOW).bold()), + Span.styled(filterInfo, Style.create().dim())); + + Block headerBlock = Block.builder() + .borderType(BorderType.ROUNDED) + .title(" Apache Camel Traces ") + .build(); + + frame.renderWidget( + Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(), + area); + } + + private void renderTraceList(Frame frame, Rect area) { + List current = traces.get(); + + // Auto-follow: select last entry + if (followMode && !current.isEmpty()) { + traceTableState.select(current.size() - 1); + } + + List rows = new ArrayList<>(); + for (TraceEntry entry : current) { + Style statusStyle = switch (entry.status) { + case "Created" -> Style.create().fg(Color.CYAN); + case "Routing", "Processing" -> Style.create().fg(Color.YELLOW); + case "Sent" -> Style.create().fg(Color.GREEN); + default -> Style.create().fg(Color.WHITE); + }; + + String bodyPreview = entry.bodyPreview != null ? truncate(entry.bodyPreview, 40) : ""; + + rows.add(Row.from( + Cell.from(entry.timestamp != null ? truncate(entry.timestamp, 12) : ""), + Cell.from(entry.pid != null ? entry.pid : ""), + Cell.from(Span.styled( + entry.routeId != null ? truncate(entry.routeId, 15) : "", + Style.create().fg(Color.CYAN))), + Cell.from(entry.nodeId != null ? truncate(entry.nodeId, 15) : ""), + Cell.from(Span.styled(entry.status != null ? entry.status : "", statusStyle)), + Cell.from(entry.elapsed + "ms"), + Cell.from(bodyPreview))); + } + + Row header = Row.from( + Cell.from(Span.styled("TIME", Style.create().bold())), + Cell.from(Span.styled("PID", Style.create().bold())), + Cell.from(Span.styled("ROUTE", Style.create().bold())), + Cell.from(Span.styled("NODE", Style.create().bold())), + Cell.from(Span.styled("STATUS", Style.create().bold())), + Cell.from(Span.styled("ELAPSED", Style.create().bold())), + Cell.from(Span.styled("BODY", Style.create().bold()))); + + Table table = Table.builder() + .rows(rows) + .header(header) + .widths( + Constraint.length(12), + Constraint.length(8), + Constraint.length(15), + Constraint.length(15), + Constraint.length(12), + Constraint.length(10), + Constraint.fill()) + .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .block(Block.builder().borderType(BorderType.ROUNDED).title(" Traces ").build()) + .build(); + + frame.renderStatefulWidget(table, area, traceTableState); + } + + private void renderDetailPanel(Frame frame, Rect area) { + List current = traces.get(); + Integer sel = traceTableState.selected(); + + if (sel == null || sel < 0 || sel >= current.size()) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from( + Span.styled(" Select a trace entry to view details", + Style.create().dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" Detail ").build()) + .build(), + area); + return; + } + + TraceEntry entry = current.get(sel); + List lines = new ArrayList<>(); + + // Exchange info + lines.add(Line.from( + Span.styled(" Exchange: ", Style.create().fg(Color.YELLOW).bold()), + Span.raw(entry.exchangeId != null ? entry.exchangeId : ""))); + lines.add(Line.from( + Span.styled(" UID: ", Style.create().fg(Color.YELLOW).bold()), + Span.raw(entry.uid != null ? entry.uid : ""))); + lines.add(Line.from( + Span.styled(" Location: ", Style.create().fg(Color.YELLOW).bold()), + Span.raw(entry.location != null ? entry.location : ""))); + lines.add(Line.from( + Span.styled(" Route: ", Style.create().fg(Color.YELLOW).bold()), + Span.raw(entry.routeId != null ? entry.routeId : ""), + Span.raw(" Node: "), + Span.raw(entry.nodeId != null ? entry.nodeId : ""))); + lines.add(Line.from( + Span.styled(" Status: ", Style.create().fg(Color.YELLOW).bold()), + Span.raw(entry.status != null ? entry.status : ""), + Span.raw(" Elapsed: "), + Span.raw(entry.elapsed + "ms"))); + lines.add(Line.from(Span.raw(""))); + + // Headers + if (showHeaders && entry.headers != null && !entry.headers.isEmpty()) { + lines.add(Line.from(Span.styled(" Headers:", Style.create().fg(Color.GREEN).bold()))); + for (Map.Entry h : entry.headers.entrySet()) { + lines.add(Line.from( + Span.styled(" " + h.getKey(), Style.create().fg(Color.CYAN)), + Span.raw(" = "), + Span.raw(h.getValue() != null ? h.getValue().toString() : "null"))); + } + lines.add(Line.from(Span.raw(""))); + } + + // Body + if (showBody && entry.body != null) { + lines.add(Line.from(Span.styled(" Body:", Style.create().fg(Color.GREEN).bold()))); + // Split body into lines for display + String[] bodyLines = entry.body.split("\n"); + for (String bl : bodyLines) { + lines.add(Line.from(Span.raw(" " + bl))); + } + lines.add(Line.from(Span.raw(""))); + } + + // Exchange properties + if (entry.exchangeProperties != null && !entry.exchangeProperties.isEmpty()) { + lines.add(Line.from(Span.styled(" Exchange Properties:", Style.create().fg(Color.GREEN).bold()))); + for (Map.Entry p : entry.exchangeProperties.entrySet()) { + lines.add(Line.from( + Span.styled(" " + p.getKey(), Style.create().fg(Color.CYAN)), + Span.raw(" = "), + Span.raw(p.getValue() != null ? p.getValue().toString() : "null"))); + } + lines.add(Line.from(Span.raw(""))); + } + + // Exchange variables + if (entry.exchangeVariables != null && !entry.exchangeVariables.isEmpty()) { + lines.add(Line.from(Span.styled(" Exchange Variables:", Style.create().fg(Color.GREEN).bold()))); + for (Map.Entry v : entry.exchangeVariables.entrySet()) { + lines.add(Line.from( + Span.styled(" " + v.getKey(), Style.create().fg(Color.CYAN)), + Span.raw(" = "), + Span.raw(v.getValue() != null ? v.getValue().toString() : "null"))); + } + } + + String title = String.format(" Detail [%s] ", entry.exchangeId != null ? truncate(entry.exchangeId, 30) : ""); + + Paragraph detail = Paragraph.builder() + .text(Text.from(lines)) + .overflow(Overflow.CLIP) + .block(Block.builder().borderType(BorderType.ROUNDED).title(title).build()) + .build(); + + frame.renderWidget(detail, area); + } + + private void renderFooter(Frame frame, Rect area) { + Line footer = Line.from( + Span.styled(" q", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" quit "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" navigate "), + Span.styled("h", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" headers" + (showHeaders ? " [on]" : " [off]") + " "), + Span.styled("b", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" body" + (showBody ? " [on]" : " [off]") + " "), + Span.styled("f", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" follow" + (followMode ? " [on]" : " [off]") + " "), + Span.styled("Refresh: " + refreshInterval + "ms", Style.create().dim())); + + frame.renderWidget(Paragraph.from(footer), area); + } + + // ---- Data Loading ---- + + private void refreshData() { + lastRefresh = System.currentTimeMillis(); + try { + List pids = findPids(name); + List allTraces = new ArrayList<>(traces.get()); + + for (Long pid : pids) { + readTraceFile(Long.toString(pid), allTraces); + } + + // Sort by timestamp + allTraces.sort((a, b) -> { + if (a.timestamp == null && b.timestamp == null) { + return 0; + } + if (a.timestamp == null) { + return -1; + } + if (b.timestamp == null) { + return 1; + } + return a.timestamp.compareTo(b.timestamp); + }); + + // Keep only last MAX_TRACES + if (allTraces.size() > MAX_TRACES) { + allTraces = new ArrayList<>(allTraces.subList(allTraces.size() - MAX_TRACES, allTraces.size())); + } + + // Apply grep filter + if (grep != null && !grep.isEmpty()) { + String lowerGrep = grep.toLowerCase(); + allTraces = allTraces.stream() + .filter(t -> matchesGrep(t, lowerGrep)) + .collect(java.util.stream.Collectors.toList()); + } + + traces.set(allTraces); + } catch (Exception e) { + // ignore refresh errors + } + } + + private boolean matchesGrep(TraceEntry entry, String lowerGrep) { + if (entry.exchangeId != null && entry.exchangeId.toLowerCase().contains(lowerGrep)) { + return true; + } + if (entry.routeId != null && entry.routeId.toLowerCase().contains(lowerGrep)) { + return true; + } + if (entry.nodeId != null && entry.nodeId.toLowerCase().contains(lowerGrep)) { + return true; + } + if (entry.status != null && entry.status.toLowerCase().contains(lowerGrep)) { + return true; + } + if (entry.body != null && entry.body.toLowerCase().contains(lowerGrep)) { + return true; + } + if (entry.location != null && entry.location.toLowerCase().contains(lowerGrep)) { + return true; + } + return false; + } + + @SuppressWarnings("unchecked") + private void readTraceFile(String pid, List allTraces) { + Path traceFile = CommandLineHelper.getCamelDir().resolve(pid + "-trace.json"); + if (!Files.exists(traceFile)) { + return; + } + + long lastPos = traceFilePositions.getOrDefault(pid, 0L); + + try (RandomAccessFile raf = new RandomAccessFile(traceFile.toFile(), "r")) { + long length = raf.length(); + if (length <= lastPos) { + return; // no new data + } + + raf.seek(lastPos); + // If we're resuming mid-file, skip any partial line + if (lastPos > 0) { + raf.readLine(); + } + + // Read remaining bytes + long startPos = raf.getFilePointer(); + byte[] remaining = new byte[(int) (length - startPos)]; + raf.readFully(remaining); + String content = new String(remaining, StandardCharsets.UTF_8); + + traceFilePositions.put(pid, length); + + // Each line is a separate JSON object + String[] lines = content.split("\n"); + for (String line : lines) { + line = line.trim(); + if (line.isEmpty()) { + continue; + } + try { + JsonObject json = (JsonObject) Jsoner.deserialize(line); + TraceEntry entry = parseTraceEntry(json, pid); + if (entry != null) { + allTraces.add(entry); + } + } catch (Exception e) { + // skip malformed lines + } + } + } catch (IOException e) { + // ignore + } + } + + @SuppressWarnings("unchecked") + private TraceEntry parseTraceEntry(JsonObject json, String pid) { + TraceEntry entry = new TraceEntry(); + entry.pid = pid; + entry.uid = json.getString("uid"); + entry.exchangeId = json.getString("exchangeId"); + entry.timestamp = json.getString("timestamp"); + entry.routeId = json.getString("routeId"); + entry.nodeId = json.getString("nodeId"); + entry.location = json.getString("location"); + entry.status = json.getString("status"); + + Object elapsedObj = json.get("elapsed"); + if (elapsedObj instanceof Number n) { + entry.elapsed = n.longValue(); + } else if (elapsedObj != null) { + try { + entry.elapsed = Long.parseLong(elapsedObj.toString()); + } catch (NumberFormatException e) { + // ignore + } + } + + // Parse message object + JsonObject message = (JsonObject) json.get("message"); + if (message != null) { + // Headers + Object headersObj = message.get("headers"); + if (headersObj instanceof Map) { + entry.headers = new LinkedHashMap<>((Map) headersObj); + } + + // Body + Object bodyObj = message.get("body"); + if (bodyObj != null) { + entry.body = bodyObj.toString(); + // Create a preview (first line, truncated) + String preview = entry.body.replace("\n", " ").replace("\r", ""); + entry.bodyPreview = preview; + } + + // Exchange properties + Object propsObj = message.get("exchangeProperties"); + if (propsObj instanceof Map) { + entry.exchangeProperties = new LinkedHashMap<>((Map) propsObj); + } + + // Exchange variables + Object varsObj = message.get("exchangeVariables"); + if (varsObj instanceof Map) { + entry.exchangeVariables = new LinkedHashMap<>((Map) varsObj); + } + } + + return entry; + } + + // ---- Helpers ---- + + private List findPids(String name) { + List pids = new ArrayList<>(); + final long cur = ProcessHandle.current().pid(); + String pattern = name; + if (!pattern.matches("\\d+") && !pattern.endsWith("*")) { + pattern = pattern + "*"; + } + final String pat = pattern; + ProcessHandle.allProcesses() + .filter(ph -> ph.pid() != cur) + .forEach(ph -> { + JsonObject root = loadStatus(ph.pid()); + if (root != null) { + String pName = ProcessHelper.extractName(root, ph); + pName = FileUtil.onlyName(pName); + if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { + pids.add(ph.pid()); + } else { + JsonObject context = (JsonObject) root.get("context"); + if (context != null) { + pName = context.getString("name"); + if ("CamelJBang".equals(pName)) { + pName = null; + } + if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { + pids.add(ph.pid()); + } + } + } + } + }); + return pids; + } + + private JsonObject loadStatus(long pid) { + try { + Path f = getStatusFile(Long.toString(pid)); + if (f != null && Files.exists(f)) { + String text = Files.readString(f); + return (JsonObject) Jsoner.deserialize(text); + } + } catch (Exception e) { + // ignore + } + return null; + } + + private static String truncate(String s, int max) { + if (s == null) { + return ""; + } + return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s; + } + + // ---- Data Classes ---- + + static class TraceEntry { + String pid; + String uid; + String exchangeId; + String timestamp; + String routeId; + String nodeId; + String location; + String status; + long elapsed; + String body; + String bodyPreview; + Map headers; + Map exchangeProperties; + Map exchangeVariables; + } +} diff --git a/parent/pom.xml b/parent/pom.xml index 891f9e99f8af3..050a06d852232 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -327,7 +327,7 @@ 1.8 1.0.8 6.0.2 - 4.0.7 + 4.0.9 0.22.0 2.14.1 2.5.1 From 0941078aa2ddaee592a6f30a1180c5284d2215f7 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 23 Mar 2026 16:14:32 +0100 Subject: [PATCH 02/10] CAMEL-23226: Add comment explaining eager class loading in CamelCatalogTui Co-Authored-By: Claude Opus 4.6 --- .../camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java index 2c85d1c113a11..9721684de2c0f 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java @@ -80,6 +80,8 @@ public CamelCatalogTui(CamelJBangMain main) { @Override public Integer doCall() throws Exception { + // Eagerly load classes used by the input reader thread and picocli + // post-processing to avoid ClassNotFoundException during shutdown try { Class.forName("dev.tamboui.tui.event.KeyModifiers"); Class.forName("dev.tamboui.tui.event.KeyEvent"); From 71bde7daf494d4a9da5dbc1ff5acedb3e119c349 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 23 Mar 2026 16:18:26 +0100 Subject: [PATCH 03/10] CAMEL-23226: Extract shared TUI utilities into TuiHelper class Move duplicated code (preloadClasses, findPids, loadStatus, truncate, objToLong) from all 6 TUI commands into a shared TuiHelper utility class. Co-Authored-By: Claude Opus 4.6 --- .../core/commands/tui/CamelCatalogTui.java | 16 +-- .../core/commands/tui/CamelHealthTui.java | 65 +-------- .../jbang/core/commands/tui/CamelLogTui.java | 57 +------- .../jbang/core/commands/tui/CamelMonitor.java | 75 +--------- .../jbang/core/commands/tui/CamelTopTui.java | 76 +--------- .../core/commands/tui/CamelTraceTui.java | 62 +------- .../jbang/core/commands/tui/TuiHelper.java | 134 ++++++++++++++++++ 7 files changed, 157 insertions(+), 328 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java index 9721684de2c0f..ab3edc857693a 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java @@ -80,16 +80,7 @@ public CamelCatalogTui(CamelJBangMain main) { @Override public Integer doCall() throws Exception { - // Eagerly load classes used by the input reader thread and picocli - // post-processing to avoid ClassNotFoundException during shutdown - try { - Class.forName("dev.tamboui.tui.event.KeyModifiers"); - Class.forName("dev.tamboui.tui.event.KeyEvent"); - Class.forName("dev.tamboui.tui.event.KeyCode"); - Class.forName("picocli.CommandLine$IExitCodeGenerator"); - } catch (ClassNotFoundException e) { - // ignore - } + TuiHelper.preloadClasses(); loadCatalog(); @@ -655,10 +646,7 @@ private void renderFooter(Frame frame, Rect area) { // ---- Helpers ---- private static String truncate(String s, int max) { - if (s == null) { - return ""; - } - return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s; + return TuiHelper.truncate(s, max); } // ---- Data Classes ---- diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java index e0e1d46548ed8..9bea6b319d7d0 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java @@ -16,8 +16,6 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; -import java.nio.file.Files; -import java.nio.file.Path; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; @@ -49,12 +47,9 @@ import org.apache.camel.dsl.jbang.core.commands.CamelCommand; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.apache.camel.dsl.jbang.core.common.ProcessHelper; -import org.apache.camel.support.PatternHelper; -import org.apache.camel.util.FileUtil; import org.apache.camel.util.TimeUtils; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; -import org.apache.camel.util.json.Jsoner; import picocli.CommandLine; import picocli.CommandLine.Command; @@ -87,17 +82,7 @@ public CamelHealthTui(CamelJBangMain main) { @Override public Integer doCall() throws Exception { - // Eagerly load classes used by the input reader thread and picocli - // post-processing to avoid ClassNotFoundException during shutdown - // when the Spring Boot LaunchedClassLoader may already be closing - try { - Class.forName("dev.tamboui.tui.event.KeyModifiers"); - Class.forName("dev.tamboui.tui.event.KeyEvent"); - Class.forName("dev.tamboui.tui.event.KeyCode"); - Class.forName("picocli.CommandLine$IExitCodeGenerator"); - } catch (ClassNotFoundException e) { - // ignore - } + TuiHelper.preloadClasses(); // Initial data load refreshData(); @@ -417,57 +402,15 @@ private List getFilteredRows() { } private List findPids(String name) { - List pids = new ArrayList<>(); - final long cur = ProcessHandle.current().pid(); - String pattern = name; - if (!pattern.matches("\\d+") && !pattern.endsWith("*")) { - pattern = pattern + "*"; - } - final String pat = pattern; - ProcessHandle.allProcesses() - .filter(ph -> ph.pid() != cur) - .forEach(ph -> { - JsonObject root = loadStatus(ph.pid()); - if (root != null) { - String pName = ProcessHelper.extractName(root, ph); - pName = FileUtil.onlyName(pName); - if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { - pids.add(ph.pid()); - } else { - JsonObject context = (JsonObject) root.get("context"); - if (context != null) { - pName = context.getString("name"); - if ("CamelJBang".equals(pName)) { - pName = null; - } - if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { - pids.add(ph.pid()); - } - } - } - } - }); - return pids; + return TuiHelper.findPids(name, this::getStatusFile); } private JsonObject loadStatus(long pid) { - try { - Path f = getStatusFile(Long.toString(pid)); - if (f != null && Files.exists(f)) { - String text = Files.readString(f); - return (JsonObject) Jsoner.deserialize(text); - } - } catch (Exception e) { - // ignore - } - return null; + return TuiHelper.loadStatus(pid, this::getStatusFile); } private static String truncate(String s, int max) { - if (s == null) { - return ""; - } - return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s; + return TuiHelper.truncate(s, max); } private static String formatBytes(long bytes) { diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java index 1bede16a83960..111a22765538e 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java @@ -46,10 +46,7 @@ import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; import org.apache.camel.dsl.jbang.core.common.ProcessHelper; -import org.apache.camel.support.PatternHelper; -import org.apache.camel.util.FileUtil; import org.apache.camel.util.json.JsonObject; -import org.apache.camel.util.json.Jsoner; import picocli.CommandLine; import picocli.CommandLine.Command; @@ -102,16 +99,7 @@ public CamelLogTui(CamelJBangMain main) { @Override public Integer doCall() throws Exception { - // Eagerly load classes used by the input reader thread and picocli - // post-processing to avoid ClassNotFoundException during shutdown - try { - Class.forName("dev.tamboui.tui.event.KeyModifiers"); - Class.forName("dev.tamboui.tui.event.KeyEvent"); - Class.forName("dev.tamboui.tui.event.KeyCode"); - Class.forName("picocli.CommandLine$IExitCodeGenerator"); - } catch (ClassNotFoundException e) { - // ignore - } + TuiHelper.preloadClasses(); // Apply --level as initial level filter if (level != null) { @@ -450,49 +438,10 @@ private boolean matchesLevelFilter(String line) { // ---- Helpers ---- private List findPids(String name) { - List pids = new ArrayList<>(); - final long cur = ProcessHandle.current().pid(); - String pattern = name; - if (!pattern.matches("\\d+") && !pattern.endsWith("*")) { - pattern = pattern + "*"; - } - final String pat = pattern; - ProcessHandle.allProcesses() - .filter(ph -> ph.pid() != cur) - .forEach(ph -> { - JsonObject root = loadStatus(ph.pid()); - if (root != null) { - String pName = ProcessHelper.extractName(root, ph); - pName = FileUtil.onlyName(pName); - if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { - pids.add(ph.pid()); - } else { - JsonObject context = (JsonObject) root.get("context"); - if (context != null) { - pName = context.getString("name"); - if ("CamelJBang".equals(pName)) { - pName = null; - } - if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { - pids.add(ph.pid()); - } - } - } - } - }); - return pids; + return TuiHelper.findPids(name, this::getStatusFile); } private JsonObject loadStatus(long pid) { - try { - Path f = getStatusFile(Long.toString(pid)); - if (f != null && Files.exists(f)) { - String text = Files.readString(f); - return (JsonObject) Jsoner.deserialize(text); - } - } catch (Exception e) { - // ignore - } - return null; + return TuiHelper.loadStatus(pid, this::getStatusFile); } } diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index df8d3dc0dfae3..cf186b146b3a1 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -65,12 +65,9 @@ import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; import org.apache.camel.dsl.jbang.core.common.ProcessHelper; import org.apache.camel.dsl.jbang.core.common.VersionHelper; -import org.apache.camel.support.PatternHelper; -import org.apache.camel.util.FileUtil; import org.apache.camel.util.TimeUtils; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; -import org.apache.camel.util.json.Jsoner; import picocli.CommandLine; import picocli.CommandLine.Command; @@ -132,17 +129,7 @@ public CamelMonitor(CamelJBangMain main) { @Override public Integer doCall() throws Exception { - // Eagerly load classes used by the input reader thread and picocli - // post-processing to avoid ClassNotFoundException during shutdown - // when the Spring Boot LaunchedClassLoader may already be closing - try { - Class.forName("dev.tamboui.tui.event.KeyModifiers"); - Class.forName("dev.tamboui.tui.event.KeyEvent"); - Class.forName("dev.tamboui.tui.event.KeyCode"); - Class.forName("picocli.CommandLine$IExitCodeGenerator"); - } catch (ClassNotFoundException e) { - // ignore - } + TuiHelper.preloadClasses(); // Initial data load refreshData(); @@ -1133,50 +1120,11 @@ private String selectedName() { } private List findPids(String name) { - List pids = new ArrayList<>(); - final long cur = ProcessHandle.current().pid(); - String pattern = name; - if (!pattern.matches("\\d+") && !pattern.endsWith("*")) { - pattern = pattern + "*"; - } - final String pat = pattern; - ProcessHandle.allProcesses() - .filter(ph -> ph.pid() != cur) - .forEach(ph -> { - JsonObject root = loadStatus(ph.pid()); - if (root != null) { - String pName = ProcessHelper.extractName(root, ph); - pName = FileUtil.onlyName(pName); - if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { - pids.add(ph.pid()); - } else { - JsonObject context = (JsonObject) root.get("context"); - if (context != null) { - pName = context.getString("name"); - if ("CamelJBang".equals(pName)) { - pName = null; - } - if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { - pids.add(ph.pid()); - } - } - } - } - }); - return pids; + return TuiHelper.findPids(name, this::getStatusFile); } private JsonObject loadStatus(long pid) { - try { - Path f = getStatusFile(Long.toString(pid)); - if (f != null && Files.exists(f)) { - String text = Files.readString(f); - return (JsonObject) Jsoner.deserialize(text); - } - } catch (Exception e) { - // ignore - } - return null; + return TuiHelper.loadStatus(pid, this::getStatusFile); } private static long extractSince(ProcessHandle ph) { @@ -1184,10 +1132,7 @@ private static long extractSince(ProcessHandle ph) { } private static String truncate(String s, int max) { - if (s == null) { - return ""; - } - return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s; + return TuiHelper.truncate(s, max); } private static String formatMemory(long used, long max) { @@ -1223,17 +1168,7 @@ private static String objToString(Object o) { } private static long objToLong(Object o) { - if (o instanceof Number n) { - return n.longValue(); - } - if (o != null) { - try { - return Long.parseLong(o.toString()); - } catch (NumberFormatException e) { - // ignore - } - } - return 0; + return TuiHelper.objToLong(o); } // ---- Data Classes ---- diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java index 54305b18a31c1..6db0b95cd451c 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java @@ -16,8 +16,6 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -48,11 +46,8 @@ import org.apache.camel.dsl.jbang.core.commands.CamelCommand; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.apache.camel.dsl.jbang.core.common.ProcessHelper; -import org.apache.camel.support.PatternHelper; -import org.apache.camel.util.FileUtil; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; -import org.apache.camel.util.json.Jsoner; import picocli.CommandLine; import picocli.CommandLine.Command; @@ -88,16 +83,7 @@ public CamelTopTui(CamelJBangMain main) { @Override public Integer doCall() throws Exception { - // Eagerly load classes used by the input reader thread and picocli - // post-processing to avoid ClassNotFoundException during shutdown - try { - Class.forName("dev.tamboui.tui.event.KeyModifiers"); - Class.forName("dev.tamboui.tui.event.KeyEvent"); - Class.forName("dev.tamboui.tui.event.KeyCode"); - Class.forName("picocli.CommandLine$IExitCodeGenerator"); - } catch (ClassNotFoundException e) { - // ignore - } + TuiHelper.preloadClasses(); // Resolve initial sort index sortIndex = indexOfSort(sort); @@ -382,50 +368,11 @@ private int sortRow(RouteRow o1, RouteRow o2) { // ---- Helpers ---- private List findPids(String name) { - List pids = new ArrayList<>(); - final long cur = ProcessHandle.current().pid(); - String pattern = name; - if (!pattern.matches("\\d+") && !pattern.endsWith("*")) { - pattern = pattern + "*"; - } - final String pat = pattern; - ProcessHandle.allProcesses() - .filter(ph -> ph.pid() != cur) - .forEach(ph -> { - JsonObject root = loadStatus(ph.pid()); - if (root != null) { - String pName = ProcessHelper.extractName(root, ph); - pName = FileUtil.onlyName(pName); - if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { - pids.add(ph.pid()); - } else { - JsonObject context = (JsonObject) root.get("context"); - if (context != null) { - pName = context.getString("name"); - if ("CamelJBang".equals(pName)) { - pName = null; - } - if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { - pids.add(ph.pid()); - } - } - } - } - }); - return pids; + return TuiHelper.findPids(name, this::getStatusFile); } private JsonObject loadStatus(long pid) { - try { - Path f = getStatusFile(Long.toString(pid)); - if (f != null && Files.exists(f)) { - String text = Files.readString(f); - return (JsonObject) Jsoner.deserialize(text); - } - } catch (Exception e) { - // ignore - } - return null; + return TuiHelper.loadStatus(pid, this::getStatusFile); } private static int indexOfSort(String s) { @@ -448,24 +395,11 @@ private Style sortStyle(String column) { } private static String truncate(String s, int max) { - if (s == null) { - return ""; - } - return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s; + return TuiHelper.truncate(s, max); } private static long objToLong(Object o) { - if (o instanceof Number n) { - return n.longValue(); - } - if (o != null) { - try { - return Long.parseLong(o.toString()); - } catch (NumberFormatException e) { - // ignore - } - } - return 0; + return TuiHelper.objToLong(o); } // ---- Data Class ---- diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java index 05d56543dcb43..64a2746ab1fe0 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java @@ -53,9 +53,6 @@ import org.apache.camel.dsl.jbang.core.commands.CamelCommand; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; -import org.apache.camel.dsl.jbang.core.common.ProcessHelper; -import org.apache.camel.support.PatternHelper; -import org.apache.camel.util.FileUtil; import org.apache.camel.util.json.JsonObject; import org.apache.camel.util.json.Jsoner; import picocli.CommandLine; @@ -96,16 +93,7 @@ public CamelTraceTui(CamelJBangMain main) { @Override public Integer doCall() throws Exception { - // Eagerly load classes used by the input reader thread and picocli - // post-processing to avoid ClassNotFoundException during shutdown - try { - Class.forName("dev.tamboui.tui.event.KeyModifiers"); - Class.forName("dev.tamboui.tui.event.KeyEvent"); - Class.forName("dev.tamboui.tui.event.KeyCode"); - Class.forName("picocli.CommandLine$IExitCodeGenerator"); - } catch (ClassNotFoundException e) { - // ignore - } + TuiHelper.preloadClasses(); // Initial data load refreshData(); @@ -561,57 +549,15 @@ private TraceEntry parseTraceEntry(JsonObject json, String pid) { // ---- Helpers ---- private List findPids(String name) { - List pids = new ArrayList<>(); - final long cur = ProcessHandle.current().pid(); - String pattern = name; - if (!pattern.matches("\\d+") && !pattern.endsWith("*")) { - pattern = pattern + "*"; - } - final String pat = pattern; - ProcessHandle.allProcesses() - .filter(ph -> ph.pid() != cur) - .forEach(ph -> { - JsonObject root = loadStatus(ph.pid()); - if (root != null) { - String pName = ProcessHelper.extractName(root, ph); - pName = FileUtil.onlyName(pName); - if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { - pids.add(ph.pid()); - } else { - JsonObject context = (JsonObject) root.get("context"); - if (context != null) { - pName = context.getString("name"); - if ("CamelJBang".equals(pName)) { - pName = null; - } - if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { - pids.add(ph.pid()); - } - } - } - } - }); - return pids; + return TuiHelper.findPids(name, this::getStatusFile); } private JsonObject loadStatus(long pid) { - try { - Path f = getStatusFile(Long.toString(pid)); - if (f != null && Files.exists(f)) { - String text = Files.readString(f); - return (JsonObject) Jsoner.deserialize(text); - } - } catch (Exception e) { - // ignore - } - return null; + return TuiHelper.loadStatus(pid, this::getStatusFile); } private static String truncate(String s, int max) { - if (s == null) { - return ""; - } - return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s; + return TuiHelper.truncate(s, max); } // ---- Data Classes ---- diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java new file mode 100644 index 0000000000000..871a74e452ea6 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java @@ -0,0 +1,134 @@ +/* + * 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.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import org.apache.camel.dsl.jbang.core.common.ProcessHelper; +import org.apache.camel.support.PatternHelper; +import org.apache.camel.util.FileUtil; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +/** + * Shared utility methods for TUI commands. + */ +final class TuiHelper { + + private TuiHelper() { + } + + /** + * Eagerly load classes used by the TUI input reader daemon thread and picocli post-processing. Without this, during + * JVM shutdown the classloader may already be closing while the input reader thread is still trying to load these + * classes lazily — causing ClassNotFoundException stack traces on exit. + */ + static void preloadClasses() { + try { + Class.forName("dev.tamboui.tui.event.KeyModifiers"); + Class.forName("dev.tamboui.tui.event.KeyEvent"); + Class.forName("dev.tamboui.tui.event.KeyCode"); + Class.forName("picocli.CommandLine$IExitCodeGenerator"); + } catch (ClassNotFoundException e) { + // ignore + } + } + + /** + * Find PIDs of running Camel integrations matching the given name pattern. + */ + static List findPids(String name, Function statusFileResolver) { + List pids = new ArrayList<>(); + final long cur = ProcessHandle.current().pid(); + String pattern = name; + if (!pattern.matches("\\d+") && !pattern.endsWith("*")) { + pattern = pattern + "*"; + } + final String pat = pattern; + ProcessHandle.allProcesses() + .filter(ph -> ph.pid() != cur) + .forEach(ph -> { + JsonObject root = loadStatus(ph.pid(), statusFileResolver); + if (root != null) { + String pName = ProcessHelper.extractName(root, ph); + pName = FileUtil.onlyName(pName); + if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { + pids.add(ph.pid()); + } else { + JsonObject context = (JsonObject) root.get("context"); + if (context != null) { + pName = context.getString("name"); + if ("CamelJBang".equals(pName)) { + pName = null; + } + if (pName != null && !pName.isEmpty() && PatternHelper.matchPattern(pName, pat)) { + pids.add(ph.pid()); + } + } + } + } + }); + return pids; + } + + /** + * Load the status JSON for a given PID. + */ + static JsonObject loadStatus(long pid, Function statusFileResolver) { + try { + Path f = statusFileResolver.apply(Long.toString(pid)); + if (f != null && Files.exists(f)) { + String text = Files.readString(f); + return (JsonObject) Jsoner.deserialize(text); + } + } catch (Exception e) { + // ignore + } + return null; + } + + /** + * Truncate a string to max length, appending an ellipsis if truncated. + */ + static String truncate(String s, int max) { + if (s == null) { + return ""; + } + return s.length() > max ? s.substring(0, max - 1) + "\u2026" : s; + } + + /** + * Convert an Object (typically from JSON) to a long value. + */ + static long objToLong(Object o) { + if (o instanceof Number n) { + return n.longValue(); + } + if (o != null) { + try { + return Long.parseLong(o.toString()); + } catch (NumberFormatException e) { + // ignore + } + } + return 0; + } +} From 549d945f044402b3ad7313f8ebd57bb494944ec1 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 23 Mar 2026 17:45:41 +0100 Subject: [PATCH 04/10] CAMEL-23226: Move TUI commands to a separate plugin module - Create camel-jbang-plugin-tui with TuiPlugin that registers all TUI commands - Move all TUI source files from camel-jbang-core to camel-jbang-plugin-tui - Remove TamboUI dependencies from camel-jbang-core - Add parentCommands support to @CamelJBangPlugin annotation so plugins can inject subcommands into existing commands (e.g. 'top tui', 'trace tui') - Update PluginHelper to load plugins when target matches parentCommands Co-Authored-By: Claude Opus 4.6 --- dsl/camel-jbang/camel-jbang-core/pom.xml | 28 ------- .../jbang/core/commands/CamelJBangMain.java | 15 +--- .../jbang/core/common/CamelJBangPlugin.java | 6 ++ .../dsl/jbang/core/common/PluginHelper.java | 82 +++++++++++++++++-- .../camel-jbang-plugin-tui/pom.xml | 78 ++++++++++++++++++ .../core/commands/tui/CamelCatalogTui.java | 0 .../core/commands/tui/CamelHealthTui.java | 0 .../jbang/core/commands/tui/CamelLogTui.java | 0 .../jbang/core/commands/tui/CamelMonitor.java | 0 .../jbang/core/commands/tui/CamelTopTui.java | 0 .../core/commands/tui/CamelTraceTui.java | 0 .../jbang/core/commands/tui/TuiHelper.java | 0 .../jbang/core/commands/tui/TuiPlugin.java | 52 ++++++++++++ dsl/camel-jbang/camel-launcher/pom.xml | 5 ++ dsl/camel-jbang/pom.xml | 1 + 15 files changed, 220 insertions(+), 47 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml rename dsl/camel-jbang/{camel-jbang-core => camel-jbang-plugin-tui}/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java (100%) rename dsl/camel-jbang/{camel-jbang-core => camel-jbang-plugin-tui}/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java (100%) rename dsl/camel-jbang/{camel-jbang-core => camel-jbang-plugin-tui}/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java (100%) rename dsl/camel-jbang/{camel-jbang-core => camel-jbang-plugin-tui}/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java (100%) rename dsl/camel-jbang/{camel-jbang-core => camel-jbang-plugin-tui}/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java (100%) rename dsl/camel-jbang/{camel-jbang-core => camel-jbang-plugin-tui}/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java (100%) rename dsl/camel-jbang/{camel-jbang-core => camel-jbang-plugin-tui}/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java (100%) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java diff --git a/dsl/camel-jbang/camel-jbang-core/pom.xml b/dsl/camel-jbang/camel-jbang-core/pom.xml index 58241f214dc08..31dccd48d50da 100644 --- a/dsl/camel-jbang/camel-jbang-core/pom.xml +++ b/dsl/camel-jbang/camel-jbang-core/pom.xml @@ -175,36 +175,8 @@ ${awaitility-version} test - - dev.tamboui - tamboui-tui - 0.2.0-SNAPSHOT - - - dev.tamboui - tamboui-widgets - 0.2.0-SNAPSHOT - - - dev.tamboui - tamboui-jline3-backend - 0.2.0-SNAPSHOT - - - - sonatype-snapshots - https://central.sonatype.com/repository/maven-snapshots/ - - true - - - false - - - - diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java index 92febdab46595..bd03af9ee39c3 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java @@ -49,7 +49,6 @@ import org.apache.camel.dsl.jbang.core.commands.plugin.PluginDelete; import org.apache.camel.dsl.jbang.core.commands.plugin.PluginGet; import org.apache.camel.dsl.jbang.core.commands.process.*; -import org.apache.camel.dsl.jbang.core.commands.tui.*; import org.apache.camel.dsl.jbang.core.commands.update.UpdateCommand; import org.apache.camel.dsl.jbang.core.commands.update.UpdateList; import org.apache.camel.dsl.jbang.core.commands.update.UpdateRun; @@ -97,8 +96,7 @@ public void execute(String... args) { .addSubcommand("kamelet", new CommandLine(new CatalogKamelet(this))) .addSubcommand("transformer", new CommandLine(new CatalogTransformer(this))) .addSubcommand("language", new CommandLine(new CatalogLanguage(this))) - .addSubcommand("other", new CommandLine(new CatalogOther(this))) - .addSubcommand("tui", new CommandLine(new CamelCatalogTui(this)))) + .addSubcommand("other", new CommandLine(new CatalogOther(this)))) .addSubcommand("cmd", new CommandLine(new CamelAction(this)) .addSubcommand("browse", new CommandLine(new CamelBrowseAction(this))) .addSubcommand("disable-processor", new CommandLine(new CamelProcessorDisableAction(this))) @@ -179,9 +177,7 @@ public void execute(String... args) { .addSubcommand("stop", new CommandLine(new InfraStop(this)))) .addSubcommand("init", new CommandLine(new Init(this))) .addSubcommand("jolokia", new CommandLine(new Jolokia(this))) - .addSubcommand("health", new CommandLine(new CamelHealthTui(this))) - .addSubcommand("log", new CommandLine(new CamelLogAction(this)) - .addSubcommand("tui", new CommandLine(new CamelLogTui(this)))) + .addSubcommand("log", new CommandLine(new CamelLogAction(this))) .addSubcommand("nano", new CommandLine(new Nano(this))) .addSubcommand("plugin", new CommandLine(new PluginCommand(this)) .addSubcommand("add", new CommandLine(new PluginAdd(this))) @@ -191,7 +187,6 @@ public void execute(String... args) { .addSubcommand("run", new CommandLine(new Run(this))) .addSubcommand("sbom", new CommandLine(new SBOMGenerator(this))) .addSubcommand("script", new CommandLine(new Script(this))) - .addSubcommand("monitor", new CommandLine(new CamelMonitor(this))) .addSubcommand("shell", new CommandLine(new Shell(this))) .addSubcommand("stop", new CommandLine(new StopProcess(this))) .addSubcommand("top", new CommandLine(new CamelTop(this)) @@ -199,10 +194,8 @@ public void execute(String... args) { .addSubcommand("group", new CommandLine(new CamelRouteGroupTop(this))) .addSubcommand("processor", new CommandLine(new CamelProcessorTop(this))) .addSubcommand("route", new CommandLine(new CamelRouteTop(this))) - .addSubcommand("source", new CommandLine(new CamelSourceTop(this))) - .addSubcommand("tui", new CommandLine(new CamelTopTui(this)))) - .addSubcommand("trace", new CommandLine(new CamelTraceAction(this)) - .addSubcommand("tui", new CommandLine(new CamelTraceTui(this)))) + .addSubcommand("source", new CommandLine(new CamelSourceTop(this)))) + .addSubcommand("trace", new CommandLine(new CamelTraceAction(this))) .addSubcommand("transform", new CommandLine(new TransformCommand(this)) .addSubcommand("dataweave", new CommandLine(new TransformDataWeave(this))) .addSubcommand("message", new CommandLine(new TransformMessageAction(this))) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java index 18329c37dcaac..ea1d09e1019fc 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java @@ -39,4 +39,10 @@ * First version this plugin was released. */ String firstVersion(); + + /** + * Names of existing commands that this plugin extends by adding subcommands. When any of these commands is invoked, + * the plugin will be loaded so it can inject its subcommands into the existing command tree. + */ + String[] parentCommands() default {}; } diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java index 3263d7138c2ad..3d31f4e9be9f0 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java @@ -22,8 +22,10 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.Arrays; import java.util.Enumeration; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Properties; @@ -118,19 +120,21 @@ public static void addPlugins(CommandLine commandLine, CamelJBangMain main, Stri } // Fall back to JSON configuration for additional or missing plugins - Map plugins = getActivePlugins(main, repos); - for (Map.Entry plugin : plugins.entrySet()) { - // only load the plugin if the command-line is calling this plugin - if (target != null && !"shell".equals(target) && !target.equals(plugin.getKey())) { + Map plugins = getActivePluginEntries(main, repos); + for (Map.Entry entry : plugins.entrySet()) { + PluginEntry pe = entry.getValue(); + // only load the plugin if the command-line is calling this plugin or one of its parent commands + if (target != null && !"shell".equals(target) && !target.equals(entry.getKey()) + && !pe.parentCommands().contains(target)) { continue; } // Skip if this plugin was already loaded from embedded plugins - if (foundEmbeddedPlugins && commandLine.getSubcommands().containsKey(plugin.getKey())) { + if (foundEmbeddedPlugins && commandLine.getSubcommands().containsKey(entry.getKey())) { continue; } - plugin.getValue().customize(commandLine, main); + pe.plugin().customize(commandLine, main); } } @@ -176,6 +180,53 @@ public static Map getActivePlugins(CamelJBangMain main, String r return activePlugins; } + record PluginEntry(Plugin plugin, List parentCommands) { + } + + @SuppressWarnings("unchecked") + static Map getActivePluginEntries(CamelJBangMain main, String repos) { + Map activePlugins = new HashMap<>(); + JsonObject config = getPluginConfig(); + if (config != null) { + CamelCatalog catalog = new DefaultCamelCatalog(); + String version = catalog.getCatalogVersion(); + JsonObject plugins = config.getMap("plugins"); + + for (String pluginKey : plugins.keySet()) { + JsonObject properties = plugins.getMap(pluginKey); + + final String name = properties.getOrDefault("name", pluginKey).toString(); + final String command = properties.getOrDefault("command", name).toString(); + final String firstVersion = properties.getOrDefault("firstVersion", "").toString(); + final String gav = properties.getOrDefault("dependency", "").toString(); + + // Parse parentCommands from JSON config + List parentCommands = List.of(); + Object pc = properties.get("parentCommands"); + if (pc instanceof List pcList) { + parentCommands = pcList.stream().map(Object::toString).toList(); + } else if (pc instanceof String pcs && !pcs.isEmpty()) { + parentCommands = Arrays.asList(pcs.split(",")); + } + + // check if plugin version can be loaded (cannot if we use an older camel version than the plugin) + if (!version.isBlank() && !firstVersion.isBlank()) { + versionCheck(main, version, firstVersion, command); + } + + Optional plugin = getPlugin(command, version, gav, repos, main.getOut()); + if (plugin.isPresent()) { + activePlugins.put(command, new PluginEntry(plugin.get(), parentCommands)); + } else { + main.getOut().println("camel-jbang-plugin-" + command + " not found. Exit"); + main.quit(1); + } + } + } + + return activePlugins; + } + public static Optional getPlugin(String name, String defaultVersion, String gav, String repos, Printer printer) { Optional plugin = FACTORY_FINDER.newInstance("camel-jbang-plugin-" + name, Plugin.class); if (plugin.isEmpty()) { @@ -428,12 +479,13 @@ private static boolean loadPluginFromService( String command = extractCommandFromPlugin(pluginClass, pluginName); // Only load the plugin if the command-line is calling this plugin or if target is null (shell mode) - if (target != null && !"shell".equals(target) && !target.equals(command)) { + CamelJBangPlugin annotation = pluginClass.getAnnotation(CamelJBangPlugin.class); + if (target != null && !"shell".equals(target) && !target.equals(command) + && !matchesParentCommand(annotation, target)) { return false; } // Check version compatibility if needed - CamelJBangPlugin annotation = pluginClass.getAnnotation(CamelJBangPlugin.class); if (annotation != null) { CamelCatalog catalog = new DefaultCamelCatalog(); String version = catalog.getCatalogVersion(); @@ -468,6 +520,20 @@ private static String extractCommandFromPlugin(Class pluginClass, String plug return pluginName; } + /** + * Checks whether the target command matches one of the plugin's declared parent commands. + */ + private static boolean matchesParentCommand(CamelJBangPlugin annotation, String target) { + if (annotation != null) { + for (String pc : annotation.parentCommands()) { + if (pc.equals(target)) { + return true; + } + } + } + return false; + } + /** * Checks if embedded plugins are available in the classpath. */ diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml new file mode 100644 index 0000000000000..44e7bd2a59c1c --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml @@ -0,0 +1,78 @@ + + + + + 4.0.0 + + + org.apache.camel + camel-jbang-parent + 4.19.0-SNAPSHOT + ../pom.xml + + + camel-jbang-plugin-tui + + Camel :: JBang :: Plugin :: TUI + Camel JBang TUI Plugin - Terminal UI dashboards for monitoring Camel integrations + + + 4.19.0 + + Preview + false + + + + + org.apache.camel + camel-jbang-core + + + org.apache.camel + camel-catalog + + + dev.tamboui + tamboui-tui + 0.2.0-SNAPSHOT + + + dev.tamboui + tamboui-widgets + 0.2.0-SNAPSHOT + + + dev.tamboui + tamboui-jline3-backend + 0.2.0-SNAPSHOT + + + + + + sonatype-snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + true + + + + diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java similarity index 100% rename from dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java rename to dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java similarity index 100% rename from dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java rename to dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java similarity index 100% rename from dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java rename to dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java similarity index 100% rename from dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java rename to dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java similarity index 100% rename from dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java rename to dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java similarity index 100% rename from dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java rename to dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java similarity index 100% rename from dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java rename to dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java new file mode 100644 index 0000000000000..f37b99c6935b2 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java @@ -0,0 +1,52 @@ +/* + * 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 org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.CamelJBangPlugin; +import org.apache.camel.dsl.jbang.core.common.Plugin; +import picocli.CommandLine; + +@CamelJBangPlugin(name = "camel-jbang-plugin-tui", firstVersion = "4.19.0", + parentCommands = { "top", "trace", "log", "catalog" }) +public class TuiPlugin implements Plugin { + + @Override + public void customize(CommandLine commandLine, CamelJBangMain main) { + // Top-level TUI commands + commandLine.addSubcommand("health", new CommandLine(new CamelHealthTui(main))); + commandLine.addSubcommand("monitor", new CommandLine(new CamelMonitor(main))); + + // Subcommands of existing commands + CommandLine topCmd = commandLine.getSubcommands().get("top"); + if (topCmd != null) { + topCmd.addSubcommand("tui", new CommandLine(new CamelTopTui(main))); + } + CommandLine traceCmd = commandLine.getSubcommands().get("trace"); + if (traceCmd != null) { + traceCmd.addSubcommand("tui", new CommandLine(new CamelTraceTui(main))); + } + CommandLine logCmd = commandLine.getSubcommands().get("log"); + if (logCmd != null) { + logCmd.addSubcommand("tui", new CommandLine(new CamelLogTui(main))); + } + CommandLine catalogCmd = commandLine.getSubcommands().get("catalog"); + if (catalogCmd != null) { + catalogCmd.addSubcommand("tui", new CommandLine(new CamelCatalogTui(main))); + } + } +} diff --git a/dsl/camel-jbang/camel-launcher/pom.xml b/dsl/camel-jbang/camel-launcher/pom.xml index 5f5a2a0bdf956..4d8c9758bbc0b 100644 --- a/dsl/camel-jbang/camel-launcher/pom.xml +++ b/dsl/camel-jbang/camel-launcher/pom.xml @@ -81,6 +81,11 @@ camel-jbang-plugin-kubernetes ${project.version} + + org.apache.camel + camel-jbang-plugin-tui + ${project.version} + org.apache.camel camel-jbang-plugin-validate diff --git a/dsl/camel-jbang/pom.xml b/dsl/camel-jbang/pom.xml index 92bb045fd0dfd..cb6f1d34d7218 100644 --- a/dsl/camel-jbang/pom.xml +++ b/dsl/camel-jbang/pom.xml @@ -43,6 +43,7 @@ camel-jbang-plugin-kubernetes camel-jbang-plugin-route-parser camel-jbang-plugin-test + camel-jbang-plugin-tui camel-jbang-plugin-validate camel-jbang-it camel-launcher From 3c2a7802400b315c96d886ecbcf7f47c3a89e6fe Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 23 Mar 2026 17:53:49 +0100 Subject: [PATCH 05/10] CAMEL-23226: Fix plugin loading and register TuiPlugin in launcher - Register TuiPlugin in CamelLauncherMain.postAddCommands() (plugins are loaded explicitly, not via classpath scanning) - Rename parentCommands to commands in @CamelJBangPlugin annotation to cover both top-level commands and parent commands the plugin extends - Include health and monitor in the plugin's commands list Co-Authored-By: Claude Opus 4.6 --- .../jbang/core/common/CamelJBangPlugin.java | 7 ++--- .../dsl/jbang/core/common/PluginHelper.java | 26 +++++++++---------- .../camel-jbang-plugin/camel-jbang-plugin-tui | 2 ++ .../jbang/core/commands/tui/TuiPlugin.java | 2 +- .../dsl/jbang/launcher/CamelLauncherMain.java | 2 ++ 5 files changed, 22 insertions(+), 17 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/services/org/apache/camel/camel-jbang-plugin/camel-jbang-plugin-tui diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java index ea1d09e1019fc..1ac0f1a882c9f 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java @@ -41,8 +41,9 @@ String firstVersion(); /** - * Names of existing commands that this plugin extends by adding subcommands. When any of these commands is invoked, - * the plugin will be loaded so it can inject its subcommands into the existing command tree. + * Additional command names that should trigger loading this plugin. This includes names of existing commands that + * this plugin extends by adding subcommands, as well as any top-level commands the plugin adds. When any of these + * commands is invoked, the plugin will be loaded. */ - String[] parentCommands() default {}; + String[] commands() default {}; } diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java index 3d31f4e9be9f0..c0ed5f5628ad1 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java @@ -125,7 +125,7 @@ public static void addPlugins(CommandLine commandLine, CamelJBangMain main, Stri PluginEntry pe = entry.getValue(); // only load the plugin if the command-line is calling this plugin or one of its parent commands if (target != null && !"shell".equals(target) && !target.equals(entry.getKey()) - && !pe.parentCommands().contains(target)) { + && !pe.commands().contains(target)) { continue; } @@ -180,7 +180,7 @@ public static Map getActivePlugins(CamelJBangMain main, String r return activePlugins; } - record PluginEntry(Plugin plugin, List parentCommands) { + record PluginEntry(Plugin plugin, List commands) { } @SuppressWarnings("unchecked") @@ -200,13 +200,13 @@ static Map getActivePluginEntries(CamelJBangMain main, Stri final String firstVersion = properties.getOrDefault("firstVersion", "").toString(); final String gav = properties.getOrDefault("dependency", "").toString(); - // Parse parentCommands from JSON config - List parentCommands = List.of(); - Object pc = properties.get("parentCommands"); + // Parse additional commands from JSON config + List commands = List.of(); + Object pc = properties.get("commands"); if (pc instanceof List pcList) { - parentCommands = pcList.stream().map(Object::toString).toList(); + commands = pcList.stream().map(Object::toString).toList(); } else if (pc instanceof String pcs && !pcs.isEmpty()) { - parentCommands = Arrays.asList(pcs.split(",")); + commands = Arrays.asList(pcs.split(",")); } // check if plugin version can be loaded (cannot if we use an older camel version than the plugin) @@ -216,7 +216,7 @@ static Map getActivePluginEntries(CamelJBangMain main, Stri Optional plugin = getPlugin(command, version, gav, repos, main.getOut()); if (plugin.isPresent()) { - activePlugins.put(command, new PluginEntry(plugin.get(), parentCommands)); + activePlugins.put(command, new PluginEntry(plugin.get(), commands)); } else { main.getOut().println("camel-jbang-plugin-" + command + " not found. Exit"); main.quit(1); @@ -481,7 +481,7 @@ private static boolean loadPluginFromService( // Only load the plugin if the command-line is calling this plugin or if target is null (shell mode) CamelJBangPlugin annotation = pluginClass.getAnnotation(CamelJBangPlugin.class); if (target != null && !"shell".equals(target) && !target.equals(command) - && !matchesParentCommand(annotation, target)) { + && !matchesCommand(annotation, target)) { return false; } @@ -521,12 +521,12 @@ private static String extractCommandFromPlugin(Class pluginClass, String plug } /** - * Checks whether the target command matches one of the plugin's declared parent commands. + * Checks whether the target command matches one of the plugin's declared additional commands. */ - private static boolean matchesParentCommand(CamelJBangPlugin annotation, String target) { + private static boolean matchesCommand(CamelJBangPlugin annotation, String target) { if (annotation != null) { - for (String pc : annotation.parentCommands()) { - if (pc.equals(target)) { + for (String cmd : annotation.commands()) { + if (cmd.equals(target)) { return true; } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/services/org/apache/camel/camel-jbang-plugin/camel-jbang-plugin-tui b/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/services/org/apache/camel/camel-jbang-plugin/camel-jbang-plugin-tui new file mode 100644 index 0000000000000..781ed88070510 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/generated/resources/META-INF/services/org/apache/camel/camel-jbang-plugin/camel-jbang-plugin-tui @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.dsl.jbang.core.commands.tui.TuiPlugin diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java index f37b99c6935b2..08dead9aef4ae 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java @@ -22,7 +22,7 @@ import picocli.CommandLine; @CamelJBangPlugin(name = "camel-jbang-plugin-tui", firstVersion = "4.19.0", - parentCommands = { "top", "trace", "log", "catalog" }) + commands = { "health", "monitor", "top", "trace", "log", "catalog" }) public class TuiPlugin implements Plugin { @Override diff --git a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherMain.java b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherMain.java index 8acfc37760ae1..ce5a77bd37588 100644 --- a/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherMain.java +++ b/dsl/camel-jbang/camel-launcher/src/main/java/org/apache/camel/dsl/jbang/launcher/CamelLauncherMain.java @@ -20,6 +20,7 @@ import org.apache.camel.dsl.jbang.core.commands.generate.GeneratePlugin; import org.apache.camel.dsl.jbang.core.commands.kubernetes.KubernetesPlugin; import org.apache.camel.dsl.jbang.core.commands.test.TestPlugin; +import org.apache.camel.dsl.jbang.core.commands.tui.TuiPlugin; import org.apache.camel.dsl.jbang.core.commands.validate.ValidatePlugin; import picocli.CommandLine; @@ -34,6 +35,7 @@ public void postAddCommands(CommandLine commandLine, String[] args) { new GeneratePlugin().customize(commandLine, this); new KubernetesPlugin().customize(commandLine, this); new TestPlugin().customize(commandLine, this); + new TuiPlugin().customize(commandLine, this); new ValidatePlugin().customize(commandLine, this); } From 635fcdac691f5459a724f3083ab1ea1f2159efdc Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 23 Mar 2026 20:07:03 +0100 Subject: [PATCH 06/10] CAMEL-23226: Consolidate TUI commands and enhance catalog browser - Consolidate CamelTopTui, CamelTraceTui, CamelLogTui, CamelHealthTui into unified CamelMonitor dashboard with 6 tabs (Overview, Routes, Health, Endpoints, Log, Trace) - Enhanced Routes tab with sort cycling (mean/max/total/failed/name) - Enhanced Health tab with readiness/liveness columns and DOWN filter - Enhanced Log tab with level filtering and follow mode - New Trace tab with exchange-level trace viewer - Redesigned CamelCatalogTui with split-panel layout (component list + options table) and context-sensitive description panel - Added inline per-panel filtering with name-only and full-text toggle (/) - Simplified TuiPlugin to only register top-level commands - Removed commands attribute from @CamelJBangPlugin annotation - Reverted plugin loading mechanism in PluginHelper Co-Authored-By: Claude Opus 4.6 --- .../jbang/core/common/CamelJBangPlugin.java | 6 - .../dsl/jbang/core/common/PluginHelper.java | 78 +- .../core/commands/tui/CamelCatalogTui.java | 642 +++++++++------- .../core/commands/tui/CamelHealthTui.java | 441 ----------- .../jbang/core/commands/tui/CamelLogTui.java | 447 ----------- .../jbang/core/commands/tui/CamelMonitor.java | 719 ++++++++++++++++-- .../jbang/core/commands/tui/CamelTopTui.java | 422 ---------- .../core/commands/tui/CamelTraceTui.java | 581 -------------- .../jbang/core/commands/tui/TuiPlugin.java | 24 +- 9 files changed, 1025 insertions(+), 2335 deletions(-) delete mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java delete mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java delete mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java delete mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java index 1ac0f1a882c9f..acfd1beae4c9e 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java @@ -40,10 +40,4 @@ */ String firstVersion(); - /** - * Additional command names that should trigger loading this plugin. This includes names of existing commands that - * this plugin extends by adding subcommands, as well as any top-level commands the plugin adds. When any of these - * commands is invoked, the plugin will be loaded. - */ - String[] commands() default {}; } diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java index c0ed5f5628ad1..e84542c608bea 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java @@ -22,10 +22,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.util.Arrays; import java.util.Enumeration; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Properties; @@ -120,12 +118,10 @@ public static void addPlugins(CommandLine commandLine, CamelJBangMain main, Stri } // Fall back to JSON configuration for additional or missing plugins - Map plugins = getActivePluginEntries(main, repos); - for (Map.Entry entry : plugins.entrySet()) { - PluginEntry pe = entry.getValue(); - // only load the plugin if the command-line is calling this plugin or one of its parent commands - if (target != null && !"shell".equals(target) && !target.equals(entry.getKey()) - && !pe.commands().contains(target)) { + Map plugins = getActivePlugins(main, repos); + for (Map.Entry entry : plugins.entrySet()) { + // only load the plugin if the command-line is calling this plugin + if (target != null && !"shell".equals(target) && !target.equals(entry.getKey())) { continue; } @@ -134,7 +130,7 @@ public static void addPlugins(CommandLine commandLine, CamelJBangMain main, Stri continue; } - pe.plugin().customize(commandLine, main); + entry.getValue().customize(commandLine, main); } } @@ -180,53 +176,6 @@ public static Map getActivePlugins(CamelJBangMain main, String r return activePlugins; } - record PluginEntry(Plugin plugin, List commands) { - } - - @SuppressWarnings("unchecked") - static Map getActivePluginEntries(CamelJBangMain main, String repos) { - Map activePlugins = new HashMap<>(); - JsonObject config = getPluginConfig(); - if (config != null) { - CamelCatalog catalog = new DefaultCamelCatalog(); - String version = catalog.getCatalogVersion(); - JsonObject plugins = config.getMap("plugins"); - - for (String pluginKey : plugins.keySet()) { - JsonObject properties = plugins.getMap(pluginKey); - - final String name = properties.getOrDefault("name", pluginKey).toString(); - final String command = properties.getOrDefault("command", name).toString(); - final String firstVersion = properties.getOrDefault("firstVersion", "").toString(); - final String gav = properties.getOrDefault("dependency", "").toString(); - - // Parse additional commands from JSON config - List commands = List.of(); - Object pc = properties.get("commands"); - if (pc instanceof List pcList) { - commands = pcList.stream().map(Object::toString).toList(); - } else if (pc instanceof String pcs && !pcs.isEmpty()) { - commands = Arrays.asList(pcs.split(",")); - } - - // check if plugin version can be loaded (cannot if we use an older camel version than the plugin) - if (!version.isBlank() && !firstVersion.isBlank()) { - versionCheck(main, version, firstVersion, command); - } - - Optional plugin = getPlugin(command, version, gav, repos, main.getOut()); - if (plugin.isPresent()) { - activePlugins.put(command, new PluginEntry(plugin.get(), commands)); - } else { - main.getOut().println("camel-jbang-plugin-" + command + " not found. Exit"); - main.quit(1); - } - } - } - - return activePlugins; - } - public static Optional getPlugin(String name, String defaultVersion, String gav, String repos, Printer printer) { Optional plugin = FACTORY_FINDER.newInstance("camel-jbang-plugin-" + name, Plugin.class); if (plugin.isEmpty()) { @@ -480,8 +429,7 @@ private static boolean loadPluginFromService( // Only load the plugin if the command-line is calling this plugin or if target is null (shell mode) CamelJBangPlugin annotation = pluginClass.getAnnotation(CamelJBangPlugin.class); - if (target != null && !"shell".equals(target) && !target.equals(command) - && !matchesCommand(annotation, target)) { + if (target != null && !"shell".equals(target) && !target.equals(command)) { return false; } @@ -520,20 +468,6 @@ private static String extractCommandFromPlugin(Class pluginClass, String plug return pluginName; } - /** - * Checks whether the target command matches one of the plugin's declared additional commands. - */ - private static boolean matchesCommand(CamelJBangPlugin annotation, String target) { - if (annotation != null) { - for (String cmd : annotation.commands()) { - if (cmd.equals(target)) { - return true; - } - } - } - return false; - } - /** * Checks if embedded plugins are available in the classpath. */ diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java index ab3edc857693a..5be691bc8b061 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java @@ -57,7 +57,7 @@ public class CamelCatalogTui extends CamelCommand { private static final int FOCUS_LIST = 0; - private static final int FOCUS_DETAILS = 1; + private static final int FOCUS_OPTIONS = 1; // Catalog data private List allComponents = Collections.emptyList(); @@ -67,12 +67,16 @@ public class CamelCatalogTui extends CamelCommand { private final TableState listTableState = new TableState(); private final TableState optionsTableState = new TableState(); private int focus = FOCUS_LIST; - private boolean searching; - private final StringBuilder searchText = new StringBuilder(); - private int detailScroll; + private final StringBuilder componentFilter = new StringBuilder(); + private final StringBuilder optionFilter = new StringBuilder(); + private boolean componentFullText; + private boolean optionFullText; + private int descriptionScroll; - // Selected component details - private ComponentDetail selectedDetail; + // All options for selected component (unfiltered) + private List allOptionsUnfiltered = Collections.emptyList(); + // Filtered options displayed in table + private List filteredOptions = Collections.emptyList(); public CamelCatalogTui(CamelJBangMain main) { super(main); @@ -96,7 +100,7 @@ public Integer doCall() throws Exception { @SuppressWarnings("unchecked") private void loadCatalog() { CamelCatalog catalog = new DefaultCamelCatalog(); - List names = catalog.findComponentNames(); + List names = new ArrayList<>(catalog.findComponentNames()); Collections.sort(names); allComponents = new ArrayList<>(); @@ -124,24 +128,8 @@ private void loadCatalog() { info.syntax = component.getStringOrDefault("syntax", ""); info.deprecated = component.getBooleanOrDefault("deprecated", false); - // Parse component properties + // Parse properties — separate by kind JsonObject properties = (JsonObject) root.get("properties"); - if (properties != null) { - for (Map.Entry entry : properties.entrySet()) { - JsonObject prop = (JsonObject) entry.getValue(); - OptionInfo opt = parseOption(entry.getKey(), prop); - opt.kind = "property"; - info.componentOptions.add(opt); - } - } - - // Parse endpoint properties (headers in newer catalogs, but "properties" is the standard) - // The endpoint options are typically under "properties" for the endpoint - // In the catalog JSON, component-level options have "kind":"property" - // and endpoint options have "kind":"path" or "kind":"parameter" - // So we re-scan and separate them - info.componentOptions.clear(); - info.endpointOptions.clear(); if (properties != null) { for (Map.Entry entry : properties.entrySet()) { JsonObject prop = (JsonObject) entry.getValue(); @@ -162,10 +150,10 @@ private void loadCatalog() { } } - applyFilter(); + applyComponentFilter(); if (!filteredComponents.isEmpty()) { listTableState.select(0); - updateSelectedDetail(); + updateSelectedComponent(); } } @@ -189,136 +177,202 @@ private OptionInfo parseOption(String name, JsonObject prop) { return opt; } - private void applyFilter() { - String filter = searchText.toString().toLowerCase(); + private void applyComponentFilter() { + String filter = componentFilter.toString().toLowerCase(); if (filter.isEmpty()) { filteredComponents = new ArrayList<>(allComponents); } else { filteredComponents = new ArrayList<>(); for (ComponentInfo c : allComponents) { - if (c.name.toLowerCase().contains(filter) - || c.title.toLowerCase().contains(filter) - || c.description.toLowerCase().contains(filter) - || c.label.toLowerCase().contains(filter)) { - filteredComponents.add(c); + if (componentFullText) { + if (c.name.toLowerCase().contains(filter) + || c.title.toLowerCase().contains(filter) + || c.description.toLowerCase().contains(filter) + || c.label.toLowerCase().contains(filter)) { + filteredComponents.add(c); + } + } else { + if (c.name.toLowerCase().contains(filter)) { + filteredComponents.add(c); + } } } } - // Reset selection if (!filteredComponents.isEmpty()) { listTableState.select(0); } else { listTableState.clearSelection(); } - updateSelectedDetail(); + updateSelectedComponent(); } - private void updateSelectedDetail() { + private void applyOptionFilter() { + String filter = optionFilter.toString().toLowerCase(); + if (filter.isEmpty()) { + filteredOptions = new ArrayList<>(allOptionsUnfiltered); + } else { + filteredOptions = new ArrayList<>(); + for (OptionInfo o : allOptionsUnfiltered) { + if (optionFullText) { + if (o.name.toLowerCase().contains(filter) + || o.description.toLowerCase().contains(filter) + || o.group.toLowerCase().contains(filter)) { + filteredOptions.add(o); + } + } else { + if (o.name.toLowerCase().contains(filter)) { + filteredOptions.add(o); + } + } + } + } + if (!filteredOptions.isEmpty()) { + optionsTableState.select(0); + } else { + optionsTableState.clearSelection(); + } + descriptionScroll = 0; + } + + private void updateSelectedComponent() { Integer sel = listTableState.selected(); if (sel != null && sel >= 0 && sel < filteredComponents.size()) { ComponentInfo info = filteredComponents.get(sel); - selectedDetail = new ComponentDetail(info); - detailScroll = 0; + allOptionsUnfiltered = new ArrayList<>(); + allOptionsUnfiltered.addAll(info.endpointOptions); + allOptionsUnfiltered.addAll(info.componentOptions); } else { - selectedDetail = null; + allOptionsUnfiltered = Collections.emptyList(); } + optionFilter.setLength(0); + optionFullText = false; + applyOptionFilter(); + descriptionScroll = 0; } // ---- Event Handling ---- private boolean handleEvent(Event event, TuiRunner runner) { if (event instanceof KeyEvent ke) { - // Search mode handling - if (searching) { - if (ke.isKey(KeyCode.ESCAPE)) { - if (searchText.isEmpty()) { - searching = false; - } else { - searchText.setLength(0); - applyFilter(); - } - return true; - } - if (ke.isKey(KeyCode.ENTER)) { - searching = false; - return true; - } - if (ke.isKey(KeyCode.BACKSPACE)) { - if (!searchText.isEmpty()) { - searchText.deleteCharAt(searchText.length() - 1); - applyFilter(); - } - return true; - } - if (ke.isUp()) { - listTableState.selectPrevious(); - updateSelectedDetail(); + // Quit + if (ke.isQuit()) { + runner.quit(); + return true; + } + + // Escape: clear filter first, then go back, then quit + if (ke.isKey(KeyCode.ESCAPE)) { + if (focus == FOCUS_OPTIONS && (!optionFilter.isEmpty() || optionFullText)) { + optionFilter.setLength(0); + optionFullText = false; + applyOptionFilter(); return true; } - if (ke.isDown()) { - listTableState.selectNext(filteredComponents.size()); - updateSelectedDetail(); + if (focus == FOCUS_OPTIONS) { + focus = FOCUS_LIST; + descriptionScroll = 0; return true; } - // Typed character - if (ke.code() == KeyCode.CHAR) { - searchText.append(ke.character()); - applyFilter(); + if (!componentFilter.isEmpty() || componentFullText) { + componentFilter.setLength(0); + componentFullText = false; + applyComponentFilter(); return true; } + runner.quit(); return true; } - // Normal mode - if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) { - runner.quit(); + // Backspace: delete from active filter + if (ke.isKey(KeyCode.BACKSPACE)) { + if (focus == FOCUS_LIST && !componentFilter.isEmpty()) { + componentFilter.deleteCharAt(componentFilter.length() - 1); + applyComponentFilter(); + } else if (focus == FOCUS_OPTIONS && !optionFilter.isEmpty()) { + optionFilter.deleteCharAt(optionFilter.length() - 1); + applyOptionFilter(); + } return true; } - if (ke.isChar('/')) { - searching = true; + // Panel switching — only when no active filter on current panel + if (ke.isKey(KeyCode.TAB)) { + if (focus == FOCUS_LIST) { + focus = FOCUS_OPTIONS; + } else { + focus = FOCUS_LIST; + } + descriptionScroll = 0; + return true; + } + if (ke.isKey(KeyCode.RIGHT) && focus == FOCUS_LIST) { + focus = FOCUS_OPTIONS; + descriptionScroll = 0; + return true; + } + if (ke.isKey(KeyCode.LEFT) && focus == FOCUS_OPTIONS) { + focus = FOCUS_LIST; + descriptionScroll = 0; return true; } - if (ke.isKey(KeyCode.TAB)) { - focus = (focus == FOCUS_LIST) ? FOCUS_DETAILS : FOCUS_LIST; + // Enter drills into options + if (ke.isKey(KeyCode.ENTER) && focus == FOCUS_LIST) { + focus = FOCUS_OPTIONS; + descriptionScroll = 0; return true; } + // Navigation if (ke.isUp()) { if (focus == FOCUS_LIST) { listTableState.selectPrevious(); - updateSelectedDetail(); + updateSelectedComponent(); } else { - detailScroll = Math.max(0, detailScroll - 1); + optionsTableState.selectPrevious(); + descriptionScroll = 0; } return true; } if (ke.isDown()) { if (focus == FOCUS_LIST) { listTableState.selectNext(filteredComponents.size()); - updateSelectedDetail(); + updateSelectedComponent(); } else { - detailScroll++; + optionsTableState.selectNext(filteredOptions.size()); + descriptionScroll = 0; } return true; } if (ke.isKey(KeyCode.PAGE_UP)) { - if (focus == FOCUS_DETAILS) { - detailScroll = Math.max(0, detailScroll - 20); - } + descriptionScroll = Math.max(0, descriptionScroll - 5); return true; } if (ke.isKey(KeyCode.PAGE_DOWN)) { - if (focus == FOCUS_DETAILS) { - detailScroll += 20; + descriptionScroll += 5; + return true; + } + + // '/' toggles full-text search mode + if (ke.isChar('/')) { + if (focus == FOCUS_LIST) { + componentFullText = !componentFullText; + applyComponentFilter(); + } else { + optionFullText = !optionFullText; + applyOptionFilter(); } return true; } - if (ke.isKey(KeyCode.ENTER)) { + + // Typing filters the active panel + if (ke.code() == KeyCode.CHAR) { if (focus == FOCUS_LIST) { - updateSelectedDetail(); - focus = FOCUS_DETAILS; + componentFilter.append(ke.character()); + applyComponentFilter(); + } else { + optionFilter.append(ke.character()); + applyOptionFilter(); } return true; } @@ -331,35 +385,29 @@ private boolean handleEvent(Event event, TuiRunner runner) { private void render(Frame frame) { Rect area = frame.area(); - // Layout: header (3 rows) + content (fill) + footer (1 row) + // Layout: header (3) + top panels (fill) + separator (1) + description (40%) + footer (1) List mainChunks = Layout.vertical() .constraints( Constraint.length(3), + Constraint.percentage(50), + Constraint.length(1), Constraint.fill(), Constraint.length(1)) .split(area); renderHeader(frame, mainChunks.get(0)); - renderContent(frame, mainChunks.get(1)); - renderFooter(frame, mainChunks.get(2)); + renderTopPanels(frame, mainChunks.get(1)); + renderSeparator(frame, mainChunks.get(2)); + renderDescription(frame, mainChunks.get(3)); + renderFooter(frame, mainChunks.get(4)); } private void renderHeader(Frame frame, Rect area) { - String searchStatus; - if (searching) { - searchStatus = " Search: " + searchText + "_"; - } else if (!searchText.isEmpty()) { - searchStatus = " Filter: \"" + searchText + "\""; - } else { - searchStatus = ""; - } - Line titleLine = Line.from( Span.styled(" Camel Catalog", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()), Span.raw(" "), Span.styled(filteredComponents.size() + "/" + allComponents.size() + " components", - Style.create().fg(Color.CYAN)), - Span.styled(searchStatus, Style.create().fg(Color.YELLOW))); + Style.create().fg(Color.CYAN))); Block headerBlock = Block.builder() .borderType(BorderType.ROUNDED) @@ -371,16 +419,14 @@ private void renderHeader(Frame frame, Rect area) { area); } - private void renderContent(Frame frame, Rect area) { - // Split horizontally: left panel (30%) + right panel (70%) + private void renderTopPanels(Frame frame, Rect area) { + // Split horizontally: component list (30%) + options table (70%) List chunks = Layout.horizontal() - .constraints( - Constraint.percentage(30), - Constraint.percentage(70)) + .constraints(Constraint.percentage(30), Constraint.percentage(70)) .split(area); renderComponentList(frame, chunks.get(0)); - renderComponentDetails(frame, chunks.get(1)); + renderOptionsTable(frame, chunks.get(1)); } private void renderComponentList(Frame frame, Rect area) { @@ -404,6 +450,11 @@ private void renderComponentList(Frame frame, Rect area) { ? Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)) : Style.create(); + String modePrefix = componentFullText ? "/" : ""; + String listTitle = componentFilter.isEmpty() && !componentFullText + ? " Components " + : " Components [" + modePrefix + componentFilter + "] "; + Table table = Table.builder() .rows(rows) .widths(Constraint.fill()) @@ -411,154 +462,42 @@ private void renderComponentList(Frame frame, Rect area) { .block(Block.builder() .borderType(BorderType.ROUNDED) .borderStyle(borderStyle) - .title(" Components ") + .title(listTitle) .build()) .build(); frame.renderStatefulWidget(table, area, listTableState); } - private void renderComponentDetails(Frame frame, Rect area) { - Style borderStyle = focus == FOCUS_DETAILS + private void renderOptionsTable(Frame frame, Rect area) { + Style borderStyle = focus == FOCUS_OPTIONS ? Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)) : Style.create(); - if (selectedDetail == null) { + String optModePrefix = optionFullText ? "/" : ""; + String optTitle = optionFilter.isEmpty() && !optionFullText + ? " Options " + : " Options [" + optModePrefix + optionFilter + "] "; + + if (filteredOptions.isEmpty()) { + String emptyMsg = allOptionsUnfiltered.isEmpty() ? " Select a component" : " No matching options"; frame.renderWidget( Paragraph.builder() .text(Text.from(Line.from( - Span.styled(" Select a component from the list", - Style.create().dim())))) + Span.styled(emptyMsg, Style.create().dim())))) .block(Block.builder() .borderType(BorderType.ROUNDED) .borderStyle(borderStyle) - .title(" Details ") + .title(optTitle) .build()) .build(), area); return; } - ComponentInfo comp = selectedDetail.info; - - // Split: info area (top) + options table (bottom) - List chunks = Layout.vertical() - .constraints( - Constraint.length(9), - Constraint.fill()) - .split(area); - - // Component info panel - List infoLines = new ArrayList<>(); - infoLines.add(Line.from( - Span.styled(" Title: ", Style.create().bold()), - Span.styled(comp.title, Style.create().fg(Color.CYAN)))); - infoLines.add(Line.from( - Span.styled(" Scheme: ", Style.create().bold()), - Span.raw(comp.scheme))); - infoLines.add(Line.from( - Span.styled(" Syntax: ", Style.create().bold()), - Span.raw(comp.syntax))); - infoLines.add(Line.from( - Span.styled(" Label: ", Style.create().bold()), - Span.styled(comp.label, Style.create().fg(Color.GREEN)))); - infoLines.add(Line.from( - Span.styled(" Maven: ", Style.create().bold()), - Span.raw(comp.groupId + ":" + comp.artifactId + ":" + comp.version))); - if (comp.deprecated) { - infoLines.add(Line.from( - Span.styled(" Status: ", Style.create().bold()), - Span.styled("DEPRECATED", Style.create().fg(Color.RED).bold()))); - } - infoLines.add(Line.from(Span.raw(""))); - // Word-wrap description into the available width - int descWidth = Math.max(10, chunks.get(0).width() - 4); - String desc = comp.description; - if (desc.length() > descWidth) { - infoLines.add(Line.from( - Span.styled(" " + desc.substring(0, descWidth), Style.create().dim()))); - if (desc.length() > descWidth * 2) { - infoLines.add(Line.from( - Span.styled(" " + desc.substring(descWidth, descWidth * 2) + "...", Style.create().dim()))); - } else { - infoLines.add(Line.from( - Span.styled(" " + desc.substring(descWidth), Style.create().dim()))); - } - } else { - infoLines.add(Line.from(Span.styled(" " + desc, Style.create().dim()))); - } - - frame.renderWidget( - Paragraph.builder() - .text(Text.from(infoLines)) - .overflow(Overflow.CLIP) - .block(Block.builder() - .borderType(BorderType.ROUNDED) - .borderStyle(borderStyle) - .title(" " + comp.title + " ") - .build()) - .build(), - chunks.get(0)); - - // Options table - renderOptionsTable(frame, chunks.get(1), borderStyle); - } - - private void renderOptionsTable(Frame frame, Rect area, Style borderStyle) { - if (selectedDetail == null) { - return; - } - - ComponentInfo comp = selectedDetail.info; - - // Combine all options with section headers List rows = new ArrayList<>(); - - if (!comp.endpointOptions.isEmpty()) { - rows.add(Row.from( - Cell.from(Span.styled("--- Endpoint Options ---", Style.create().fg(Color.YELLOW).bold())), - Cell.from(""), - Cell.from(""), - Cell.from(""), - Cell.from(""))); - for (OptionInfo opt : comp.endpointOptions) { - rows.add(optionToRow(opt)); - } - } - - if (!comp.componentOptions.isEmpty()) { - rows.add(Row.from( - Cell.from(Span.styled("--- Component Options ---", Style.create().fg(Color.YELLOW).bold())), - Cell.from(""), - Cell.from(""), - Cell.from(""), - Cell.from(""))); - for (OptionInfo opt : comp.componentOptions) { - rows.add(optionToRow(opt)); - } - } - - if (rows.isEmpty()) { - rows.add(Row.from( - Cell.from(Span.styled("No options", Style.create().dim())), - Cell.from(""), - Cell.from(""), - Cell.from(""), - Cell.from(""))); - } - - // Apply scrolling - int innerHeight = Math.max(1, area.height() - 3); // border + header - int maxScroll = Math.max(0, rows.size() - innerHeight); - if (detailScroll > maxScroll) { - detailScroll = maxScroll; - } - List visibleRows; - if (detailScroll > 0 && detailScroll < rows.size()) { - int end = Math.min(detailScroll + innerHeight, rows.size()); - visibleRows = rows.subList(detailScroll, end); - } else { - visibleRows = rows; + for (OptionInfo opt : filteredOptions) { + rows.add(optionToRow(opt)); } Row header = Row.from( @@ -566,15 +505,10 @@ private void renderOptionsTable(Frame frame, Rect area, Style borderStyle) { Cell.from(Span.styled("TYPE", Style.create().bold())), Cell.from(Span.styled("REQ", Style.create().bold())), Cell.from(Span.styled("DEFAULT", Style.create().bold())), - Cell.from(Span.styled("DESCRIPTION", Style.create().bold()))); - - String scrollInfo = rows.size() > innerHeight - ? " [" + (detailScroll + 1) + "-" + Math.min(detailScroll + innerHeight, rows.size()) - + "/" + rows.size() + "] " - : " "; + Cell.from(Span.styled("KIND", Style.create().bold()))); Table table = Table.builder() - .rows(visibleRows) + .rows(rows) .header(header) .widths( Constraint.length(25), @@ -582,26 +516,119 @@ private void renderOptionsTable(Frame frame, Rect area, Style borderStyle) { Constraint.length(4), Constraint.length(12), Constraint.fill()) + .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) .block(Block.builder() .borderType(BorderType.ROUNDED) .borderStyle(borderStyle) - .title(" Options" + scrollInfo) + .title(optTitle) .build()) .build(); frame.renderStatefulWidget(table, area, optionsTableState); } + private void renderSeparator(Frame frame, Rect area) { + String line = "\u2500".repeat(Math.max(0, area.width())); + frame.renderWidget( + Paragraph.from(Line.from(Span.styled(line, Style.create().fg(Color.DARK_GRAY)))), + area); + } + + private void renderDescription(Frame frame, Rect area) { + List lines = new ArrayList<>(); + String title; + int wrapWidth = Math.max(20, area.width() - 4); + + if (focus == FOCUS_OPTIONS) { + // Show selected option detail + Integer sel = optionsTableState.selected(); + if (sel != null && sel >= 0 && sel < filteredOptions.size()) { + OptionInfo opt = filteredOptions.get(sel); + title = " " + opt.name + " "; + + List fields = new ArrayList<>(); + fields.add(new String[] { "Name", opt.name }); + if (opt.kind != null && !opt.kind.isEmpty()) { + fields.add(new String[] { "Kind", opt.kind }); + } + fields.add(new String[] { "Type", opt.type }); + fields.add(new String[] { "Required", opt.required ? "Yes" : "No" }); + if (!opt.defaultValue.isEmpty()) { + fields.add(new String[] { "Default", opt.defaultValue }); + } + if (opt.group != null && !opt.group.isEmpty()) { + fields.add(new String[] { "Group", opt.group }); + } + if (opt.enumValues != null && !opt.enumValues.isEmpty()) { + fields.add(new String[] { "Values", opt.enumValues }); + } + flowFields(lines, fields, wrapWidth, opt); + + lines.add(Line.from(Span.raw(""))); + wrapText(lines, opt.description, wrapWidth, Style.create()); + } else { + title = " Description "; + } + } else { + // Show selected component detail + Integer sel = listTableState.selected(); + if (sel != null && sel >= 0 && sel < filteredComponents.size()) { + ComponentInfo comp = filteredComponents.get(sel); + title = " " + comp.title + " "; + + List fields = new ArrayList<>(); + fields.add(new String[] { "Scheme", comp.scheme }); + fields.add(new String[] { "Syntax", comp.syntax }); + fields.add(new String[] { "Label", comp.label }); + fields.add(new String[] { "Maven", comp.groupId + ":" + comp.artifactId + ":" + comp.version }); + fields.add(new String[] { + "Options", + comp.endpointOptions.size() + " endpoint, " + comp.componentOptions.size() + " component" }); + if (comp.deprecated) { + fields.add(new String[] { "Status", "DEPRECATED" }); + } + flowFields(lines, fields, wrapWidth, null); + + lines.add(Line.from(Span.raw(""))); + wrapText(lines, comp.description, wrapWidth, Style.create()); + } else { + title = " Description "; + } + } + + // Apply scroll + int innerHeight = Math.max(1, area.height() - 2); + int maxScroll = Math.max(0, lines.size() - innerHeight); + if (descriptionScroll > maxScroll) { + descriptionScroll = maxScroll; + } + List visible; + if (descriptionScroll > 0) { + int end = Math.min(descriptionScroll + innerHeight, lines.size()); + visible = lines.subList(descriptionScroll, end); + } else if (lines.size() > innerHeight) { + visible = lines.subList(0, innerHeight); + } else { + visible = lines; + } + + frame.renderWidget( + Paragraph.builder() + .text(Text.from(visible)) + .overflow(Overflow.CLIP) + .block(Block.builder() + .borderType(BorderType.ROUNDED) + .title(title) + .build()) + .build(), + area); + } + private Row optionToRow(OptionInfo opt) { Style nameStyle = opt.required ? Style.create().fg(Color.CYAN).bold() : Style.create().fg(Color.CYAN); - String desc = opt.description; - if (!opt.enumValues.isEmpty()) { - desc = desc + " [" + opt.enumValues + "]"; - } - return Row.from( Cell.from(Span.styled(opt.name, nameStyle)), Cell.from(Span.styled(opt.type, Style.create().dim())), @@ -609,42 +636,97 @@ private Row optionToRow(OptionInfo opt) { ? Span.styled("*", Style.create().fg(Color.RED).bold()) : Span.raw("")), Cell.from(Span.styled(opt.defaultValue, Style.create().dim())), - Cell.from(truncate(desc, 80))); + Cell.from(Span.styled(opt.kind != null ? opt.kind : "", Style.create().dim()))); } private void renderFooter(Frame frame, Rect area) { - Line footer; - if (searching) { - footer = Line.from( - Span.styled(" Type", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" to filter "), - Span.styled("Enter", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" confirm "), - Span.styled("Esc", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" clear/cancel "), - Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" navigate")); - } else { - footer = Line.from( - Span.styled(" q", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" quit "), - Span.styled("/", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" search "), - Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" navigate "), - Span.styled("Tab", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" switch panel "), - Span.styled("Enter", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" select "), - Span.styled("PgUp/PgDn", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" scroll details")); - } + Line footer = Line.from( + Span.styled(" Type", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" name filter "), + Span.styled("/", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" full-text "), + Span.styled("Esc", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" clear/back/quit "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" navigate "), + Span.styled("\u2190\u2192", Style.create().fg(Color.YELLOW).bold()), + Span.raw("/"), + Span.styled("Tab", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" panels "), + Span.styled("PgUp/Dn", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" scroll")); frame.renderWidget(Paragraph.from(footer), area); } // ---- Helpers ---- + private static void flowFields(List lines, List fields, int maxWidth, OptionInfo opt) { + List currentSpans = new ArrayList<>(); + int currentLen = 1; // leading space + String gap = " "; + + for (String[] field : fields) { + String label = field[0] + ": "; + String value = field[1]; + int fieldLen = label.length() + value.length(); + + // If adding this field would exceed width, flush current line + if (!currentSpans.isEmpty() && currentLen + gap.length() + fieldLen > maxWidth) { + lines.add(Line.from(currentSpans)); + currentSpans = new ArrayList<>(); + currentLen = 1; + } + + if (!currentSpans.isEmpty()) { + currentSpans.add(Span.raw(gap)); + currentLen += gap.length(); + } else { + currentSpans.add(Span.raw(" ")); + } + + currentSpans.add(Span.styled(label, Style.create().fg(Color.YELLOW).bold())); + + // Apply special styling for certain values + Style valueStyle; + if ("DEPRECATED".equals(value)) { + valueStyle = Style.create().fg(Color.RED).bold(); + } else if (opt != null && "Required".equals(field[0]) && opt.required) { + valueStyle = Style.create().fg(Color.RED).bold(); + } else if (opt != null && "Name".equals(field[0])) { + valueStyle = Style.create().fg(Color.CYAN).bold(); + } else if ("Label".equals(field[0]) || "Values".equals(field[0])) { + valueStyle = Style.create().fg(Color.CYAN); + } else { + valueStyle = Style.create(); + } + currentSpans.add(Span.styled(value, valueStyle)); + currentLen += fieldLen; + } + + if (!currentSpans.isEmpty()) { + lines.add(Line.from(currentSpans)); + } + } + + private static void wrapText(List lines, String text, int width, Style style) { + if (text == null || text.isEmpty()) { + return; + } + int pos = 0; + while (pos < text.length()) { + int end = Math.min(pos + width, text.length()); + if (end < text.length() && end > pos) { + int lastSpace = text.lastIndexOf(' ', end); + if (lastSpace > pos) { + end = lastSpace + 1; + } + } + lines.add(Line.from(Span.styled(" " + text.substring(pos, end).trim(), style))); + pos = end; + } + } + private static String truncate(String s, int max) { return TuiHelper.truncate(s, max); } @@ -676,12 +758,4 @@ static class OptionInfo { String group; String enumValues; } - - static class ComponentDetail { - final ComponentInfo info; - - ComponentDetail(ComponentInfo info) { - this.info = info; - } - } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java deleted file mode 100644 index 9bea6b319d7d0..0000000000000 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelHealthTui.java +++ /dev/null @@ -1,441 +0,0 @@ -/* - * 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.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; - -import dev.tamboui.layout.Constraint; -import dev.tamboui.layout.Layout; -import dev.tamboui.layout.Rect; -import dev.tamboui.style.Color; -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.tui.TuiRunner; -import dev.tamboui.tui.event.Event; -import dev.tamboui.tui.event.KeyCode; -import dev.tamboui.tui.event.KeyEvent; -import dev.tamboui.tui.event.TickEvent; -import dev.tamboui.widgets.block.Block; -import dev.tamboui.widgets.block.BorderType; -import dev.tamboui.widgets.gauge.Gauge; -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.dsl.jbang.core.commands.CamelCommand; -import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; -import org.apache.camel.dsl.jbang.core.common.ProcessHelper; -import org.apache.camel.util.TimeUtils; -import org.apache.camel.util.json.JsonArray; -import org.apache.camel.util.json.JsonObject; -import picocli.CommandLine; -import picocli.CommandLine.Command; - -@Command(name = "health-tui", - description = "TUI health check dashboard", - sortOptions = false) -public class CamelHealthTui extends CamelCommand { - - @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1") - String name = "*"; - - @CommandLine.Option(names = { "--refresh" }, - description = "Refresh interval in milliseconds (default: ${DEFAULT-VALUE})", - defaultValue = "1000") - long refreshInterval = 1000; - - // State - private final AtomicReference> data = new AtomicReference<>(Collections.emptyList()); - private final TableState tableState = new TableState(); - private boolean showOnlyDown; - private volatile long lastRefresh; - - // Memory info for the selected row's integration - private long selectedHeapUsed; - private long selectedHeapMax; - - public CamelHealthTui(CamelJBangMain main) { - super(main); - } - - @Override - public Integer doCall() throws Exception { - TuiHelper.preloadClasses(); - - // Initial data load - refreshData(); - - try (var tui = TuiRunner.create()) { - sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit()); - tui.run( - this::handleEvent, - this::render); - } - return 0; - } - - // ---- Event Handling ---- - - private boolean handleEvent(Event event, TuiRunner runner) { - if (event instanceof KeyEvent ke) { - if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) { - runner.quit(); - return true; - } - if (ke.isChar('r')) { - refreshData(); - return true; - } - if (ke.isChar('d')) { - showOnlyDown = !showOnlyDown; - return true; - } - if (ke.isUp()) { - tableState.selectPrevious(); - return true; - } - if (ke.isDown()) { - List rows = getFilteredRows(); - tableState.selectNext(rows.size()); - return true; - } - } - if (event instanceof TickEvent) { - long now = System.currentTimeMillis(); - if (now - lastRefresh >= refreshInterval) { - refreshData(); - } - return true; - } - return false; - } - - // ---- Rendering ---- - - private void render(Frame frame) { - Rect area = frame.area(); - - // Layout: header (3 rows) + health table (fill) + memory gauge (3 rows) + footer (1 row) - List chunks = Layout.vertical() - .constraints( - Constraint.length(3), - Constraint.fill(), - Constraint.length(3), - Constraint.length(1)) - .split(area); - - renderHeader(frame, chunks.get(0)); - renderHealthTable(frame, chunks.get(1)); - renderMemoryGauge(frame, chunks.get(2)); - renderFooter(frame, chunks.get(3)); - } - - private void renderHeader(Frame frame, Rect area) { - List allRows = data.get(); - long upCount = allRows.stream().filter(r -> "UP".equals(r.state)).count(); - long downCount = allRows.stream().filter(r -> "DOWN".equals(r.state)).count(); - - Line titleLine = Line.from( - Span.styled(" Health Dashboard", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()), - Span.raw(" "), - Span.styled(upCount + " UP", Style.create().fg(Color.GREEN).bold()), - Span.raw(", "), - downCount > 0 - ? Span.styled(downCount + " DOWN", Style.create().fg(Color.RED).bold()) - : Span.styled("0 DOWN", Style.create().fg(Color.GREEN)), - showOnlyDown ? Span.styled(" [DOWN only]", Style.create().fg(Color.YELLOW)) : Span.raw("")); - - Block headerBlock = Block.builder() - .borderType(BorderType.ROUNDED) - .title(" Camel Health Checks ") - .build(); - - frame.renderWidget( - Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(), - area); - } - - private void renderHealthTable(Frame frame, Rect area) { - List rows = getFilteredRows(); - - // Update selected integration's memory info - Integer sel = tableState.selected(); - if (sel != null && sel >= 0 && sel < rows.size()) { - HealthRow selected = rows.get(sel); - selectedHeapUsed = selected.heapMemUsed; - selectedHeapMax = selected.heapMemMax; - } else if (!rows.isEmpty()) { - selectedHeapUsed = rows.get(0).heapMemUsed; - selectedHeapMax = rows.get(0).heapMemMax; - } else { - selectedHeapUsed = 0; - selectedHeapMax = 0; - } - - List tableRows = new ArrayList<>(); - for (HealthRow hr : rows) { - Style stateStyle; - String icon; - if ("UP".equals(hr.state)) { - stateStyle = Style.create().fg(Color.GREEN); - icon = "\u2714 "; - } else if ("DOWN".equals(hr.state)) { - stateStyle = Style.create().fg(Color.RED); - icon = "\u2716 "; - } else { - stateStyle = Style.create().fg(Color.YELLOW); - icon = "\u26A0 "; - } - - String rate = ""; - if (hr.readiness) { - rate += "R"; - } - if (hr.liveness) { - rate += rate.isEmpty() ? "L" : "/L"; - } - - tableRows.add(Row.from( - Cell.from(hr.pid), - Cell.from(Span.styled(truncate(hr.integrationName, 20), Style.create().fg(Color.CYAN))), - Cell.from(hr.group != null ? truncate(hr.group, 12) : ""), - Cell.from(Span.styled(truncate(hr.checkId, 25), Style.create().fg(Color.CYAN))), - Cell.from(Span.styled(icon + hr.state, stateStyle)), - Cell.from(rate), - Cell.from(hr.since != null ? hr.since : ""), - Cell.from(hr.message != null ? truncate(hr.message, 40) : ""))); - } - - if (tableRows.isEmpty()) { - tableRows.add(Row.from( - Cell.from(""), - Cell.from(Span.styled("No health checks found", Style.create().dim())), - Cell.from(""), - Cell.from(""), - Cell.from(""), - Cell.from(""), - Cell.from(""), - Cell.from(""))); - } - - Row header = Row.from( - Cell.from(Span.styled("PID", Style.create().bold())), - Cell.from(Span.styled("NAME", Style.create().bold())), - Cell.from(Span.styled("GROUP", Style.create().bold())), - Cell.from(Span.styled("CHECK", Style.create().bold())), - Cell.from(Span.styled("STATUS", Style.create().bold())), - Cell.from(Span.styled("RATE", Style.create().bold())), - Cell.from(Span.styled("SINCE", Style.create().bold())), - Cell.from(Span.styled("MESSAGE", Style.create().bold()))); - - Table table = Table.builder() - .rows(tableRows) - .header(header) - .widths( - Constraint.length(8), - Constraint.length(20), - Constraint.length(12), - Constraint.length(25), - Constraint.length(10), - Constraint.length(6), - Constraint.length(8), - Constraint.fill()) - .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) - .block(Block.builder().borderType(BorderType.ROUNDED) - .title(showOnlyDown ? " Health Checks [DOWN only] " : " Health Checks ").build()) - .build(); - - frame.renderStatefulWidget(table, area, tableState); - } - - private void renderMemoryGauge(Frame frame, Rect area) { - if (selectedHeapMax > 0) { - int pct = (int) (100.0 * selectedHeapUsed / selectedHeapMax); - Style gaugeStyle = pct > 80 ? Style.create().fg(Color.RED) - : pct > 60 ? Style.create().fg(Color.YELLOW) : Style.create().fg(Color.GREEN); - Gauge gauge = Gauge.builder() - .percent(pct) - .label(String.format("Heap: %s / %s (%d%%)", - formatBytes(selectedHeapUsed), formatBytes(selectedHeapMax), pct)) - .gaugeStyle(gaugeStyle) - .block(Block.builder().borderType(BorderType.ROUNDED).title(" Memory ").build()) - .build(); - frame.renderWidget(gauge, area); - } else { - frame.renderWidget( - Paragraph.builder() - .text(Text.from(Line.from(Span.styled(" No memory data", Style.create().dim())))) - .block(Block.builder().borderType(BorderType.ROUNDED).title(" Memory ").build()) - .build(), - area); - } - } - - private void renderFooter(Frame frame, Rect area) { - String refreshLabel = refreshInterval >= 1000 - ? (refreshInterval / 1000) + "s" - : refreshInterval + "ms"; - - Line footer = Line.from( - Span.styled(" q", Style.create().fg(Color.YELLOW).bold()), - Span.raw("/"), - Span.styled("Esc", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" quit "), - Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" navigate "), - Span.styled("r", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" refresh "), - Span.styled("d", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" toggle DOWN "), - Span.styled("Refresh: " + refreshLabel, Style.create().dim())); - - frame.renderWidget(Paragraph.from(footer), area); - } - - // ---- Data Loading ---- - - private void refreshData() { - lastRefresh = System.currentTimeMillis(); - try { - List rows = new ArrayList<>(); - List pids = findPids(name); - ProcessHandle.allProcesses() - .filter(ph -> pids.contains(ph.pid())) - .forEach(ph -> { - JsonObject root = loadStatus(ph.pid()); - if (root != null) { - parseHealthRows(ph, root, rows); - } - }); - data.set(rows); - } catch (Exception e) { - // ignore refresh errors - } - } - - @SuppressWarnings("unchecked") - private void parseHealthRows(ProcessHandle ph, JsonObject root, List rows) { - JsonObject context = (JsonObject) root.get("context"); - if (context == null) { - return; - } - - String pid = Long.toString(ph.pid()); - String integrationName = context.getString("name"); - if ("CamelJBang".equals(integrationName)) { - integrationName = ProcessHelper.extractName(root, ph); - } - - String since = TimeUtils.printSince( - ph.info().startInstant().map(Instant::toEpochMilli).orElse(0L)); - - // Parse memory - long heapUsed = 0; - long heapMax = 0; - JsonObject mem = (JsonObject) root.get("memory"); - if (mem != null) { - heapUsed = mem.getLong("heapMemoryUsed"); - heapMax = mem.getLong("heapMemoryMax"); - } - - // Parse health checks - JsonObject healthChecks = (JsonObject) root.get("healthChecks"); - if (healthChecks != null) { - JsonArray checks = (JsonArray) healthChecks.get("checks"); - if (checks != null) { - for (Object c : checks) { - JsonObject cj = (JsonObject) c; - HealthRow hr = new HealthRow(); - hr.pid = pid; - hr.integrationName = integrationName; - hr.checkId = cj.getString("id"); - hr.group = cj.getString("group"); - hr.state = cj.getString("state"); - hr.readiness = cj.getBooleanOrDefault("readiness", false); - hr.liveness = cj.getBooleanOrDefault("liveness", false); - hr.since = since; - hr.heapMemUsed = heapUsed; - hr.heapMemMax = heapMax; - - // Extract failure message from details - JsonObject details = (JsonObject) cj.get("details"); - if (details != null && details.containsKey("failure.error.message")) { - hr.message = details.getString("failure.error.message"); - } - - rows.add(hr); - } - } - } - } - - // ---- Helpers ---- - - private List getFilteredRows() { - List allRows = data.get(); - if (showOnlyDown) { - return allRows.stream().filter(r -> "DOWN".equals(r.state)).toList(); - } - return allRows; - } - - private List findPids(String name) { - return TuiHelper.findPids(name, this::getStatusFile); - } - - private JsonObject loadStatus(long pid) { - return TuiHelper.loadStatus(pid, this::getStatusFile); - } - - private static String truncate(String s, int max) { - return TuiHelper.truncate(s, max); - } - - private static String formatBytes(long bytes) { - if (bytes < 1024) { - return bytes + "B"; - } - if (bytes < 1024 * 1024) { - return (bytes / 1024) + "K"; - } - return (bytes / (1024 * 1024)) + "M"; - } - - // ---- Data Class ---- - - static class HealthRow { - String pid; - String integrationName; - String group; - String checkId; - String state; - boolean readiness; - boolean liveness; - String since; - String message; - long heapMemUsed; - long heapMemMax; - } -} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java deleted file mode 100644 index 111a22765538e..0000000000000 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelLogTui.java +++ /dev/null @@ -1,447 +0,0 @@ -/* - * 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.io.IOException; -import java.io.RandomAccessFile; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; - -import dev.tamboui.layout.Constraint; -import dev.tamboui.layout.Layout; -import dev.tamboui.layout.Rect; -import dev.tamboui.style.Color; -import dev.tamboui.style.Overflow; -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.tui.TuiRunner; -import dev.tamboui.tui.event.Event; -import dev.tamboui.tui.event.KeyCode; -import dev.tamboui.tui.event.KeyEvent; -import dev.tamboui.tui.event.TickEvent; -import dev.tamboui.widgets.block.Block; -import dev.tamboui.widgets.block.BorderType; -import dev.tamboui.widgets.paragraph.Paragraph; -import org.apache.camel.dsl.jbang.core.commands.CamelCommand; -import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; -import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; -import org.apache.camel.dsl.jbang.core.common.ProcessHelper; -import org.apache.camel.util.json.JsonObject; -import picocli.CommandLine; -import picocli.CommandLine.Command; - -@Command(name = "log-tui", - description = "TUI log viewer for Camel integrations", - sortOptions = false) -public class CamelLogTui extends CamelCommand { - - private static final int MAX_READ_BYTES = 64 * 1024; - - @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1") - String name = "*"; - - @CommandLine.Option(names = { "--refresh" }, - description = "Refresh interval in milliseconds (default: ${DEFAULT-VALUE})", - defaultValue = "500") - long refreshInterval = 500; - - @CommandLine.Option(names = { "--grep" }, - description = "Filter logs to matching lines") - String grep; - - @CommandLine.Option(names = { "--level" }, - description = "Filter by log level (INFO, WARN, ERROR, DEBUG)") - String level; - - // State - private final List logLines = new ArrayList<>(); - private final List filteredLines = new ArrayList<>(); - private int scrollOffset; - private boolean followMode = true; - - // Level toggle filters (all enabled by default) - private boolean showTrace = true; - private boolean showDebug = true; - private boolean showInfo = true; - private boolean showWarn = true; - private boolean showError = true; - - // Integration info - private String resolvedPid; - private String integrationName; - private Path logFilePath; - - private volatile long lastRefresh; - - public CamelLogTui(CamelJBangMain main) { - super(main); - } - - @Override - public Integer doCall() throws Exception { - TuiHelper.preloadClasses(); - - // Apply --level as initial level filter - if (level != null) { - String lv = level.toUpperCase(); - showTrace = "TRACE".equals(lv); - showDebug = "DEBUG".equals(lv) || "TRACE".equals(lv); - showInfo = "INFO".equals(lv) || "DEBUG".equals(lv) || "TRACE".equals(lv); - showWarn = "WARN".equals(lv) || "INFO".equals(lv) || "DEBUG".equals(lv) || "TRACE".equals(lv); - showError = true; // always show errors - } - - // Initial data load - resolveIntegration(); - refreshLogData(); - - try (var tui = TuiRunner.create()) { - sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit()); - tui.run( - this::handleEvent, - this::render); - } - return 0; - } - - // ---- Event Handling ---- - - private boolean handleEvent(Event event, TuiRunner runner) { - if (event instanceof KeyEvent ke) { - if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) { - runner.quit(); - return true; - } - if (ke.isUp()) { - followMode = false; - scrollOffset = Math.max(0, scrollOffset - 1); - return true; - } - if (ke.isDown()) { - scrollOffset++; - return true; - } - if (ke.isKey(KeyCode.PAGE_UP)) { - followMode = false; - scrollOffset = Math.max(0, scrollOffset - 20); - return true; - } - if (ke.isKey(KeyCode.PAGE_DOWN)) { - scrollOffset += 20; - return true; - } - if (ke.isChar('g')) { - followMode = false; - scrollOffset = 0; - return true; - } - if (ke.isChar('G')) { - followMode = true; - return true; - } - if (ke.isChar('f')) { - followMode = !followMode; - return true; - } - // Level toggles: 1=TRACE, 2=DEBUG, 3=INFO, 4=WARN, 5=ERROR - if (ke.isChar('1')) { - showTrace = !showTrace; - applyFilters(); - return true; - } - if (ke.isChar('2')) { - showDebug = !showDebug; - applyFilters(); - return true; - } - if (ke.isChar('3')) { - showInfo = !showInfo; - applyFilters(); - return true; - } - if (ke.isChar('4')) { - showWarn = !showWarn; - applyFilters(); - return true; - } - if (ke.isChar('5')) { - showError = !showError; - applyFilters(); - return true; - } - } - if (event instanceof TickEvent) { - long now = System.currentTimeMillis(); - if (now - lastRefresh >= refreshInterval) { - resolveIntegration(); - refreshLogData(); - } - return true; - } - return false; - } - - // ---- Rendering ---- - - private void render(Frame frame) { - Rect area = frame.area(); - - // Layout: header (3 rows) + log area (fill) + footer (1 row) - List mainChunks = Layout.vertical() - .constraints( - Constraint.length(3), - Constraint.fill(), - Constraint.length(1)) - .split(area); - - renderHeader(frame, mainChunks.get(0)); - renderLogArea(frame, mainChunks.get(1)); - renderFooter(frame, mainChunks.get(2)); - } - - private void renderHeader(Frame frame, Rect area) { - String titleText = integrationName != null ? integrationName : name; - String logInfo = logFilePath != null ? logFilePath.getFileName().toString() : "no log file"; - String lineCount = filteredLines.size() + " lines"; - - Line titleLine = Line.from( - Span.styled(" Log Viewer", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()), - Span.raw(" "), - Span.styled(titleText, Style.create().fg(Color.CYAN)), - Span.raw(" "), - Span.styled(logInfo, Style.create().dim()), - Span.raw(" "), - Span.styled(lineCount, Style.create().fg(Color.GREEN)), - followMode - ? Span.styled(" [FOLLOW]", Style.create().fg(Color.YELLOW).bold()) - : Span.raw("")); - - Block headerBlock = Block.builder() - .borderType(BorderType.ROUNDED) - .title(" Camel Log ") - .build(); - - frame.renderWidget( - Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(), - area); - } - - private void renderLogArea(Frame frame, Rect area) { - Block logBlock = Block.builder() - .borderType(BorderType.ROUNDED) - .title(buildLevelFilterTitle()) - .build(); - - int innerHeight = Math.max(1, area.height() - 2); - int totalLines = filteredLines.size(); - - int startLine; - if (followMode) { - startLine = Math.max(0, totalLines - innerHeight); - scrollOffset = startLine; - } else { - scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, totalLines - innerHeight))); - startLine = scrollOffset; - } - - List visibleLines = new ArrayList<>(); - for (int i = startLine; i < Math.min(startLine + innerHeight, totalLines); i++) { - visibleLines.add(colorizeLogLine(filteredLines.get(i))); - } - - // Fill remaining space - while (visibleLines.size() < innerHeight) { - visibleLines.add(Line.from(Span.raw(""))); - } - - Paragraph logParagraph = Paragraph.builder() - .text(Text.from(visibleLines)) - .overflow(Overflow.CLIP) - .block(logBlock) - .build(); - - frame.renderWidget(logParagraph, area); - } - - private String buildLevelFilterTitle() { - StringBuilder sb = new StringBuilder(" Levels: "); - sb.append(showTrace ? "[1:TRACE] " : "[1:trace] "); - sb.append(showDebug ? "[2:DEBUG] " : "[2:debug] "); - sb.append(showInfo ? "[3:INFO] " : "[3:info] "); - sb.append(showWarn ? "[4:WARN] " : "[4:warn] "); - sb.append(showError ? "[5:ERROR] " : "[5:error] "); - return sb.toString(); - } - - private void renderFooter(Frame frame, Rect area) { - Line footer = Line.from( - Span.styled(" q", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" quit "), - Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" scroll "), - Span.styled("g", Style.create().fg(Color.YELLOW).bold()), - Span.raw("/"), - Span.styled("G", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" top/bottom "), - Span.styled("f", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" follow "), - Span.styled("PgUp/PgDn", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" page "), - Span.styled("1-5", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" toggle levels"), - grep != null - ? Span.styled(" grep:" + grep, Style.create().fg(Color.MAGENTA)) - : Span.raw("")); - - frame.renderWidget(Paragraph.from(footer), area); - } - - private Line colorizeLogLine(String line) { - if (line.contains(" ERROR ") || line.contains(" FATAL ")) { - return Line.from(Span.styled(line, Style.create().fg(Color.RED))); - } else if (line.contains(" WARN ")) { - return Line.from(Span.styled(line, Style.create().fg(Color.YELLOW))); - } else if (line.contains(" DEBUG ")) { - return Line.from(Span.styled(line, Style.create().dim())); - } else if (line.contains(" TRACE ")) { - return Line.from(Span.styled(line, Style.create().dim())); - } - return Line.from(Span.raw(line)); - } - - // ---- Data Loading ---- - - private void resolveIntegration() { - if (resolvedPid != null) { - // Check if still alive - try { - ProcessHandle.of(Long.parseLong(resolvedPid)).ifPresentOrElse( - ph -> { - if (!ph.isAlive()) { - resolvedPid = null; - integrationName = null; - logFilePath = null; - } - }, - () -> { - resolvedPid = null; - integrationName = null; - logFilePath = null; - }); - } catch (NumberFormatException e) { - // ignore - } - } - - if (resolvedPid == null) { - List pids = findPids(name); - if (!pids.isEmpty()) { - long pid = pids.get(0); - resolvedPid = Long.toString(pid); - logFilePath = CommandLineHelper.getCamelDir().resolve(resolvedPid + ".log"); - - // Load integration name - JsonObject root = loadStatus(pid); - if (root != null) { - JsonObject context = (JsonObject) root.get("context"); - if (context != null) { - integrationName = context.getString("name"); - if ("CamelJBang".equals(integrationName)) { - ProcessHandle.of(pid).ifPresent( - ph -> integrationName = ProcessHelper.extractName(root, ph)); - } - } - } - } - } - } - - private void refreshLogData() { - lastRefresh = System.currentTimeMillis(); - readLogFile(); - applyFilters(); - } - - private void readLogFile() { - logLines.clear(); - if (logFilePath == null || !Files.exists(logFilePath)) { - return; - } - try (RandomAccessFile raf = new RandomAccessFile(logFilePath.toFile(), "r")) { - long length = raf.length(); - long startPos = Math.max(0, length - MAX_READ_BYTES); - raf.seek(startPos); - if (startPos > 0) { - raf.readLine(); // skip partial line - } - byte[] remaining = new byte[(int) (length - raf.getFilePointer())]; - raf.readFully(remaining); - String content = new String(remaining, StandardCharsets.UTF_8); - String[] lines = content.split("\n", -1); - for (String line : lines) { - if (!line.isEmpty()) { - logLines.add(line); - } - } - } catch (IOException e) { - // ignore - } - } - - private void applyFilters() { - filteredLines.clear(); - for (String line : logLines) { - if (!matchesLevelFilter(line)) { - continue; - } - if (grep != null && !grep.isEmpty() && !line.contains(grep)) { - continue; - } - filteredLines.add(line); - } - } - - private boolean matchesLevelFilter(String line) { - if (line.contains(" ERROR ") || line.contains(" FATAL ")) { - return showError; - } else if (line.contains(" WARN ")) { - return showWarn; - } else if (line.contains(" DEBUG ")) { - return showDebug; - } else if (line.contains(" TRACE ")) { - return showTrace; - } - // Lines without a recognized level are treated as INFO - return showInfo; - } - - // ---- Helpers ---- - - private List findPids(String name) { - return TuiHelper.findPids(name, this::getStatusFile); - } - - private JsonObject loadStatus(long pid) { - return TuiHelper.loadStatus(pid, this::getStatusFile); - } -} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index cf186b146b3a1..e903c9e8afdc2 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -18,12 +18,14 @@ import java.io.IOException; import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -68,6 +70,7 @@ import org.apache.camel.util.TimeUtils; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; import picocli.CommandLine; import picocli.CommandLine.Command; @@ -82,6 +85,8 @@ public class CamelMonitor extends CamelCommand { private static final long DEFAULT_REFRESH_MS = 100; private static final int MAX_SPARKLINE_POINTS = 60; private static final int MAX_LOG_LINES = 200; + private static final int MAX_TRACES = 200; + private static final int NUM_TABS = 6; // Tab indices private static final int TAB_OVERVIEW = 0; @@ -89,6 +94,10 @@ public class CamelMonitor extends CamelCommand { private static final int TAB_HEALTH = 2; private static final int TAB_ENDPOINTS = 3; private static final int TAB_LOG = 4; + private static final int TAB_TRACE = 5; + + // Route sort columns + private static final String[] ROUTE_SORT_COLUMNS = { "mean", "max", "total", "failed", "name" }; @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1") String name = "*"; @@ -114,9 +123,31 @@ public class CamelMonitor extends CamelCommand { // Track last time a sparkline point was recorded private final Map previousExchangesTime = new ConcurrentHashMap<>(); + // Route sort state + private String routeSort = "mean"; + private int routeSortIndex; + + // Health filter state + private boolean showOnlyDown; + // Log state private final List logLines = new ArrayList<>(); + private final List filteredLogLines = new ArrayList<>(); private int logScroll; + private boolean logFollowMode = true; + private boolean showLogTrace = true; + private boolean showLogDebug = true; + private boolean showLogInfo = true; + private boolean showLogWarn = true; + private boolean showLogError = true; + + // Trace state + private final AtomicReference> traces = new AtomicReference<>(Collections.emptyList()); + private final TableState traceTableState = new TableState(); + private final Map traceFilePositions = new LinkedHashMap<>(); + private boolean showTraceHeaders = true; + private boolean showTraceBody = true; + private boolean traceFollowMode = true; // Selected integration for detail views private String selectedPid; @@ -167,46 +198,38 @@ private boolean handleEvent(Event event, TuiRunner runner) { // Tab switching with number keys if (ke.isChar('1')) { - tabsState.select(TAB_OVERVIEW); - selectedPid = null; - return true; + return handleTabKey(TAB_OVERVIEW); } if (ke.isChar('2')) { - selectCurrentIntegration(); - tabsState.select(TAB_ROUTES); - return true; + return handleTabKey(TAB_ROUTES); } if (ke.isChar('3')) { - selectCurrentIntegration(); - tabsState.select(TAB_HEALTH); - return true; + return handleTabKey(TAB_HEALTH); } if (ke.isChar('4')) { - selectCurrentIntegration(); - tabsState.select(TAB_ENDPOINTS); - return true; + return handleTabKey(TAB_ENDPOINTS); } if (ke.isChar('5')) { - selectCurrentIntegration(); - tabsState.select(TAB_LOG); - logScroll = 0; - return true; + return handleTabKey(TAB_LOG); + } + if (ke.isChar('6')) { + return handleTabKey(TAB_TRACE); } // Tab cycling if (ke.isKey(KeyCode.TAB)) { - int next = (tabsState.selected() + 1) % 5; + int next = (tabsState.selected() + 1) % NUM_TABS; if (next != TAB_OVERVIEW) { selectCurrentIntegration(); } tabsState.select(next); - if (next == TAB_LOG) { - logScroll = 0; - } return true; } - // Navigation + // Tab-specific keys + int tab = tabsState.selected(); + + // Navigation (all tabs) if (ke.isUp()) { navigateUp(); return true; @@ -216,26 +239,104 @@ private boolean handleEvent(Event event, TuiRunner runner) { return true; } if (ke.isKey(KeyCode.PAGE_UP)) { - if (tabsState.selected() == TAB_LOG) { + if (tab == TAB_LOG) { + logFollowMode = false; logScroll = Math.max(0, logScroll - 20); } return true; } if (ke.isKey(KeyCode.PAGE_DOWN)) { - if (tabsState.selected() == TAB_LOG) { + if (tab == TAB_LOG) { logScroll += 20; } return true; } // Enter to drill into selected integration - if (ke.isKey(KeyCode.ENTER) && tabsState.selected() == TAB_OVERVIEW) { + if (ke.isKey(KeyCode.ENTER) && tab == TAB_OVERVIEW) { selectCurrentIntegration(); if (selectedPid != null) { tabsState.select(TAB_ROUTES); } return true; } + + // Routes tab: sort + if (tab == TAB_ROUTES && ke.isCharIgnoreCase('s')) { + routeSortIndex = (routeSortIndex + 1) % ROUTE_SORT_COLUMNS.length; + routeSort = ROUTE_SORT_COLUMNS[routeSortIndex]; + return true; + } + + // Health tab: DOWN filter + if (tab == TAB_HEALTH && ke.isCharIgnoreCase('d')) { + showOnlyDown = !showOnlyDown; + return true; + } + + // Log tab: level filters and follow mode + if (tab == TAB_LOG) { + if (ke.isCharIgnoreCase('t')) { + showLogTrace = !showLogTrace; + applyLogFilters(); + return true; + } + if (ke.isCharIgnoreCase('d')) { + showLogDebug = !showLogDebug; + applyLogFilters(); + return true; + } + if (ke.isCharIgnoreCase('i')) { + showLogInfo = !showLogInfo; + applyLogFilters(); + return true; + } + if (ke.isCharIgnoreCase('w')) { + showLogWarn = !showLogWarn; + applyLogFilters(); + return true; + } + if (ke.isCharIgnoreCase('e')) { + showLogError = !showLogError; + applyLogFilters(); + return true; + } + if (ke.isCharIgnoreCase('f')) { + logFollowMode = !logFollowMode; + return true; + } + if (ke.isChar('g')) { + logFollowMode = false; + logScroll = 0; + return true; + } + if (ke.isChar('G')) { + logFollowMode = true; + return true; + } + } + + // Trace tab: headers/body toggle and follow mode + if (tab == TAB_TRACE) { + if (ke.isCharIgnoreCase('h')) { + showTraceHeaders = !showTraceHeaders; + return true; + } + if (ke.isCharIgnoreCase('b')) { + showTraceBody = !showTraceBody; + return true; + } + if (ke.isCharIgnoreCase('f')) { + traceFollowMode = !traceFollowMode; + if (traceFollowMode) { + List current = traces.get(); + if (!current.isEmpty()) { + traceTableState.select(current.size() - 1); + } + } + return true; + } + } } if (event instanceof TickEvent) { long now = System.currentTimeMillis(); @@ -247,6 +348,16 @@ private boolean handleEvent(Event event, TuiRunner runner) { return false; } + private boolean handleTabKey(int tab) { + if (tab != TAB_OVERVIEW) { + selectCurrentIntegration(); + } else { + selectedPid = null; + } + tabsState.select(tab); + return true; + } + private void selectCurrentIntegration() { if (selectedPid != null) { return; @@ -266,7 +377,14 @@ private void navigateUp() { case TAB_ROUTES -> routeTableState.selectPrevious(); case TAB_HEALTH -> healthTableState.selectPrevious(); case TAB_ENDPOINTS -> endpointTableState.selectPrevious(); - case TAB_LOG -> logScroll = Math.max(0, logScroll - 1); + case TAB_LOG -> { + logFollowMode = false; + logScroll = Math.max(0, logScroll - 1); + } + case TAB_TRACE -> { + traceFollowMode = false; + traceTableState.selectPrevious(); + } } } @@ -280,13 +398,17 @@ private void navigateDown() { } case TAB_HEALTH -> { IntegrationInfo info = findSelectedIntegration(); - healthTableState.selectNext(info != null ? info.healthChecks.size() : 0); + healthTableState.selectNext(info != null ? getFilteredHealthChecks(info).size() : 0); } case TAB_ENDPOINTS -> { IntegrationInfo info = findSelectedIntegration(); endpointTableState.selectNext(info != null ? info.endpoints.size() : 0); } case TAB_LOG -> logScroll++; + case TAB_TRACE -> { + List current = traces.get(); + traceTableState.selectNext(current.size()); + } } } @@ -340,7 +462,8 @@ private void renderTabs(Frame frame, Rect area) { " 2 Routes" + sel + " ", " 3 Health" + sel + " ", " 4 Endpoints" + sel + " ", - " 5 Log" + sel + " ") + " 5 Log" + sel + " ", + " 6 Trace" + sel + " ") .highlightStyle(Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()) .divider(Span.styled(" | ", Style.create().dim())) .build(); @@ -357,6 +480,7 @@ private void renderContent(Frame frame, Rect area) { case TAB_HEALTH -> renderHealth(frame, area); case TAB_ENDPOINTS -> renderEndpoints(frame, area); case TAB_LOG -> renderLog(frame, area); + case TAB_TRACE -> renderTrace(frame, area); } } @@ -487,6 +611,10 @@ private void renderRoutes(Frame frame, Rect area) { return; } + // Sort routes + List sortedRoutes = new ArrayList<>(info.routes); + sortedRoutes.sort(this::sortRoute); + // Split: routes table (top half) + processors table (bottom half) List chunks = Layout.vertical() .constraints(Constraint.percentage(45), Constraint.percentage(55)) @@ -494,11 +622,15 @@ private void renderRoutes(Frame frame, Rect area) { // Routes table List routeRows = new ArrayList<>(); - for (RouteInfo route : info.routes) { + for (RouteInfo route : sortedRoutes) { Style stateStyle = "Started".equals(route.state) ? Style.create().fg(Color.GREEN) : Style.create().fg(Color.RED); + Style failStyle = route.failed > 0 + ? Style.create().fg(Color.RED).bold() + : Style.create(); + routeRows.add(Row.from( Cell.from(Span.styled(truncate(route.routeId, 12), Style.create().fg(Color.CYAN))), Cell.from(truncate(route.from, 30)), @@ -506,9 +638,7 @@ private void renderRoutes(Frame frame, Rect area) { Cell.from(route.uptime != null ? route.uptime : ""), Cell.from(route.throughput != null ? route.throughput : ""), Cell.from(String.valueOf(route.total)), - Cell.from(route.failed > 0 - ? Span.styled(String.valueOf(route.failed), Style.create().fg(Color.RED)) - : Span.raw("0")), + Cell.from(Span.styled(String.valueOf(route.failed), failStyle)), Cell.from(route.meanTime + "/" + route.maxTime))); } @@ -520,9 +650,9 @@ private void renderRoutes(Frame frame, Rect area) { Cell.from(Span.styled("STATE", Style.create().bold())), Cell.from(Span.styled("UPTIME", Style.create().bold())), Cell.from(Span.styled("THRUPUT", Style.create().bold())), - Cell.from(Span.styled("TOTAL", Style.create().bold())), - Cell.from(Span.styled("FAILED", Style.create().bold())), - Cell.from(Span.styled("MEAN/MAX", Style.create().bold())))) + Cell.from(Span.styled(routeSortLabel("TOTAL", "total"), routeSortStyle("total"))), + Cell.from(Span.styled(routeSortLabel("FAILED", "failed"), routeSortStyle("failed"))), + Cell.from(Span.styled(routeSortLabel("MEAN/MAX", "mean"), routeSortStyle("mean"))))) .widths( Constraint.length(12), Constraint.fill(), @@ -534,18 +664,18 @@ private void renderRoutes(Frame frame, Rect area) { Constraint.length(12)) .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) .block(Block.builder().borderType(BorderType.ROUNDED) - .title(" Routes [" + info.name + "] ").build()) + .title(" Routes [" + info.name + "] sort:" + routeSort + " ").build()) .build(); frame.renderStatefulWidget(routeTable, chunks.get(0), routeTableState); // Processors for selected route Integer selectedRoute = routeTableState.selected(); - if (selectedRoute != null && selectedRoute >= 0 && selectedRoute < info.routes.size()) { - RouteInfo route = info.routes.get(selectedRoute); + if (selectedRoute != null && selectedRoute >= 0 && selectedRoute < sortedRoutes.size()) { + RouteInfo route = sortedRoutes.get(selectedRoute); renderProcessors(frame, chunks.get(1), route); - } else if (!info.routes.isEmpty()) { - renderProcessors(frame, chunks.get(1), info.routes.get(0)); + } else if (!sortedRoutes.isEmpty()) { + renderProcessors(frame, chunks.get(1), sortedRoutes.get(0)); } else { frame.renderWidget( Paragraph.builder() @@ -556,6 +686,31 @@ private void renderRoutes(Frame frame, Rect area) { } } + private int sortRoute(RouteInfo a, RouteInfo b) { + return switch (routeSort) { + case "mean" -> Long.compare(b.meanTime, a.meanTime); + case "max" -> Long.compare(b.maxTime, a.maxTime); + case "total" -> Long.compare(b.total, a.total); + case "failed" -> Long.compare(b.failed, a.failed); + case "name" -> { + String ra = a.routeId != null ? a.routeId : ""; + String rb = b.routeId != null ? b.routeId : ""; + yield ra.compareToIgnoreCase(rb); + } + default -> 0; + }; + } + + private String routeSortLabel(String label, String column) { + return routeSort.equals(column) ? label + "\u25BC" : label; + } + + private Style routeSortStyle(String column) { + return routeSort.equals(column) + ? Style.create().fg(Color.YELLOW).bold() + : Style.create().bold(); + } + private void renderProcessors(Frame frame, Rect area, RouteInfo route) { List rows = new ArrayList<>(); for (ProcessorInfo proc : route.processors) { @@ -613,8 +768,10 @@ private void renderHealth(Frame frame, Rect area) { .constraints(Constraint.fill(), Constraint.length(3)) .split(area); + List healthChecks = getFilteredHealthChecks(info); + List rows = new ArrayList<>(); - for (HealthCheckInfo hc : info.healthChecks) { + for (HealthCheckInfo hc : healthChecks) { Style stateStyle; String icon; if ("UP".equals(hc.state)) { @@ -628,36 +785,52 @@ private void renderHealth(Frame frame, Rect area) { icon = "\u26A0 "; } + String rate = ""; + if (hc.readiness) { + rate += "R"; + } + if (hc.liveness) { + rate += rate.isEmpty() ? "L" : "/L"; + } + rows.add(Row.from( Cell.from(Span.styled(truncate(hc.group != null ? hc.group : "", 12), Style.create().dim())), - Cell.from(Span.styled(truncate(hc.name, 30), Style.create().fg(Color.CYAN))), + Cell.from(Span.styled(truncate(hc.name, 25), Style.create().fg(Color.CYAN))), Cell.from(Span.styled(icon + hc.state, stateStyle)), + Cell.from(rate), Cell.from(hc.message != null ? truncate(hc.message, 50) : ""))); } if (rows.isEmpty()) { rows.add(Row.from( Cell.from(""), - Cell.from(Span.styled("No health checks registered", Style.create().dim())), + Cell.from(Span.styled(showOnlyDown ? "No DOWN checks" : "No health checks registered", + Style.create().dim())), + Cell.from(""), Cell.from(""), Cell.from(""))); } + String title = showOnlyDown + ? " Health [" + info.name + "] [DOWN only] " + : " Health [" + info.name + "] "; + Table table = Table.builder() .rows(rows) .header(Row.from( Cell.from(Span.styled("GROUP", Style.create().bold())), Cell.from(Span.styled("NAME", Style.create().bold())), Cell.from(Span.styled("STATUS", Style.create().bold())), + Cell.from(Span.styled("RATE", Style.create().bold())), Cell.from(Span.styled("MESSAGE", Style.create().bold())))) .widths( Constraint.length(12), - Constraint.length(30), + Constraint.length(25), Constraint.length(12), + Constraint.length(6), Constraint.fill()) .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) - .block(Block.builder().borderType(BorderType.ROUNDED) - .title(" Health [" + info.name + "] ").build()) + .block(Block.builder().borderType(BorderType.ROUNDED).title(title).build()) .build(); frame.renderStatefulWidget(table, chunks.get(0), healthTableState); @@ -679,6 +852,13 @@ private void renderHealth(Frame frame, Rect area) { } } + private List getFilteredHealthChecks(IntegrationInfo info) { + if (showOnlyDown) { + return info.healthChecks.stream().filter(hc -> "DOWN".equals(hc.state)).toList(); + } + return info.healthChecks; + } + // ---- Tab 4: Endpoints ---- private void renderEndpoints(Frame frame, Rect area) { @@ -745,27 +925,30 @@ private void renderLog(Frame frame, Rect area) { return; } - // Read log lines + // Read and filter log lines readLogFile(info.pid); + applyLogFilters(); + String levelTitle = buildLevelFilterTitle(); Block logBlock = Block.builder().borderType(BorderType.ROUNDED) - .title(" Log [" + info.name + "] ") + .title(" Log [" + info.name + "] " + levelTitle) .build(); int innerHeight = Math.max(1, area.height() - 2); // account for border - int totalLines = logLines.size(); + int totalLines = filteredLogLines.size(); - // Auto-scroll to bottom if logScroll is 0 (default) int startLine; - if (logScroll == 0) { + if (logFollowMode) { startLine = Math.max(0, totalLines - innerHeight); + logScroll = startLine; } else { - startLine = Math.max(0, Math.min(logScroll, totalLines - innerHeight)); + logScroll = Math.max(0, Math.min(logScroll, Math.max(0, totalLines - innerHeight))); + startLine = logScroll; } List visibleLines = new ArrayList<>(); for (int i = startLine; i < Math.min(startLine + innerHeight, totalLines); i++) { - visibleLines.add(colorizeLogLine(logLines.get(i))); + visibleLines.add(colorizeLogLine(filteredLogLines.get(i))); } // Fill remaining space @@ -782,6 +965,19 @@ private void renderLog(Frame frame, Rect area) { frame.renderWidget(logParagraph, area); } + private String buildLevelFilterTitle() { + StringBuilder sb = new StringBuilder(); + sb.append(showLogTrace ? "[T] " : "[t] "); + sb.append(showLogDebug ? "[D] " : "[d] "); + sb.append(showLogInfo ? "[I] " : "[i] "); + sb.append(showLogWarn ? "[W] " : "[w] "); + sb.append(showLogError ? "[E] " : "[e] "); + if (logFollowMode) { + sb.append("[FOLLOW]"); + } + return sb.toString(); + } + private Line colorizeLogLine(String line) { if (line.contains(" ERROR ") || line.contains(" FATAL ")) { return Line.from(Span.styled(line, Style.create().fg(Color.RED))); @@ -810,7 +1006,7 @@ private void readLogFile(String pid) { // Read remaining bytes and split into lines using proper encoding byte[] remaining = new byte[(int) (length - raf.getFilePointer())]; raf.readFully(remaining); - String content = new String(remaining, java.nio.charset.StandardCharsets.UTF_8); + String content = new String(remaining, StandardCharsets.UTF_8); String[] lines = content.split("\n", -1); int start = Math.max(0, lines.length - MAX_LOG_LINES); for (int i = start; i < lines.length; i++) { @@ -824,6 +1020,206 @@ private void readLogFile(String pid) { } } + private void applyLogFilters() { + filteredLogLines.clear(); + for (String line : logLines) { + if (!matchesLogLevelFilter(line)) { + continue; + } + filteredLogLines.add(line); + } + } + + private boolean matchesLogLevelFilter(String line) { + if (line.contains(" ERROR ") || line.contains(" FATAL ")) { + return showLogError; + } else if (line.contains(" WARN ")) { + return showLogWarn; + } else if (line.contains(" DEBUG ")) { + return showLogDebug; + } else if (line.contains(" TRACE ")) { + return showLogTrace; + } + // Lines without a recognized level are treated as INFO + return showLogInfo; + } + + // ---- Tab 6: Trace ---- + + private void renderTrace(Frame frame, Rect area) { + IntegrationInfo info = findSelectedIntegration(); + if (info == null) { + renderNoSelection(frame, area); + return; + } + + List current = traces.get(); + + // Layout: trace list (50%) + detail panel (50%) + List chunks = Layout.vertical() + .constraints(Constraint.percentage(50), Constraint.fill()) + .split(area); + + // Auto-follow: select last entry + if (traceFollowMode && !current.isEmpty()) { + traceTableState.select(current.size() - 1); + } + + // Trace list + List rows = new ArrayList<>(); + for (TraceEntry entry : current) { + Style statusStyle = switch (entry.status) { + case "Created" -> Style.create().fg(Color.CYAN); + case "Routing", "Processing" -> Style.create().fg(Color.YELLOW); + case "Sent" -> Style.create().fg(Color.GREEN); + default -> Style.create().fg(Color.WHITE); + }; + + String bodyPreview = entry.bodyPreview != null ? truncate(entry.bodyPreview, 40) : ""; + + rows.add(Row.from( + Cell.from(entry.timestamp != null ? truncate(entry.timestamp, 12) : ""), + Cell.from(entry.pid != null ? entry.pid : ""), + Cell.from(Span.styled( + entry.routeId != null ? truncate(entry.routeId, 15) : "", + Style.create().fg(Color.CYAN))), + Cell.from(entry.nodeId != null ? truncate(entry.nodeId, 15) : ""), + Cell.from(Span.styled(entry.status != null ? entry.status : "", statusStyle)), + Cell.from(entry.elapsed + "ms"), + Cell.from(bodyPreview))); + } + + Row header = Row.from( + Cell.from(Span.styled("TIME", Style.create().bold())), + Cell.from(Span.styled("PID", Style.create().bold())), + Cell.from(Span.styled("ROUTE", Style.create().bold())), + Cell.from(Span.styled("NODE", Style.create().bold())), + Cell.from(Span.styled("STATUS", Style.create().bold())), + Cell.from(Span.styled("ELAPSED", Style.create().bold())), + Cell.from(Span.styled("BODY", Style.create().bold()))); + + String traceTitle = String.format(" Traces [%d] %s ", + current.size(), + traceFollowMode ? "[FOLLOW]" : "[SCROLL]"); + + Table table = Table.builder() + .rows(rows) + .header(header) + .widths( + Constraint.length(12), + Constraint.length(8), + Constraint.length(15), + Constraint.length(15), + Constraint.length(12), + Constraint.length(10), + Constraint.fill()) + .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .block(Block.builder().borderType(BorderType.ROUNDED).title(traceTitle).build()) + .build(); + + frame.renderStatefulWidget(table, chunks.get(0), traceTableState); + + // Detail panel + renderTraceDetail(frame, chunks.get(1), current); + } + + private void renderTraceDetail(Frame frame, Rect area, List current) { + Integer sel = traceTableState.selected(); + + if (sel == null || sel < 0 || sel >= current.size()) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from( + Span.styled(" Select a trace entry to view details", + Style.create().dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" Detail ").build()) + .build(), + area); + return; + } + + TraceEntry entry = current.get(sel); + List lines = new ArrayList<>(); + + // Exchange info + lines.add(Line.from( + Span.styled(" Exchange: ", Style.create().fg(Color.YELLOW).bold()), + Span.raw(entry.exchangeId != null ? entry.exchangeId : ""))); + lines.add(Line.from( + Span.styled(" UID: ", Style.create().fg(Color.YELLOW).bold()), + Span.raw(entry.uid != null ? entry.uid : ""))); + lines.add(Line.from( + Span.styled(" Location: ", Style.create().fg(Color.YELLOW).bold()), + Span.raw(entry.location != null ? entry.location : ""))); + lines.add(Line.from( + Span.styled(" Route: ", Style.create().fg(Color.YELLOW).bold()), + Span.raw(entry.routeId != null ? entry.routeId : ""), + Span.raw(" Node: "), + Span.raw(entry.nodeId != null ? entry.nodeId : ""))); + lines.add(Line.from( + Span.styled(" Status: ", Style.create().fg(Color.YELLOW).bold()), + Span.raw(entry.status != null ? entry.status : ""), + Span.raw(" Elapsed: "), + Span.raw(entry.elapsed + "ms"))); + lines.add(Line.from(Span.raw(""))); + + // Headers + if (showTraceHeaders && entry.headers != null && !entry.headers.isEmpty()) { + lines.add(Line.from(Span.styled(" Headers:", Style.create().fg(Color.GREEN).bold()))); + for (Map.Entry h : entry.headers.entrySet()) { + lines.add(Line.from( + Span.styled(" " + h.getKey(), Style.create().fg(Color.CYAN)), + Span.raw(" = "), + Span.raw(h.getValue() != null ? h.getValue().toString() : "null"))); + } + lines.add(Line.from(Span.raw(""))); + } + + // Body + if (showTraceBody && entry.body != null) { + lines.add(Line.from(Span.styled(" Body:", Style.create().fg(Color.GREEN).bold()))); + String[] bodyLines = entry.body.split("\n"); + for (String bl : bodyLines) { + lines.add(Line.from(Span.raw(" " + bl))); + } + lines.add(Line.from(Span.raw(""))); + } + + // Exchange properties + if (entry.exchangeProperties != null && !entry.exchangeProperties.isEmpty()) { + lines.add(Line.from(Span.styled(" Exchange Properties:", Style.create().fg(Color.GREEN).bold()))); + for (Map.Entry p : entry.exchangeProperties.entrySet()) { + lines.add(Line.from( + Span.styled(" " + p.getKey(), Style.create().fg(Color.CYAN)), + Span.raw(" = "), + Span.raw(p.getValue() != null ? p.getValue().toString() : "null"))); + } + lines.add(Line.from(Span.raw(""))); + } + + // Exchange variables + if (entry.exchangeVariables != null && !entry.exchangeVariables.isEmpty()) { + lines.add(Line.from(Span.styled(" Exchange Variables:", Style.create().fg(Color.GREEN).bold()))); + for (Map.Entry v : entry.exchangeVariables.entrySet()) { + lines.add(Line.from( + Span.styled(" " + v.getKey(), Style.create().fg(Color.CYAN)), + Span.raw(" = "), + Span.raw(v.getValue() != null ? v.getValue().toString() : "null"))); + } + } + + String title = String.format(" Detail [%s] ", entry.exchangeId != null ? truncate(entry.exchangeId, 30) : ""); + + Paragraph detail = Paragraph.builder() + .text(Text.from(lines)) + .overflow(Overflow.CLIP) + .block(Block.builder().borderType(BorderType.ROUNDED).title(title).build()) + .build(); + + frame.renderWidget(detail, area); + } + // ---- Shared rendering ---- private void renderNoSelection(Frame frame, Rect area) { @@ -843,7 +1239,8 @@ private void renderFooter(Frame frame, Rect area) { ? (refreshInterval / 1000) + "s" : refreshInterval + "ms"; Line footer; - if (tabsState.selected() == TAB_OVERVIEW) { + int tab = tabsState.selected(); + if (tab == TAB_OVERVIEW) { footer = Line.from( Span.styled(" q", Style.create().fg(Color.YELLOW).bold()), Span.raw(" quit "), @@ -853,10 +1250,32 @@ private void renderFooter(Frame frame, Rect area) { Span.raw(" navigate "), Span.styled("Enter", Style.create().fg(Color.YELLOW).bold()), Span.raw(" details "), - Span.styled("1-5", Style.create().fg(Color.YELLOW).bold()), + Span.styled("1-6", Style.create().fg(Color.YELLOW).bold()), Span.raw(" tabs "), Span.styled("Refresh: " + refreshLabel, Style.create().dim())); - } else if (tabsState.selected() == TAB_LOG) { + } else if (tab == TAB_ROUTES) { + footer = Line.from( + Span.styled(" Esc", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" back "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" navigate "), + Span.styled("s", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" sort "), + Span.styled("1-6", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" tabs "), + Span.styled("Refresh: " + refreshLabel, Style.create().dim())); + } else if (tab == TAB_HEALTH) { + footer = Line.from( + Span.styled(" Esc", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" back "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" navigate "), + Span.styled("d", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" toggle DOWN "), + Span.styled("1-6", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" tabs "), + Span.styled("Refresh: " + refreshLabel, Style.create().dim())); + } else if (tab == TAB_LOG) { footer = Line.from( Span.styled(" Esc", Style.create().fg(Color.YELLOW).bold()), Span.raw(" back "), @@ -864,16 +1283,31 @@ private void renderFooter(Frame frame, Rect area) { Span.raw(" scroll "), Span.styled("PgUp/PgDn", Style.create().fg(Color.YELLOW).bold()), Span.raw(" page "), - Span.styled("1-5", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" tabs "), - Span.styled("Refresh: " + refreshLabel, Style.create().dim())); + Span.styled("t/d/i/w/e", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" levels "), + Span.styled("f", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" follow "), + Span.styled("g/G", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" top/end")); + } else if (tab == TAB_TRACE) { + footer = Line.from( + Span.styled(" Esc", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" back "), + Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" navigate "), + Span.styled("h", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" headers" + (showTraceHeaders ? " [on]" : " [off]") + " "), + Span.styled("b", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" body" + (showTraceBody ? " [on]" : " [off]") + " "), + Span.styled("f", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" follow" + (traceFollowMode ? " [on]" : " [off]"))); } else { footer = Line.from( Span.styled(" Esc", Style.create().fg(Color.YELLOW).bold()), Span.raw(" back "), Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), Span.raw(" navigate "), - Span.styled("1-5", Style.create().fg(Color.YELLOW).bold()), + Span.styled("1-6", Style.create().fg(Color.YELLOW).bold()), Span.raw(" tabs "), Span.styled("Refresh: " + refreshLabel, Style.create().dim())); } @@ -930,6 +1364,9 @@ private void refreshData() { } data.set(infos); + + // Refresh trace data + refreshTraceData(pids); } catch (Exception e) { // ignore refresh errors } @@ -970,6 +1407,147 @@ private void updateThroughputHistory(IntegrationInfo info) { } } + // ---- Trace Data Loading ---- + + private void refreshTraceData(List pids) { + List allTraces = new ArrayList<>(traces.get()); + + for (Long pid : pids) { + readTraceFile(Long.toString(pid), allTraces); + } + + // Sort by timestamp + allTraces.sort((a, b) -> { + if (a.timestamp == null && b.timestamp == null) { + return 0; + } + if (a.timestamp == null) { + return -1; + } + if (b.timestamp == null) { + return 1; + } + return a.timestamp.compareTo(b.timestamp); + }); + + // Keep only last MAX_TRACES + if (allTraces.size() > MAX_TRACES) { + allTraces = new ArrayList<>(allTraces.subList(allTraces.size() - MAX_TRACES, allTraces.size())); + } + + traces.set(allTraces); + } + + @SuppressWarnings("unchecked") + private void readTraceFile(String pid, List allTraces) { + Path traceFile = CommandLineHelper.getCamelDir().resolve(pid + "-trace.json"); + if (!Files.exists(traceFile)) { + return; + } + + long lastPos = traceFilePositions.getOrDefault(pid, 0L); + + try (RandomAccessFile raf = new RandomAccessFile(traceFile.toFile(), "r")) { + long length = raf.length(); + if (length <= lastPos) { + return; // no new data + } + + raf.seek(lastPos); + // If we're resuming mid-file, skip any partial line + if (lastPos > 0) { + raf.readLine(); + } + + // Read remaining bytes + long startPos = raf.getFilePointer(); + byte[] remaining = new byte[(int) (length - startPos)]; + raf.readFully(remaining); + String content = new String(remaining, StandardCharsets.UTF_8); + + traceFilePositions.put(pid, length); + + // Each line is a separate JSON object + String[] lines = content.split("\n"); + for (String line : lines) { + line = line.trim(); + if (line.isEmpty()) { + continue; + } + try { + JsonObject json = (JsonObject) Jsoner.deserialize(line); + TraceEntry entry = parseTraceEntry(json, pid); + if (entry != null) { + allTraces.add(entry); + } + } catch (Exception e) { + // skip malformed lines + } + } + } catch (IOException e) { + // ignore + } + } + + @SuppressWarnings("unchecked") + private TraceEntry parseTraceEntry(JsonObject json, String pid) { + TraceEntry entry = new TraceEntry(); + entry.pid = pid; + entry.uid = json.getString("uid"); + entry.exchangeId = json.getString("exchangeId"); + entry.timestamp = json.getString("timestamp"); + entry.routeId = json.getString("routeId"); + entry.nodeId = json.getString("nodeId"); + entry.location = json.getString("location"); + entry.status = json.getString("status"); + + Object elapsedObj = json.get("elapsed"); + if (elapsedObj instanceof Number n) { + entry.elapsed = n.longValue(); + } else if (elapsedObj != null) { + try { + entry.elapsed = Long.parseLong(elapsedObj.toString()); + } catch (NumberFormatException e) { + // ignore + } + } + + // Parse message object + JsonObject message = (JsonObject) json.get("message"); + if (message != null) { + // Headers + Object headersObj = message.get("headers"); + if (headersObj instanceof Map) { + entry.headers = new LinkedHashMap<>((Map) headersObj); + } + + // Body + Object bodyObj = message.get("body"); + if (bodyObj != null) { + entry.body = bodyObj.toString(); + // Create a preview (first line, truncated) + String preview = entry.body.replace("\n", " ").replace("\r", ""); + entry.bodyPreview = preview; + } + + // Exchange properties + Object propsObj = message.get("exchangeProperties"); + if (propsObj instanceof Map) { + entry.exchangeProperties = new LinkedHashMap<>((Map) propsObj); + } + + // Exchange variables + Object varsObj = message.get("exchangeVariables"); + if (varsObj instanceof Map) { + entry.exchangeVariables = new LinkedHashMap<>((Map) varsObj); + } + } + + return entry; + } + + // ---- Integration Parsing ---- + @SuppressWarnings("unchecked") private IntegrationInfo parseIntegration(ProcessHandle ph, JsonObject root) { JsonObject context = (JsonObject) root.get("context"); @@ -1069,6 +1647,8 @@ private IntegrationInfo parseIntegration(ProcessHandle ph, JsonObject root) { hc.group = cj.getString("group"); hc.name = cj.getString("id"); hc.state = cj.getString("state"); + hc.readiness = cj.getBooleanOrDefault("readiness", false); + hc.liveness = cj.getBooleanOrDefault("liveness", false); // Extract message from details if available JsonObject details = (JsonObject) cj.get("details"); if (details != null && details.containsKey("failure.error.message")) { @@ -1221,6 +1801,8 @@ static class HealthCheckInfo { String group; String name; String state; + boolean readiness; + boolean liveness; String message; } @@ -1231,6 +1813,23 @@ static class EndpointInfo { String routeId; } + static class TraceEntry { + String pid; + String uid; + String exchangeId; + String timestamp; + String routeId; + String nodeId; + String location; + String status; + long elapsed; + String body; + String bodyPreview; + Map headers; + Map exchangeProperties; + Map exchangeVariables; + } + record VanishingInfo(IntegrationInfo info, long startTime) { } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java deleted file mode 100644 index 6db0b95cd451c..0000000000000 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTopTui.java +++ /dev/null @@ -1,422 +0,0 @@ -/* - * 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.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; - -import dev.tamboui.layout.Constraint; -import dev.tamboui.layout.Layout; -import dev.tamboui.layout.Rect; -import dev.tamboui.style.Color; -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.tui.TuiRunner; -import dev.tamboui.tui.event.Event; -import dev.tamboui.tui.event.KeyCode; -import dev.tamboui.tui.event.KeyEvent; -import dev.tamboui.tui.event.TickEvent; -import dev.tamboui.widgets.block.Block; -import dev.tamboui.widgets.block.BorderType; -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.dsl.jbang.core.commands.CamelCommand; -import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; -import org.apache.camel.dsl.jbang.core.common.ProcessHelper; -import org.apache.camel.util.json.JsonArray; -import org.apache.camel.util.json.JsonObject; -import picocli.CommandLine; -import picocli.CommandLine.Command; - -@Command(name = "top-tui", - description = "Live TUI dashboard for route performance", - sortOptions = false) -public class CamelTopTui extends CamelCommand { - - private static final String[] SORT_COLUMNS = { "mean", "max", "total", "failed", "name" }; - - @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1") - String name = "*"; - - @CommandLine.Option(names = { "--refresh" }, - description = "Refresh interval in milliseconds (default: ${DEFAULT-VALUE})", - defaultValue = "500") - long refreshInterval = 500; - - @CommandLine.Option(names = { "--sort" }, - description = "Sort column: mean, max, total, failed, name (default: ${DEFAULT-VALUE})", - defaultValue = "mean") - String sort = "mean"; - - // State - private final AtomicReference> data = new AtomicReference<>(Collections.emptyList()); - private final TableState tableState = new TableState(); - private int sortIndex; - private volatile long lastRefresh; - - public CamelTopTui(CamelJBangMain main) { - super(main); - } - - @Override - public Integer doCall() throws Exception { - TuiHelper.preloadClasses(); - - // Resolve initial sort index - sortIndex = indexOfSort(sort); - - // Initial data load - refreshData(); - - try (var tui = TuiRunner.create()) { - sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit()); - tui.run(this::handleEvent, this::render); - } - return 0; - } - - // ---- Event Handling ---- - - private boolean handleEvent(Event event, TuiRunner runner) { - if (event instanceof KeyEvent ke) { - if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) { - runner.quit(); - return true; - } - if (ke.isChar('r')) { - refreshData(); - return true; - } - if (ke.isCharIgnoreCase('s')) { - // Cycle sort column - sortIndex = (sortIndex + 1) % SORT_COLUMNS.length; - sort = SORT_COLUMNS[sortIndex]; - sortData(); - return true; - } - if (ke.isUp()) { - tableState.selectPrevious(); - return true; - } - if (ke.isDown()) { - tableState.selectNext(data.get().size()); - return true; - } - } - if (event instanceof TickEvent) { - long now = System.currentTimeMillis(); - if (now - lastRefresh >= refreshInterval) { - refreshData(); - } - return true; - } - return false; - } - - // ---- Rendering ---- - - private void render(Frame frame) { - Rect area = frame.area(); - - // Layout: header (3 rows) + table (fill) + footer (1 row) - List chunks = Layout.vertical() - .constraints( - Constraint.length(3), - Constraint.fill(), - Constraint.length(1)) - .split(area); - - renderHeader(frame, chunks.get(0)); - renderTable(frame, chunks.get(1)); - renderFooter(frame, chunks.get(2)); - } - - private void renderHeader(Frame frame, Rect area) { - List rows = data.get(); - long totalExchanges = rows.stream().mapToLong(r -> r.total).sum(); - long totalFailed = rows.stream().mapToLong(r -> r.failed).sum(); - int routeCount = rows.size(); - long pidCount = rows.stream().map(r -> r.pid).distinct().count(); - - Line titleLine = Line.from( - Span.styled(" Camel Top", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()), - Span.raw(" "), - Span.styled(pidCount + " integration(s)", Style.create().fg(Color.CYAN)), - Span.raw(" "), - Span.styled(routeCount + " route(s)", Style.create().fg(Color.GREEN)), - Span.raw(" "), - Span.styled("total: " + totalExchanges, Style.create().fg(Color.WHITE)), - Span.raw(" "), - Span.styled("failed: " + totalFailed, - totalFailed > 0 ? Style.create().fg(Color.RED).bold() : Style.create().dim()), - Span.raw(" "), - Span.styled("sort: " + sort, Style.create().fg(Color.YELLOW))); - - Block headerBlock = Block.builder() - .borderType(BorderType.ROUNDED) - .title(" Apache Camel - Route Performance ") - .build(); - - frame.renderWidget( - Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(), - area); - } - - private void renderTable(Frame frame, Rect area) { - List rows = data.get(); - - List tableRows = new ArrayList<>(); - for (RouteRow r : rows) { - Style statusStyle = "Started".equals(r.state) - ? Style.create().fg(Color.GREEN) - : Style.create().fg(Color.RED); - - Style failStyle = r.failed > 0 - ? Style.create().fg(Color.RED).bold() - : Style.create(); - - tableRows.add(Row.from( - Cell.from(r.pid), - Cell.from(Span.styled(truncate(r.name, 20), Style.create().fg(Color.CYAN))), - Cell.from(Span.styled(truncate(r.routeId, 18), Style.create().fg(Color.WHITE))), - Cell.from(truncate(r.from, 30)), - Cell.from(Span.styled(r.state != null ? r.state : "", statusStyle)), - Cell.from(String.valueOf(r.total)), - Cell.from(Span.styled(String.valueOf(r.failed), failStyle)), - Cell.from(String.valueOf(r.inflight)), - Cell.from(r.mean >= 0 ? String.valueOf(r.mean) : ""), - Cell.from(r.min >= 0 ? String.valueOf(r.min) : ""), - Cell.from(r.max >= 0 ? String.valueOf(r.max) : ""), - Cell.from(r.last >= 0 ? String.valueOf(r.last) : ""), - Cell.from(r.throughput != null ? r.throughput : ""))); - } - - Row header = Row.from( - Cell.from(Span.styled("PID", Style.create().bold())), - Cell.from(Span.styled("NAME", Style.create().bold())), - Cell.from(Span.styled("ROUTE", Style.create().bold())), - Cell.from(Span.styled("FROM", Style.create().bold())), - Cell.from(Span.styled("STATUS", Style.create().bold())), - Cell.from(Span.styled("TOTAL", Style.create().bold())), - Cell.from(Span.styled("FAIL", Style.create().bold())), - Cell.from(Span.styled("INFLIGHT", Style.create().bold())), - Cell.from(Span.styled(sortLabel("MEAN", "mean"), sortStyle("mean"))), - Cell.from(Span.styled(sortLabel("MIN", "min"), sortStyle("min"))), - Cell.from(Span.styled(sortLabel("MAX", "max"), sortStyle("max"))), - Cell.from(Span.styled("LAST", Style.create().bold())), - Cell.from(Span.styled("THRUPUT", Style.create().bold()))); - - Table table = Table.builder() - .rows(tableRows) - .header(header) - .widths( - Constraint.length(8), // PID - Constraint.length(20), // NAME - Constraint.length(18), // ROUTE - Constraint.fill(), // FROM - Constraint.length(9), // STATUS - Constraint.length(8), // TOTAL - Constraint.length(6), // FAIL - Constraint.length(9), // INFLIGHT - Constraint.length(6), // MEAN - Constraint.length(6), // MIN - Constraint.length(6), // MAX - Constraint.length(6), // LAST - Constraint.length(9)) // THRUPUT - .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) - .block(Block.builder().borderType(BorderType.ROUNDED).title(" Routes ").build()) - .build(); - - frame.renderStatefulWidget(table, area, tableState); - } - - private void renderFooter(Frame frame, Rect area) { - String refreshLabel = refreshInterval >= 1000 - ? (refreshInterval / 1000) + "s" - : refreshInterval + "ms"; - - Line footer = Line.from( - Span.styled(" q", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" quit "), - Span.styled("s", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" sort "), - Span.styled("r", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" refresh "), - Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" navigate "), - Span.styled("Refresh: " + refreshLabel, Style.create().dim())); - - frame.renderWidget(Paragraph.from(footer), area); - } - - // ---- Data Loading ---- - - @SuppressWarnings("unchecked") - private void refreshData() { - lastRefresh = System.currentTimeMillis(); - try { - List rows = new ArrayList<>(); - List pids = findPids(name); - ProcessHandle.allProcesses() - .filter(ph -> pids.contains(ph.pid())) - .forEach(ph -> { - JsonObject root = loadStatus(ph.pid()); - if (root != null) { - JsonObject context = (JsonObject) root.get("context"); - if (context == null) { - return; - } - String integrationName = context.getString("name"); - if ("CamelJBang".equals(integrationName)) { - integrationName = ProcessHelper.extractName(root, ph); - } - String pid = Long.toString(ph.pid()); - - JsonArray routesArray = (JsonArray) root.get("routes"); - if (routesArray != null) { - for (Object r : routesArray) { - JsonObject rj = (JsonObject) r; - RouteRow row = new RouteRow(); - row.pid = pid; - row.name = integrationName; - row.routeId = rj.getString("routeId"); - row.from = rj.getString("from"); - row.state = rj.getString("state"); - - Map stats = rj.getMap("statistics"); - if (stats != null) { - row.total = objToLong(stats.get("exchangesTotal")); - row.failed = objToLong(stats.get("exchangesFailed")); - row.inflight = objToLong(stats.get("exchangesInflight")); - row.mean = objToLong(stats.get("meanProcessingTime")); - row.min = objToLong(stats.get("minProcessingTime")); - row.max = objToLong(stats.get("maxProcessingTime")); - row.last = objToLong(stats.get("lastProcessingTime")); - Object thp = stats.get("exchangesThroughput"); - if (thp != null) { - row.throughput = thp.toString(); - } - } - rows.add(row); - } - } - } - }); - - // Sort - rows.sort(this::sortRow); - data.set(rows); - } catch (Exception e) { - // ignore refresh errors - } - } - - private void sortData() { - List rows = new ArrayList<>(data.get()); - rows.sort(this::sortRow); - data.set(rows); - } - - private int sortRow(RouteRow o1, RouteRow o2) { - switch (sort) { - case "mean": - return Long.compare(o2.mean, o1.mean); // highest first - case "max": - return Long.compare(o2.max, o1.max); - case "total": - return Long.compare(o2.total, o1.total); - case "failed": - return Long.compare(o2.failed, o1.failed); - case "name": - int c = o1.name != null && o2.name != null - ? o1.name.compareToIgnoreCase(o2.name) - : 0; - if (c == 0) { - c = o1.routeId != null && o2.routeId != null - ? o1.routeId.compareToIgnoreCase(o2.routeId) - : 0; - } - return c; - default: - return 0; - } - } - - // ---- Helpers ---- - - private List findPids(String name) { - return TuiHelper.findPids(name, this::getStatusFile); - } - - private JsonObject loadStatus(long pid) { - return TuiHelper.loadStatus(pid, this::getStatusFile); - } - - private static int indexOfSort(String s) { - for (int i = 0; i < SORT_COLUMNS.length; i++) { - if (SORT_COLUMNS[i].equals(s)) { - return i; - } - } - return 0; - } - - private String sortLabel(String label, String column) { - return sort.equals(column) ? label + "\u25BC" : label; - } - - private Style sortStyle(String column) { - return sort.equals(column) - ? Style.create().fg(Color.YELLOW).bold() - : Style.create().bold(); - } - - private static String truncate(String s, int max) { - return TuiHelper.truncate(s, max); - } - - private static long objToLong(Object o) { - return TuiHelper.objToLong(o); - } - - // ---- Data Class ---- - - static class RouteRow { - String pid; - String name; - String routeId; - String from; - String state; - long total; - long failed; - long inflight; - long mean; - long min; - long max; - long last; - String throughput; - } -} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java deleted file mode 100644 index 64a2746ab1fe0..0000000000000 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelTraceTui.java +++ /dev/null @@ -1,581 +0,0 @@ -/* - * 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.io.IOException; -import java.io.RandomAccessFile; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; - -import dev.tamboui.layout.Constraint; -import dev.tamboui.layout.Layout; -import dev.tamboui.layout.Rect; -import dev.tamboui.style.Color; -import dev.tamboui.style.Overflow; -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.tui.TuiRunner; -import dev.tamboui.tui.event.Event; -import dev.tamboui.tui.event.KeyCode; -import dev.tamboui.tui.event.KeyEvent; -import dev.tamboui.tui.event.TickEvent; -import dev.tamboui.widgets.block.Block; -import dev.tamboui.widgets.block.BorderType; -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.dsl.jbang.core.commands.CamelCommand; -import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; -import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; -import org.apache.camel.util.json.JsonObject; -import org.apache.camel.util.json.Jsoner; -import picocli.CommandLine; -import picocli.CommandLine.Command; - -@Command(name = "trace-tui", - description = "TUI exchange trace viewer", - sortOptions = false) -public class CamelTraceTui extends CamelCommand { - - private static final int MAX_TRACES = 200; - - @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1") - String name = "*"; - - @CommandLine.Option(names = { "--refresh" }, - description = "Refresh interval in milliseconds (default: ${DEFAULT-VALUE})", - defaultValue = "200") - long refreshInterval = 200; - - @CommandLine.Option(names = { "--grep" }, - description = "Filter traces by text") - String grep; - - // State - private final AtomicReference> traces = new AtomicReference<>(Collections.emptyList()); - private final TableState traceTableState = new TableState(); - private final Map traceFilePositions = new LinkedHashMap<>(); - - private boolean showHeaders = true; - private boolean showBody = true; - private boolean followMode = true; - private volatile long lastRefresh; - - public CamelTraceTui(CamelJBangMain main) { - super(main); - } - - @Override - public Integer doCall() throws Exception { - TuiHelper.preloadClasses(); - - // Initial data load - refreshData(); - - try (var tui = TuiRunner.create()) { - sun.misc.Signal.handle(new sun.misc.Signal("INT"), sig -> tui.quit()); - tui.run( - this::handleEvent, - this::render); - } - return 0; - } - - // ---- Event Handling ---- - - private boolean handleEvent(Event event, TuiRunner runner) { - if (event instanceof KeyEvent ke) { - if (ke.isQuit() || ke.isCharIgnoreCase('q') || ke.isKey(KeyCode.ESCAPE)) { - runner.quit(); - return true; - } - if (ke.isUp()) { - followMode = false; - traceTableState.selectPrevious(); - return true; - } - if (ke.isDown()) { - List current = traces.get(); - traceTableState.selectNext(current.size()); - return true; - } - if (ke.isCharIgnoreCase('h')) { - showHeaders = !showHeaders; - return true; - } - if (ke.isCharIgnoreCase('b')) { - showBody = !showBody; - return true; - } - if (ke.isCharIgnoreCase('f')) { - followMode = !followMode; - if (followMode) { - List current = traces.get(); - if (!current.isEmpty()) { - traceTableState.select(current.size() - 1); - } - } - return true; - } - } - if (event instanceof TickEvent) { - long now = System.currentTimeMillis(); - if (now - lastRefresh >= refreshInterval) { - refreshData(); - } - return true; - } - return false; - } - - // ---- Rendering ---- - - private void render(Frame frame) { - Rect area = frame.area(); - - // Layout: header (3 rows) + trace list (50%) + detail panel (50%) + footer (1 row) - List mainChunks = Layout.vertical() - .constraints( - Constraint.length(3), - Constraint.percentage(50), - Constraint.fill(), - Constraint.length(1)) - .split(area); - - renderHeader(frame, mainChunks.get(0)); - renderTraceList(frame, mainChunks.get(1)); - renderDetailPanel(frame, mainChunks.get(2)); - renderFooter(frame, mainChunks.get(3)); - } - - private void renderHeader(Frame frame, Rect area) { - List current = traces.get(); - String filterInfo = grep != null ? " filter: " + grep : ""; - - Line titleLine = Line.from( - Span.styled(" Camel Trace Viewer", Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)).bold()), - Span.raw(" "), - Span.styled(current.size() + " trace(s)", Style.create().fg(Color.CYAN)), - Span.raw(" "), - Span.styled(followMode ? "[FOLLOW]" : "[SCROLL]", - Style.create().fg(followMode ? Color.GREEN : Color.YELLOW).bold()), - Span.styled(filterInfo, Style.create().dim())); - - Block headerBlock = Block.builder() - .borderType(BorderType.ROUNDED) - .title(" Apache Camel Traces ") - .build(); - - frame.renderWidget( - Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(), - area); - } - - private void renderTraceList(Frame frame, Rect area) { - List current = traces.get(); - - // Auto-follow: select last entry - if (followMode && !current.isEmpty()) { - traceTableState.select(current.size() - 1); - } - - List rows = new ArrayList<>(); - for (TraceEntry entry : current) { - Style statusStyle = switch (entry.status) { - case "Created" -> Style.create().fg(Color.CYAN); - case "Routing", "Processing" -> Style.create().fg(Color.YELLOW); - case "Sent" -> Style.create().fg(Color.GREEN); - default -> Style.create().fg(Color.WHITE); - }; - - String bodyPreview = entry.bodyPreview != null ? truncate(entry.bodyPreview, 40) : ""; - - rows.add(Row.from( - Cell.from(entry.timestamp != null ? truncate(entry.timestamp, 12) : ""), - Cell.from(entry.pid != null ? entry.pid : ""), - Cell.from(Span.styled( - entry.routeId != null ? truncate(entry.routeId, 15) : "", - Style.create().fg(Color.CYAN))), - Cell.from(entry.nodeId != null ? truncate(entry.nodeId, 15) : ""), - Cell.from(Span.styled(entry.status != null ? entry.status : "", statusStyle)), - Cell.from(entry.elapsed + "ms"), - Cell.from(bodyPreview))); - } - - Row header = Row.from( - Cell.from(Span.styled("TIME", Style.create().bold())), - Cell.from(Span.styled("PID", Style.create().bold())), - Cell.from(Span.styled("ROUTE", Style.create().bold())), - Cell.from(Span.styled("NODE", Style.create().bold())), - Cell.from(Span.styled("STATUS", Style.create().bold())), - Cell.from(Span.styled("ELAPSED", Style.create().bold())), - Cell.from(Span.styled("BODY", Style.create().bold()))); - - Table table = Table.builder() - .rows(rows) - .header(header) - .widths( - Constraint.length(12), - Constraint.length(8), - Constraint.length(15), - Constraint.length(15), - Constraint.length(12), - Constraint.length(10), - Constraint.fill()) - .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) - .block(Block.builder().borderType(BorderType.ROUNDED).title(" Traces ").build()) - .build(); - - frame.renderStatefulWidget(table, area, traceTableState); - } - - private void renderDetailPanel(Frame frame, Rect area) { - List current = traces.get(); - Integer sel = traceTableState.selected(); - - if (sel == null || sel < 0 || sel >= current.size()) { - frame.renderWidget( - Paragraph.builder() - .text(Text.from(Line.from( - Span.styled(" Select a trace entry to view details", - Style.create().dim())))) - .block(Block.builder().borderType(BorderType.ROUNDED) - .title(" Detail ").build()) - .build(), - area); - return; - } - - TraceEntry entry = current.get(sel); - List lines = new ArrayList<>(); - - // Exchange info - lines.add(Line.from( - Span.styled(" Exchange: ", Style.create().fg(Color.YELLOW).bold()), - Span.raw(entry.exchangeId != null ? entry.exchangeId : ""))); - lines.add(Line.from( - Span.styled(" UID: ", Style.create().fg(Color.YELLOW).bold()), - Span.raw(entry.uid != null ? entry.uid : ""))); - lines.add(Line.from( - Span.styled(" Location: ", Style.create().fg(Color.YELLOW).bold()), - Span.raw(entry.location != null ? entry.location : ""))); - lines.add(Line.from( - Span.styled(" Route: ", Style.create().fg(Color.YELLOW).bold()), - Span.raw(entry.routeId != null ? entry.routeId : ""), - Span.raw(" Node: "), - Span.raw(entry.nodeId != null ? entry.nodeId : ""))); - lines.add(Line.from( - Span.styled(" Status: ", Style.create().fg(Color.YELLOW).bold()), - Span.raw(entry.status != null ? entry.status : ""), - Span.raw(" Elapsed: "), - Span.raw(entry.elapsed + "ms"))); - lines.add(Line.from(Span.raw(""))); - - // Headers - if (showHeaders && entry.headers != null && !entry.headers.isEmpty()) { - lines.add(Line.from(Span.styled(" Headers:", Style.create().fg(Color.GREEN).bold()))); - for (Map.Entry h : entry.headers.entrySet()) { - lines.add(Line.from( - Span.styled(" " + h.getKey(), Style.create().fg(Color.CYAN)), - Span.raw(" = "), - Span.raw(h.getValue() != null ? h.getValue().toString() : "null"))); - } - lines.add(Line.from(Span.raw(""))); - } - - // Body - if (showBody && entry.body != null) { - lines.add(Line.from(Span.styled(" Body:", Style.create().fg(Color.GREEN).bold()))); - // Split body into lines for display - String[] bodyLines = entry.body.split("\n"); - for (String bl : bodyLines) { - lines.add(Line.from(Span.raw(" " + bl))); - } - lines.add(Line.from(Span.raw(""))); - } - - // Exchange properties - if (entry.exchangeProperties != null && !entry.exchangeProperties.isEmpty()) { - lines.add(Line.from(Span.styled(" Exchange Properties:", Style.create().fg(Color.GREEN).bold()))); - for (Map.Entry p : entry.exchangeProperties.entrySet()) { - lines.add(Line.from( - Span.styled(" " + p.getKey(), Style.create().fg(Color.CYAN)), - Span.raw(" = "), - Span.raw(p.getValue() != null ? p.getValue().toString() : "null"))); - } - lines.add(Line.from(Span.raw(""))); - } - - // Exchange variables - if (entry.exchangeVariables != null && !entry.exchangeVariables.isEmpty()) { - lines.add(Line.from(Span.styled(" Exchange Variables:", Style.create().fg(Color.GREEN).bold()))); - for (Map.Entry v : entry.exchangeVariables.entrySet()) { - lines.add(Line.from( - Span.styled(" " + v.getKey(), Style.create().fg(Color.CYAN)), - Span.raw(" = "), - Span.raw(v.getValue() != null ? v.getValue().toString() : "null"))); - } - } - - String title = String.format(" Detail [%s] ", entry.exchangeId != null ? truncate(entry.exchangeId, 30) : ""); - - Paragraph detail = Paragraph.builder() - .text(Text.from(lines)) - .overflow(Overflow.CLIP) - .block(Block.builder().borderType(BorderType.ROUNDED).title(title).build()) - .build(); - - frame.renderWidget(detail, area); - } - - private void renderFooter(Frame frame, Rect area) { - Line footer = Line.from( - Span.styled(" q", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" quit "), - Span.styled("\u2191\u2193", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" navigate "), - Span.styled("h", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" headers" + (showHeaders ? " [on]" : " [off]") + " "), - Span.styled("b", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" body" + (showBody ? " [on]" : " [off]") + " "), - Span.styled("f", Style.create().fg(Color.YELLOW).bold()), - Span.raw(" follow" + (followMode ? " [on]" : " [off]") + " "), - Span.styled("Refresh: " + refreshInterval + "ms", Style.create().dim())); - - frame.renderWidget(Paragraph.from(footer), area); - } - - // ---- Data Loading ---- - - private void refreshData() { - lastRefresh = System.currentTimeMillis(); - try { - List pids = findPids(name); - List allTraces = new ArrayList<>(traces.get()); - - for (Long pid : pids) { - readTraceFile(Long.toString(pid), allTraces); - } - - // Sort by timestamp - allTraces.sort((a, b) -> { - if (a.timestamp == null && b.timestamp == null) { - return 0; - } - if (a.timestamp == null) { - return -1; - } - if (b.timestamp == null) { - return 1; - } - return a.timestamp.compareTo(b.timestamp); - }); - - // Keep only last MAX_TRACES - if (allTraces.size() > MAX_TRACES) { - allTraces = new ArrayList<>(allTraces.subList(allTraces.size() - MAX_TRACES, allTraces.size())); - } - - // Apply grep filter - if (grep != null && !grep.isEmpty()) { - String lowerGrep = grep.toLowerCase(); - allTraces = allTraces.stream() - .filter(t -> matchesGrep(t, lowerGrep)) - .collect(java.util.stream.Collectors.toList()); - } - - traces.set(allTraces); - } catch (Exception e) { - // ignore refresh errors - } - } - - private boolean matchesGrep(TraceEntry entry, String lowerGrep) { - if (entry.exchangeId != null && entry.exchangeId.toLowerCase().contains(lowerGrep)) { - return true; - } - if (entry.routeId != null && entry.routeId.toLowerCase().contains(lowerGrep)) { - return true; - } - if (entry.nodeId != null && entry.nodeId.toLowerCase().contains(lowerGrep)) { - return true; - } - if (entry.status != null && entry.status.toLowerCase().contains(lowerGrep)) { - return true; - } - if (entry.body != null && entry.body.toLowerCase().contains(lowerGrep)) { - return true; - } - if (entry.location != null && entry.location.toLowerCase().contains(lowerGrep)) { - return true; - } - return false; - } - - @SuppressWarnings("unchecked") - private void readTraceFile(String pid, List allTraces) { - Path traceFile = CommandLineHelper.getCamelDir().resolve(pid + "-trace.json"); - if (!Files.exists(traceFile)) { - return; - } - - long lastPos = traceFilePositions.getOrDefault(pid, 0L); - - try (RandomAccessFile raf = new RandomAccessFile(traceFile.toFile(), "r")) { - long length = raf.length(); - if (length <= lastPos) { - return; // no new data - } - - raf.seek(lastPos); - // If we're resuming mid-file, skip any partial line - if (lastPos > 0) { - raf.readLine(); - } - - // Read remaining bytes - long startPos = raf.getFilePointer(); - byte[] remaining = new byte[(int) (length - startPos)]; - raf.readFully(remaining); - String content = new String(remaining, StandardCharsets.UTF_8); - - traceFilePositions.put(pid, length); - - // Each line is a separate JSON object - String[] lines = content.split("\n"); - for (String line : lines) { - line = line.trim(); - if (line.isEmpty()) { - continue; - } - try { - JsonObject json = (JsonObject) Jsoner.deserialize(line); - TraceEntry entry = parseTraceEntry(json, pid); - if (entry != null) { - allTraces.add(entry); - } - } catch (Exception e) { - // skip malformed lines - } - } - } catch (IOException e) { - // ignore - } - } - - @SuppressWarnings("unchecked") - private TraceEntry parseTraceEntry(JsonObject json, String pid) { - TraceEntry entry = new TraceEntry(); - entry.pid = pid; - entry.uid = json.getString("uid"); - entry.exchangeId = json.getString("exchangeId"); - entry.timestamp = json.getString("timestamp"); - entry.routeId = json.getString("routeId"); - entry.nodeId = json.getString("nodeId"); - entry.location = json.getString("location"); - entry.status = json.getString("status"); - - Object elapsedObj = json.get("elapsed"); - if (elapsedObj instanceof Number n) { - entry.elapsed = n.longValue(); - } else if (elapsedObj != null) { - try { - entry.elapsed = Long.parseLong(elapsedObj.toString()); - } catch (NumberFormatException e) { - // ignore - } - } - - // Parse message object - JsonObject message = (JsonObject) json.get("message"); - if (message != null) { - // Headers - Object headersObj = message.get("headers"); - if (headersObj instanceof Map) { - entry.headers = new LinkedHashMap<>((Map) headersObj); - } - - // Body - Object bodyObj = message.get("body"); - if (bodyObj != null) { - entry.body = bodyObj.toString(); - // Create a preview (first line, truncated) - String preview = entry.body.replace("\n", " ").replace("\r", ""); - entry.bodyPreview = preview; - } - - // Exchange properties - Object propsObj = message.get("exchangeProperties"); - if (propsObj instanceof Map) { - entry.exchangeProperties = new LinkedHashMap<>((Map) propsObj); - } - - // Exchange variables - Object varsObj = message.get("exchangeVariables"); - if (varsObj instanceof Map) { - entry.exchangeVariables = new LinkedHashMap<>((Map) varsObj); - } - } - - return entry; - } - - // ---- Helpers ---- - - private List findPids(String name) { - return TuiHelper.findPids(name, this::getStatusFile); - } - - private JsonObject loadStatus(long pid) { - return TuiHelper.loadStatus(pid, this::getStatusFile); - } - - private static String truncate(String s, int max) { - return TuiHelper.truncate(s, max); - } - - // ---- Data Classes ---- - - static class TraceEntry { - String pid; - String uid; - String exchangeId; - String timestamp; - String routeId; - String nodeId; - String location; - String status; - long elapsed; - String body; - String bodyPreview; - Map headers; - Map exchangeProperties; - Map exchangeVariables; - } -} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java index 08dead9aef4ae..1132df8923bde 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java @@ -21,32 +21,12 @@ import org.apache.camel.dsl.jbang.core.common.Plugin; import picocli.CommandLine; -@CamelJBangPlugin(name = "camel-jbang-plugin-tui", firstVersion = "4.19.0", - commands = { "health", "monitor", "top", "trace", "log", "catalog" }) +@CamelJBangPlugin(name = "camel-jbang-plugin-tui", firstVersion = "4.19.0") public class TuiPlugin implements Plugin { @Override public void customize(CommandLine commandLine, CamelJBangMain main) { - // Top-level TUI commands - commandLine.addSubcommand("health", new CommandLine(new CamelHealthTui(main))); commandLine.addSubcommand("monitor", new CommandLine(new CamelMonitor(main))); - - // Subcommands of existing commands - CommandLine topCmd = commandLine.getSubcommands().get("top"); - if (topCmd != null) { - topCmd.addSubcommand("tui", new CommandLine(new CamelTopTui(main))); - } - CommandLine traceCmd = commandLine.getSubcommands().get("trace"); - if (traceCmd != null) { - traceCmd.addSubcommand("tui", new CommandLine(new CamelTraceTui(main))); - } - CommandLine logCmd = commandLine.getSubcommands().get("log"); - if (logCmd != null) { - logCmd.addSubcommand("tui", new CommandLine(new CamelLogTui(main))); - } - CommandLine catalogCmd = commandLine.getSubcommands().get("catalog"); - if (catalogCmd != null) { - catalogCmd.addSubcommand("tui", new CommandLine(new CamelCatalogTui(main))); - } + commandLine.addSubcommand("catalog-tui", new CommandLine(new CamelCatalogTui(main))); } } From 0afc6c0291cb153d8192a8cb30bd68fe5a6d3f29 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 23 Mar 2026 21:20:27 +0100 Subject: [PATCH 07/10] CAMEL-23226: Redesign log tab with selectable entries and fix rendering - Redesign log tab: table with selectable rows + bottom detail panel - Add LogEntry data class for structured log parsing - Add highlight spacing (ALWAYS) to all tables to prevent content shift - Remove hardcoded column truncation (routes FROM, overview NAME, etc.) - Increase endpoints ROUTE column width - Remove redundant SPACE_CELL buffer fill (Cell.EMPTY already uses space) - Rendering artifacts fix: requires updated TamboUI 0.2.0-SNAPSHOT with refactored AbstractBackend (cursor optimization removed) Co-Authored-By: Claude Opus 4.6 --- .../core/commands/tui/CamelCatalogTui.java | 2 + .../jbang/core/commands/tui/CamelMonitor.java | 245 +++++++++++++----- 2 files changed, 177 insertions(+), 70 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java index 5be691bc8b061..40b9c29224673 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java @@ -459,6 +459,7 @@ private void renderComponentList(Frame frame, Rect area) { .rows(rows) .widths(Constraint.fill()) .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) .block(Block.builder() .borderType(BorderType.ROUNDED) .borderStyle(borderStyle) @@ -517,6 +518,7 @@ private void renderOptionsTable(Frame frame, Rect area) { Constraint.length(12), Constraint.fill()) .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) .block(Block.builder() .borderType(BorderType.ROUNDED) .borderStyle(borderStyle) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index e903c9e8afdc2..eb25f9e59853e 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -132,8 +132,8 @@ public class CamelMonitor extends CamelCommand { // Log state private final List logLines = new ArrayList<>(); - private final List filteredLogLines = new ArrayList<>(); - private int logScroll; + private final List filteredLogEntries = new ArrayList<>(); + private final TableState logTableState = new TableState(); private boolean logFollowMode = true; private boolean showLogTrace = true; private boolean showLogDebug = true; @@ -241,13 +241,17 @@ private boolean handleEvent(Event event, TuiRunner runner) { if (ke.isKey(KeyCode.PAGE_UP)) { if (tab == TAB_LOG) { logFollowMode = false; - logScroll = Math.max(0, logScroll - 20); + for (int i = 0; i < 20; i++) { + logTableState.selectPrevious(); + } } return true; } if (ke.isKey(KeyCode.PAGE_DOWN)) { if (tab == TAB_LOG) { - logScroll += 20; + for (int i = 0; i < 20; i++) { + logTableState.selectNext(filteredLogEntries.size()); + } } return true; } @@ -307,7 +311,7 @@ private boolean handleEvent(Event event, TuiRunner runner) { } if (ke.isChar('g')) { logFollowMode = false; - logScroll = 0; + logTableState.select(0); return true; } if (ke.isChar('G')) { @@ -379,7 +383,7 @@ private void navigateUp() { case TAB_ENDPOINTS -> endpointTableState.selectPrevious(); case TAB_LOG -> { logFollowMode = false; - logScroll = Math.max(0, logScroll - 1); + logTableState.selectPrevious(); } case TAB_TRACE -> { traceFollowMode = false; @@ -404,7 +408,7 @@ private void navigateDown() { IntegrationInfo info = findSelectedIntegration(); endpointTableState.selectNext(info != null ? info.endpoints.size() : 0); } - case TAB_LOG -> logScroll++; + case TAB_LOG -> logTableState.selectNext(filteredLogEntries.size()); case TAB_TRACE -> { List current = traces.get(); traceTableState.selectNext(current.size()); @@ -472,8 +476,6 @@ private void renderTabs(Frame frame, Rect area) { } private void renderContent(Frame frame, Rect area) { - // Clear the content area to prevent artifacts when switching tabs - frame.buffer().clear(area); switch (tabsState.selected()) { case TAB_OVERVIEW -> renderOverview(frame, area); case TAB_ROUTES -> renderRoutes(frame, area); @@ -511,7 +513,7 @@ private void renderOverview(Frame frame, Rect area) { rows.add(Row.from( Cell.from(Span.styled(info.pid, dimStyle)), - Cell.from(Span.styled(truncate(info.name, 25), dimStyle)), + Cell.from(Span.styled(info.name != null ? info.name : "", dimStyle)), Cell.from(Span.styled(info.platform != null ? info.platform : "", dimStyle)), Cell.from(Span.styled("\u2716 Stopped", Style.create().fg(Color.RED).dim())), Cell.from(Span.styled(info.ago != null ? info.ago : "", dimStyle)), @@ -527,7 +529,7 @@ private void renderOverview(Frame frame, Rect area) { rows.add(Row.from( Cell.from(info.pid), - Cell.from(Span.styled(truncate(info.name, 25), Style.create().fg(Color.CYAN))), + Cell.from(Span.styled(info.name != null ? info.name : "", Style.create().fg(Color.CYAN))), Cell.from(info.platform != null ? info.platform : ""), Cell.from(Span.styled(extractState(info.state), statusStyle)), Cell.from(info.ago != null ? info.ago : ""), @@ -560,6 +562,7 @@ private void renderOverview(Frame frame, Rect area) { Constraint.length(15), Constraint.length(12)) .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) .block(Block.builder().borderType(BorderType.ROUNDED).title(" Integrations ").build()) .build(); @@ -632,8 +635,8 @@ private void renderRoutes(Frame frame, Rect area) { : Style.create(); routeRows.add(Row.from( - Cell.from(Span.styled(truncate(route.routeId, 12), Style.create().fg(Color.CYAN))), - Cell.from(truncate(route.from, 30)), + Cell.from(Span.styled(route.routeId != null ? route.routeId : "", Style.create().fg(Color.CYAN))), + Cell.from(route.from != null ? route.from : ""), Cell.from(Span.styled(route.state, stateStyle)), Cell.from(route.uptime != null ? route.uptime : ""), Cell.from(route.throughput != null ? route.throughput : ""), @@ -663,6 +666,7 @@ private void renderRoutes(Frame frame, Rect area) { Constraint.length(8), Constraint.length(12)) .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) .block(Block.builder().borderType(BorderType.ROUNDED) .title(" Routes [" + info.name + "] sort:" + routeSort + " ").build()) .build(); @@ -794,11 +798,11 @@ private void renderHealth(Frame frame, Rect area) { } rows.add(Row.from( - Cell.from(Span.styled(truncate(hc.group != null ? hc.group : "", 12), Style.create().dim())), - Cell.from(Span.styled(truncate(hc.name, 25), Style.create().fg(Color.CYAN))), + Cell.from(Span.styled(hc.group != null ? hc.group : "", Style.create().dim())), + Cell.from(Span.styled(hc.name != null ? hc.name : "", Style.create().fg(Color.CYAN))), Cell.from(Span.styled(icon + hc.state, stateStyle)), Cell.from(rate), - Cell.from(hc.message != null ? truncate(hc.message, 50) : ""))); + Cell.from(hc.message != null ? hc.message : ""))); } if (rows.isEmpty()) { @@ -830,6 +834,7 @@ private void renderHealth(Frame frame, Rect area) { Constraint.length(6), Constraint.fill()) .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) .block(Block.builder().borderType(BorderType.ROUNDED).title(title).build()) .build(); @@ -884,7 +889,7 @@ private void renderEndpoints(Frame frame, Rect area) { rows.add(Row.from( Cell.from(Span.styled(ep.component, Style.create().fg(Color.CYAN))), Cell.from(Span.styled(arrow + ep.direction, dirStyle)), - Cell.from(truncate(ep.uri, 60)), + Cell.from(ep.uri != null ? ep.uri : ""), Cell.from(ep.routeId != null ? ep.routeId : ""))); } @@ -907,8 +912,9 @@ private void renderEndpoints(Frame frame, Rect area) { Constraint.length(15), Constraint.length(8), Constraint.fill(), - Constraint.length(12)) + Constraint.length(20)) .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) .block(Block.builder().borderType(BorderType.ROUNDED) .title(" Endpoints [" + info.name + "] ").build()) .build(); @@ -929,40 +935,90 @@ private void renderLog(Frame frame, Rect area) { readLogFile(info.pid); applyLogFilters(); - String levelTitle = buildLevelFilterTitle(); - Block logBlock = Block.builder().borderType(BorderType.ROUNDED) - .title(" Log [" + info.name + "] " + levelTitle) - .build(); + // Auto-follow: select last entry + if (logFollowMode && !filteredLogEntries.isEmpty()) { + logTableState.select(filteredLogEntries.size() - 1); + } - int innerHeight = Math.max(1, area.height() - 2); // account for border - int totalLines = filteredLogLines.size(); + // Split: log table (60%) + detail (40%) + List chunks = Layout.vertical() + .constraints(Constraint.percentage(60), Constraint.fill()) + .split(area); - int startLine; - if (logFollowMode) { - startLine = Math.max(0, totalLines - innerHeight); - logScroll = startLine; - } else { - logScroll = Math.max(0, Math.min(logScroll, Math.max(0, totalLines - innerHeight))); - startLine = logScroll; - } + // Log table + List rows = new ArrayList<>(); + for (LogEntry entry : filteredLogEntries) { + Style levelStyle = switch (entry.level) { + case "ERROR", "FATAL" -> Style.create().fg(Color.RED); + case "WARN" -> Style.create().fg(Color.YELLOW); + case "DEBUG", "TRACE" -> Style.create().dim(); + default -> Style.create(); + }; - List visibleLines = new ArrayList<>(); - for (int i = startLine; i < Math.min(startLine + innerHeight, totalLines); i++) { - visibleLines.add(colorizeLogLine(filteredLogLines.get(i))); + rows.add(Row.from( + Cell.from(Span.styled(entry.time, Style.create().dim())), + Cell.from(Span.styled(entry.level, levelStyle)), + Cell.from(Span.styled(entry.logger != null ? entry.logger : "", Style.create().fg(Color.CYAN))), + Cell.from(Span.styled(entry.message, levelStyle)))); } - // Fill remaining space - while (visibleLines.size() < innerHeight) { - visibleLines.add(Line.from(Span.raw(""))); + String levelTitle = buildLevelFilterTitle(); + Table logTable = Table.builder() + .rows(rows) + .header(Row.from( + Cell.from(Span.styled("TIME", Style.create().bold())), + Cell.from(Span.styled("LEVEL", Style.create().bold())), + Cell.from(Span.styled("LOGGER", Style.create().bold())), + Cell.from(Span.styled("MESSAGE", Style.create().bold())))) + .widths( + Constraint.length(12), + Constraint.length(6), + Constraint.length(20), + Constraint.fill()) + .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" Log [" + info.name + "] " + levelTitle).build()) + .build(); + + frame.renderStatefulWidget(logTable, chunks.get(0), logTableState); + + // Detail panel for selected log entry + renderLogDetail(frame, chunks.get(1)); + } + + private void renderLogDetail(Frame frame, Rect area) { + Integer sel = logTableState.selected(); + if (sel == null || sel < 0 || sel >= filteredLogEntries.size()) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from( + Span.styled(" Select a log entry", Style.create().dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" Detail ").build()) + .build(), + area); + return; } - Paragraph logParagraph = Paragraph.builder() - .text(Text.from(visibleLines)) - .overflow(Overflow.CLIP) - .block(logBlock) - .build(); + LogEntry entry = filteredLogEntries.get(sel); + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from(Span.styled(entry.raw, colorStyleForLevel(entry.level))))) + .overflow(Overflow.WRAP_WORD) + .block(Block.builder().borderType(BorderType.ROUNDED) + .title(" " + entry.time + " " + entry.level + " ").build()) + .build(), + area); + } - frame.renderWidget(logParagraph, area); + private Style colorStyleForLevel(String level) { + return switch (level) { + case "ERROR", "FATAL" -> Style.create().fg(Color.RED); + case "WARN" -> Style.create().fg(Color.YELLOW); + case "DEBUG", "TRACE" -> Style.create().dim(); + default -> Style.create(); + }; } private String buildLevelFilterTitle() { @@ -978,17 +1034,6 @@ private String buildLevelFilterTitle() { return sb.toString(); } - private Line colorizeLogLine(String line) { - if (line.contains(" ERROR ") || line.contains(" FATAL ")) { - return Line.from(Span.styled(line, Style.create().fg(Color.RED))); - } else if (line.contains(" WARN ")) { - return Line.from(Span.styled(line, Style.create().fg(Color.YELLOW))); - } else if (line.contains(" DEBUG ") || line.contains(" TRACE ")) { - return Line.from(Span.styled(line, Style.create().dim())); - } - return Line.from(Span.raw(line)); - } - private void readLogFile(String pid) { logLines.clear(); Path logFile = CommandLineHelper.getCamelDir().resolve(pid + ".log"); @@ -1021,27 +1066,78 @@ private void readLogFile(String pid) { } private void applyLogFilters() { - filteredLogLines.clear(); + filteredLogEntries.clear(); for (String line : logLines) { - if (!matchesLogLevelFilter(line)) { + LogEntry entry = parseLogLine(line); + if (!matchesLogLevelFilter(entry.level)) { continue; } - filteredLogLines.add(line); + filteredLogEntries.add(entry); + } + } + + private static LogEntry parseLogLine(String line) { + LogEntry entry = new LogEntry(); + entry.raw = line; + // Typical format: "2026-03-22 22:45:47.768 INFO 92447 --- [thread] logger : message" + // Try to parse structured fields + try { + // Extract time (first 12 chars of timestamp, skip date) + if (line.length() > 24 && line.charAt(10) == ' ') { + entry.time = line.substring(11, 23); // HH:mm:ss.SSS + String rest = line.substring(23).trim(); + // Extract level + int spaceIdx = rest.indexOf(' '); + if (spaceIdx > 0) { + entry.level = rest.substring(0, spaceIdx).trim(); + rest = rest.substring(spaceIdx).trim(); + } + // Skip PID and "---" + int dashIdx = rest.indexOf("---"); + if (dashIdx >= 0) { + rest = rest.substring(dashIdx + 3).trim(); + } + // Extract thread [...] + if (rest.startsWith("[")) { + int closeBracket = rest.indexOf(']'); + if (closeBracket > 0) { + rest = rest.substring(closeBracket + 1).trim(); + } + } + // Extract logger and message (logger : message) + int colonIdx = rest.indexOf(" : "); + if (colonIdx > 0) { + entry.logger = rest.substring(0, colonIdx).trim(); + // Shorten logger to simple name + int lastDot = entry.logger.lastIndexOf('.'); + if (lastDot > 0) { + entry.logger = entry.logger.substring(lastDot + 1); + } + entry.message = rest.substring(colonIdx + 3).trim(); + } else { + entry.message = rest; + } + } else { + entry.time = ""; + entry.level = "INFO"; + entry.message = line; + } + } catch (Exception e) { + entry.time = ""; + entry.level = "INFO"; + entry.message = line; } + return entry; } - private boolean matchesLogLevelFilter(String line) { - if (line.contains(" ERROR ") || line.contains(" FATAL ")) { - return showLogError; - } else if (line.contains(" WARN ")) { - return showLogWarn; - } else if (line.contains(" DEBUG ")) { - return showLogDebug; - } else if (line.contains(" TRACE ")) { - return showLogTrace; - } - // Lines without a recognized level are treated as INFO - return showLogInfo; + private boolean matchesLogLevelFilter(String level) { + return switch (level) { + case "ERROR", "FATAL" -> showLogError; + case "WARN" -> showLogWarn; + case "DEBUG" -> showLogDebug; + case "TRACE" -> showLogTrace; + default -> showLogInfo; + }; } // ---- Tab 6: Trace ---- @@ -1114,6 +1210,7 @@ private void renderTrace(Frame frame, Rect area) { Constraint.length(10), Constraint.fill()) .highlightStyle(Style.create().fg(Color.WHITE).bold().onBlue()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) .block(Block.builder().borderType(BorderType.ROUNDED).title(traceTitle).build()) .build(); @@ -1830,6 +1927,14 @@ static class TraceEntry { Map exchangeVariables; } + static class LogEntry { + String raw; + String time = ""; + String level = "INFO"; + String logger; + String message = ""; + } + record VanishingInfo(IntegrationInfo info, long startTime) { } } From a3bdbefa2255b49e1576c9f704c585645e18d067 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 23 Mar 2026 21:29:59 +0100 Subject: [PATCH 08/10] CAMEL-23226: Fix log parser for ISO-8601 timestamp format - Use regex-based parser that handles both old format (space separator) and Spring Boot 3.x/Camel 4.x ISO-8601 format (T separator + timezone) - Strip ANSI color codes from log lines before parsing Co-Authored-By: Claude Opus 4.6 --- .../jbang/core/commands/tui/CamelMonitor.java | 60 ++++++++----------- 1 file changed, 24 insertions(+), 36 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index eb25f9e59853e..7c464476ab7a6 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -1055,7 +1055,7 @@ private void readLogFile(String pid) { String[] lines = content.split("\n", -1); int start = Math.max(0, lines.length - MAX_LOG_LINES); for (int i = start; i < lines.length; i++) { - String line = lines[i]; + String line = lines[i].replaceAll("\u001B\\[[;\\d]*m", ""); if (!line.isEmpty()) { logLines.add(line); } @@ -1076,47 +1076,35 @@ private void applyLogFilters() { } } + // Regex for Spring Boot / Camel log format: + // "2026-03-23T21:24:11.705+01:00 WARN 11283 --- [thread] logger : message" + // "2026-03-23 21:24:11.705 WARN 11283 --- [thread] logger : message" + private static final java.util.regex.Pattern LOG_PATTERN = java.util.regex.Pattern.compile( + "^(\\d{4}-\\d{2}-\\d{2})[T ](\\d{2}:\\d{2}:\\d{2}\\.\\d+)\\S*\\s+" + + "(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\\s+" + + "\\d+\\s+---\\s+" + + "\\[([^]]*)]\\s+" + + "(\\S+)\\s*:\\s*(.*)$"); + private static LogEntry parseLogLine(String line) { LogEntry entry = new LogEntry(); entry.raw = line; - // Typical format: "2026-03-22 22:45:47.768 INFO 92447 --- [thread] logger : message" - // Try to parse structured fields try { - // Extract time (first 12 chars of timestamp, skip date) - if (line.length() > 24 && line.charAt(10) == ' ') { - entry.time = line.substring(11, 23); // HH:mm:ss.SSS - String rest = line.substring(23).trim(); - // Extract level - int spaceIdx = rest.indexOf(' '); - if (spaceIdx > 0) { - entry.level = rest.substring(0, spaceIdx).trim(); - rest = rest.substring(spaceIdx).trim(); - } - // Skip PID and "---" - int dashIdx = rest.indexOf("---"); - if (dashIdx >= 0) { - rest = rest.substring(dashIdx + 3).trim(); - } - // Extract thread [...] - if (rest.startsWith("[")) { - int closeBracket = rest.indexOf(']'); - if (closeBracket > 0) { - rest = rest.substring(closeBracket + 1).trim(); - } + java.util.regex.Matcher m = LOG_PATTERN.matcher(line); + if (m.matches()) { + entry.time = m.group(2); // HH:mm:ss.SSS... + // Truncate time to 12 chars (HH:mm:ss.SSS) + if (entry.time.length() > 12) { + entry.time = entry.time.substring(0, 12); } - // Extract logger and message (logger : message) - int colonIdx = rest.indexOf(" : "); - if (colonIdx > 0) { - entry.logger = rest.substring(0, colonIdx).trim(); - // Shorten logger to simple name - int lastDot = entry.logger.lastIndexOf('.'); - if (lastDot > 0) { - entry.logger = entry.logger.substring(lastDot + 1); - } - entry.message = rest.substring(colonIdx + 3).trim(); - } else { - entry.message = rest; + entry.level = m.group(3); + entry.logger = m.group(5); + // Shorten logger to simple name + int lastDot = entry.logger.lastIndexOf('.'); + if (lastDot > 0) { + entry.logger = entry.logger.substring(lastDot + 1); } + entry.message = m.group(6); } else { entry.time = ""; entry.level = "INFO"; From b80e2eaf43e166ec43c7571ea4a1a43df48fc50f Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 23 Mar 2026 22:06:56 +0100 Subject: [PATCH 09/10] CAMEL-23226: Fix trace parser, NPE guards, and code review improvements - Fix trace file parser to handle actual JSON format (traces array wrapper, numeric uid/timestamp, done/failed booleans instead of status string, nested body/headers/properties structures) - Add null guards for switch expressions (ep.direction, entry.status) - Use getIntegerOrDefault/getLongOrDefault to prevent NPE from null unboxing - Add null checks for route.state, ep.component, proc.id, proc.processor - Move log file reading from render to refreshData tick for better performance - Make traceFilePositions thread-safe with ConcurrentHashMap - Remove dead truncate method in CamelCatalogTui - Revert unneeded PluginHelper.java changes Co-Authored-By: Claude Opus 4.6 --- .../dsl/jbang/core/common/PluginHelper.java | 10 +- .../core/commands/tui/CamelCatalogTui.java | 4 - .../jbang/core/commands/tui/CamelMonitor.java | 165 +++++++++++++----- 3 files changed, 128 insertions(+), 51 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java index e84542c608bea..3263d7138c2ad 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java @@ -119,18 +119,18 @@ public static void addPlugins(CommandLine commandLine, CamelJBangMain main, Stri // Fall back to JSON configuration for additional or missing plugins Map plugins = getActivePlugins(main, repos); - for (Map.Entry entry : plugins.entrySet()) { + for (Map.Entry plugin : plugins.entrySet()) { // only load the plugin if the command-line is calling this plugin - if (target != null && !"shell".equals(target) && !target.equals(entry.getKey())) { + if (target != null && !"shell".equals(target) && !target.equals(plugin.getKey())) { continue; } // Skip if this plugin was already loaded from embedded plugins - if (foundEmbeddedPlugins && commandLine.getSubcommands().containsKey(entry.getKey())) { + if (foundEmbeddedPlugins && commandLine.getSubcommands().containsKey(plugin.getKey())) { continue; } - entry.getValue().customize(commandLine, main); + plugin.getValue().customize(commandLine, main); } } @@ -428,12 +428,12 @@ private static boolean loadPluginFromService( String command = extractCommandFromPlugin(pluginClass, pluginName); // Only load the plugin if the command-line is calling this plugin or if target is null (shell mode) - CamelJBangPlugin annotation = pluginClass.getAnnotation(CamelJBangPlugin.class); if (target != null && !"shell".equals(target) && !target.equals(command)) { return false; } // Check version compatibility if needed + CamelJBangPlugin annotation = pluginClass.getAnnotation(CamelJBangPlugin.class); if (annotation != null) { CamelCatalog catalog = new DefaultCamelCatalog(); String version = catalog.getCatalogVersion(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java index 40b9c29224673..19efb3c0dd9d0 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java @@ -729,10 +729,6 @@ private static void wrapText(List lines, String text, int width, Style sty } } - private static String truncate(String s, int max) { - return TuiHelper.truncate(s, max); - } - // ---- Data Classes ---- static class ComponentInfo { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index 7c464476ab7a6..db9723445bdaa 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -144,7 +144,7 @@ public class CamelMonitor extends CamelCommand { // Trace state private final AtomicReference> traces = new AtomicReference<>(Collections.emptyList()); private final TableState traceTableState = new TableState(); - private final Map traceFilePositions = new LinkedHashMap<>(); + private final Map traceFilePositions = new ConcurrentHashMap<>(); private boolean showTraceHeaders = true; private boolean showTraceBody = true; private boolean traceFollowMode = true; @@ -637,7 +637,7 @@ private void renderRoutes(Frame frame, Rect area) { routeRows.add(Row.from( Cell.from(Span.styled(route.routeId != null ? route.routeId : "", Style.create().fg(Color.CYAN))), Cell.from(route.from != null ? route.from : ""), - Cell.from(Span.styled(route.state, stateStyle)), + Cell.from(Span.styled(route.state != null ? route.state : "", stateStyle)), Cell.from(route.uptime != null ? route.uptime : ""), Cell.from(route.throughput != null ? route.throughput : ""), Cell.from(String.valueOf(route.total)), @@ -722,8 +722,8 @@ private void renderProcessors(Frame frame, Rect area, RouteInfo route) { Style nameStyle = proc.failed > 0 ? Style.create().fg(Color.RED) : Style.create().fg(Color.CYAN); rows.add(Row.from( - Cell.from(Span.styled(indent + proc.id, nameStyle)), - Cell.from(proc.processor), + Cell.from(Span.styled(indent + (proc.id != null ? proc.id : ""), nameStyle)), + Cell.from(proc.processor != null ? proc.processor : ""), Cell.from(String.valueOf(proc.total)), Cell.from(proc.failed > 0 ? Span.styled(String.valueOf(proc.failed), Style.create().fg(Color.RED)) @@ -875,20 +875,21 @@ private void renderEndpoints(Frame frame, Rect area) { List rows = new ArrayList<>(); for (EndpointInfo ep : info.endpoints) { - Style dirStyle = switch (ep.direction) { + String dir = ep.direction != null ? ep.direction : ""; + Style dirStyle = switch (dir) { case "in" -> Style.create().fg(Color.GREEN); case "out" -> Style.create().fg(Color.BLUE); default -> Style.create().fg(Color.YELLOW); }; - String arrow = switch (ep.direction) { + String arrow = switch (dir) { case "in" -> "\u2192 "; case "out" -> "\u2190 "; default -> "\u2194 "; }; rows.add(Row.from( - Cell.from(Span.styled(ep.component, Style.create().fg(Color.CYAN))), - Cell.from(Span.styled(arrow + ep.direction, dirStyle)), + Cell.from(Span.styled(ep.component != null ? ep.component : "", Style.create().fg(Color.CYAN))), + Cell.from(Span.styled(arrow + dir, dirStyle)), Cell.from(ep.uri != null ? ep.uri : ""), Cell.from(ep.routeId != null ? ep.routeId : ""))); } @@ -931,9 +932,7 @@ private void renderLog(Frame frame, Rect area) { return; } - // Read and filter log lines - readLogFile(info.pid); - applyLogFilters(); + // Log data is refreshed in refreshData() tick handler // Auto-follow: select last entry if (logFollowMode && !filteredLogEntries.isEmpty()) { @@ -1152,10 +1151,11 @@ private void renderTrace(Frame frame, Rect area) { // Trace list List rows = new ArrayList<>(); for (TraceEntry entry : current) { - Style statusStyle = switch (entry.status) { - case "Created" -> Style.create().fg(Color.CYAN); - case "Routing", "Processing" -> Style.create().fg(Color.YELLOW); - case "Sent" -> Style.create().fg(Color.GREEN); + String status = entry.status != null ? entry.status : ""; + Style statusStyle = switch (status) { + case "Done" -> Style.create().fg(Color.GREEN); + case "Failed" -> Style.create().fg(Color.RED); + case "Processing" -> Style.create().fg(Color.YELLOW); default -> Style.create().fg(Color.WHITE); }; @@ -1168,7 +1168,7 @@ private void renderTrace(Frame frame, Rect area) { entry.routeId != null ? truncate(entry.routeId, 15) : "", Style.create().fg(Color.CYAN))), Cell.from(entry.nodeId != null ? truncate(entry.nodeId, 15) : ""), - Cell.from(Span.styled(entry.status != null ? entry.status : "", statusStyle)), + Cell.from(Span.styled(status, statusStyle)), Cell.from(entry.elapsed + "ms"), Cell.from(bodyPreview))); } @@ -1241,7 +1241,8 @@ private void renderTraceDetail(Frame frame, Rect area, List current) Span.styled(" Route: ", Style.create().fg(Color.YELLOW).bold()), Span.raw(entry.routeId != null ? entry.routeId : ""), Span.raw(" Node: "), - Span.raw(entry.nodeId != null ? entry.nodeId : ""))); + Span.raw(entry.nodeId != null ? entry.nodeId : ""), + Span.raw(entry.nodeLabel != null ? " (" + entry.nodeLabel + ")" : ""))); lines.add(Line.from( Span.styled(" Status: ", Style.create().fg(Color.YELLOW).bold()), Span.raw(entry.status != null ? entry.status : ""), @@ -1450,6 +1451,13 @@ private void refreshData() { data.set(infos); + // Refresh log data for the selected integration + IntegrationInfo selected = findSelectedIntegration(); + if (selected != null) { + readLogFile(selected.pid); + applyLogFilters(); + } + // Refresh trace data refreshTraceData(pids); } catch (Exception e) { @@ -1552,7 +1560,7 @@ private void readTraceFile(String pid, List allTraces) { traceFilePositions.put(pid, length); - // Each line is a separate JSON object + // Each line is a JSON object: {"enabled":true,"traces":[...]} String[] lines = content.split("\n"); for (String line : lines) { line = line.trim(); @@ -1561,9 +1569,22 @@ private void readTraceFile(String pid, List allTraces) { } try { JsonObject json = (JsonObject) Jsoner.deserialize(line); - TraceEntry entry = parseTraceEntry(json, pid); - if (entry != null) { - allTraces.add(entry); + Object tracesArray = json.get("traces"); + if (tracesArray instanceof List traceList) { + for (Object traceObj : traceList) { + if (traceObj instanceof JsonObject traceJson) { + TraceEntry entry = parseTraceEntry(traceJson, pid); + if (entry != null) { + allTraces.add(entry); + } + } + } + } else { + // Fallback: try parsing the line itself as a trace entry + TraceEntry entry = parseTraceEntry(json, pid); + if (entry != null) { + allTraces.add(entry); + } } } catch (Exception e) { // skip malformed lines @@ -1578,13 +1599,38 @@ private void readTraceFile(String pid, List allTraces) { private TraceEntry parseTraceEntry(JsonObject json, String pid) { TraceEntry entry = new TraceEntry(); entry.pid = pid; - entry.uid = json.getString("uid"); + entry.uid = stringValue(json.get("uid")); entry.exchangeId = json.getString("exchangeId"); - entry.timestamp = json.getString("timestamp"); entry.routeId = json.getString("routeId"); entry.nodeId = json.getString("nodeId"); entry.location = json.getString("location"); - entry.status = json.getString("status"); + entry.nodeLabel = json.getString("nodeLabel"); + + // timestamp is epoch millis (number) + Object tsObj = json.get("timestamp"); + if (tsObj instanceof Number n) { + long epochMs = n.longValue(); + entry.timestamp = java.time.Instant.ofEpochMilli(epochMs) + .atZone(java.time.ZoneId.systemDefault()) + .toLocalTime().toString(); + // Truncate to HH:mm:ss.SSS + if (entry.timestamp.length() > 12) { + entry.timestamp = entry.timestamp.substring(0, 12); + } + } else if (tsObj != null) { + entry.timestamp = tsObj.toString(); + } + + // Derive status from done/failed booleans + Boolean done = (Boolean) json.get("done"); + Boolean failed = (Boolean) json.get("failed"); + if (Boolean.TRUE.equals(failed)) { + entry.status = "Failed"; + } else if (Boolean.TRUE.equals(done)) { + entry.status = "Done"; + } else { + entry.status = "Processing"; + } Object elapsedObj = json.get("elapsed"); if (elapsedObj instanceof Number n) { @@ -1598,32 +1644,62 @@ private TraceEntry parseTraceEntry(JsonObject json, String pid) { } // Parse message object - JsonObject message = (JsonObject) json.get("message"); - if (message != null) { - // Headers + Object msgObj = json.get("message"); + if (msgObj instanceof JsonObject message) { + // Headers: can be a list of {key, type, value} or a map Object headersObj = message.get("headers"); - if (headersObj instanceof Map) { + if (headersObj instanceof List headerList) { + entry.headers = new LinkedHashMap<>(); + for (Object h : headerList) { + if (h instanceof JsonObject hObj) { + entry.headers.put( + String.valueOf(hObj.get("key")), + hObj.get("value")); + } + } + } else if (headersObj instanceof Map) { entry.headers = new LinkedHashMap<>((Map) headersObj); } - // Body + // Body: can be {type, value} or a plain string Object bodyObj = message.get("body"); - if (bodyObj != null) { + if (bodyObj instanceof JsonObject bodyJson) { + Object val = bodyJson.get("value"); + entry.body = val != null ? val.toString() : bodyJson.toString(); + } else if (bodyObj != null) { entry.body = bodyObj.toString(); - // Create a preview (first line, truncated) - String preview = entry.body.replace("\n", " ").replace("\r", ""); - entry.bodyPreview = preview; + } + if (entry.body != null) { + entry.bodyPreview = entry.body.replace("\n", " ").replace("\r", ""); } - // Exchange properties + // Exchange properties: can be a list of {key, type, value} or a map Object propsObj = message.get("exchangeProperties"); - if (propsObj instanceof Map) { + if (propsObj instanceof List propList) { + entry.exchangeProperties = new LinkedHashMap<>(); + for (Object p : propList) { + if (p instanceof JsonObject pObj) { + entry.exchangeProperties.put( + String.valueOf(pObj.get("key")), + pObj.get("value")); + } + } + } else if (propsObj instanceof Map) { entry.exchangeProperties = new LinkedHashMap<>((Map) propsObj); } - // Exchange variables + // Exchange variables: can be a list of {key, type, value} or a map Object varsObj = message.get("exchangeVariables"); - if (varsObj instanceof Map) { + if (varsObj instanceof List varList) { + entry.exchangeVariables = new LinkedHashMap<>(); + for (Object v : varList) { + if (v instanceof JsonObject vObj) { + entry.exchangeVariables.put( + String.valueOf(vObj.get("key")), + vObj.get("value")); + } + } + } else if (varsObj instanceof Map) { entry.exchangeVariables = new LinkedHashMap<>((Map) varsObj); } } @@ -1631,6 +1707,10 @@ private TraceEntry parseTraceEntry(JsonObject json, String pid) { return entry; } + private static String stringValue(Object obj) { + return obj != null ? obj.toString() : null; + } + // ---- Integration Parsing ---- @SuppressWarnings("unchecked") @@ -1648,7 +1728,7 @@ private IntegrationInfo parseIntegration(ProcessHandle ph, JsonObject root) { info.pid = Long.toString(ph.pid()); info.uptime = extractSince(ph); info.ago = TimeUtils.printSince(info.uptime); - info.state = context.getInteger("phase"); + info.state = context.getIntegerOrDefault("phase", 0); JsonObject runtime = (JsonObject) root.get("runtime"); info.platform = runtime != null ? runtime.getString("platform") : null; @@ -1664,14 +1744,14 @@ private IntegrationInfo parseIntegration(ProcessHandle ph, JsonObject root) { JsonObject mem = (JsonObject) root.get("memory"); if (mem != null) { - info.heapMemUsed = mem.getLong("heapMemoryUsed"); - info.heapMemMax = mem.getLong("heapMemoryMax"); + info.heapMemUsed = mem.getLongOrDefault("heapMemoryUsed", 0L); + info.heapMemMax = mem.getLongOrDefault("heapMemoryMax", 0L); } JsonObject threads = (JsonObject) root.get("threads"); if (threads != null) { - info.threadCount = threads.getInteger("threadCount"); - info.peakThreadCount = threads.getInteger("peakThreadCount"); + info.threadCount = threads.getIntegerOrDefault("threadCount", 0); + info.peakThreadCount = threads.getIntegerOrDefault("peakThreadCount", 0); } // Parse routes @@ -1905,6 +1985,7 @@ static class TraceEntry { String timestamp; String routeId; String nodeId; + String nodeLabel; String location; String status; long elapsed; From 52f2a7cdfc4050b33f4e98960f39a69a22f9af8e Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 23 Mar 2026 22:15:26 +0100 Subject: [PATCH 10/10] CAMEL-23226: Use TamboUI 0.1.0 release instead of SNAPSHOT Switch from 0.2.0-SNAPSHOT to the released 0.1.0 version and remove the sonatype-snapshots repository block. No 0.2.0-specific APIs are used by the TUI plugin. Co-Authored-By: Claude Opus 4.6 --- dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml index 44e7bd2a59c1c..8043dd3baa713 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml @@ -52,27 +52,18 @@ dev.tamboui tamboui-tui - 0.2.0-SNAPSHOT + 0.1.0 dev.tamboui tamboui-widgets - 0.2.0-SNAPSHOT + 0.1.0 dev.tamboui tamboui-jline3-backend - 0.2.0-SNAPSHOT + 0.1.0 - - - sonatype-snapshots - https://central.sonatype.com/repository/maven-snapshots/ - - true - - -