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..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 @@ -39,4 +39,5 @@ * First version this plugin was released. */ String firstVersion(); + } 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..8043dd3baa713 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml @@ -0,0 +1,69 @@ + + + + + 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.1.0 + + + dev.tamboui + tamboui-widgets + 0.1.0 + + + dev.tamboui + tamboui-jline3-backend + 0.1.0 + + + + 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/CamelCatalogTui.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java new file mode 100644 index 0000000000000..19efb3c0dd9d0 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java @@ -0,0 +1,759 @@ +/* + * 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_OPTIONS = 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 final StringBuilder componentFilter = new StringBuilder(); + private final StringBuilder optionFilter = new StringBuilder(); + private boolean componentFullText; + private boolean optionFullText; + private int descriptionScroll; + + // 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); + } + + @Override + public Integer doCall() throws Exception { + TuiHelper.preloadClasses(); + + 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 = new ArrayList<>(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 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); + 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 + } + } + + applyComponentFilter(); + if (!filteredComponents.isEmpty()) { + listTableState.select(0); + updateSelectedComponent(); + } + } + + 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 applyComponentFilter() { + String filter = componentFilter.toString().toLowerCase(); + if (filter.isEmpty()) { + filteredComponents = new ArrayList<>(allComponents); + } else { + filteredComponents = new ArrayList<>(); + for (ComponentInfo c : allComponents) { + 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); + } + } + } + } + if (!filteredComponents.isEmpty()) { + listTableState.select(0); + } else { + listTableState.clearSelection(); + } + updateSelectedComponent(); + } + + 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); + allOptionsUnfiltered = new ArrayList<>(); + allOptionsUnfiltered.addAll(info.endpointOptions); + allOptionsUnfiltered.addAll(info.componentOptions); + } else { + 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) { + // 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 (focus == FOCUS_OPTIONS) { + focus = FOCUS_LIST; + descriptionScroll = 0; + return true; + } + if (!componentFilter.isEmpty() || componentFullText) { + componentFilter.setLength(0); + componentFullText = false; + applyComponentFilter(); + return true; + } + runner.quit(); + return true; + } + + // 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; + } + + // 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; + } + + // 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(); + updateSelectedComponent(); + } else { + optionsTableState.selectPrevious(); + descriptionScroll = 0; + } + return true; + } + if (ke.isDown()) { + if (focus == FOCUS_LIST) { + listTableState.selectNext(filteredComponents.size()); + updateSelectedComponent(); + } else { + optionsTableState.selectNext(filteredOptions.size()); + descriptionScroll = 0; + } + return true; + } + if (ke.isKey(KeyCode.PAGE_UP)) { + descriptionScroll = Math.max(0, descriptionScroll - 5); + return true; + } + if (ke.isKey(KeyCode.PAGE_DOWN)) { + 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; + } + + // Typing filters the active panel + if (ke.code() == KeyCode.CHAR) { + if (focus == FOCUS_LIST) { + componentFilter.append(ke.character()); + applyComponentFilter(); + } else { + optionFilter.append(ke.character()); + applyOptionFilter(); + } + return true; + } + } + return false; + } + + // ---- Rendering ---- + + private void render(Frame frame) { + Rect area = frame.area(); + + // 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)); + 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) { + 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))); + + 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 renderTopPanels(Frame frame, Rect area) { + // Split horizontally: component list (30%) + options table (70%) + List chunks = Layout.horizontal() + .constraints(Constraint.percentage(30), Constraint.percentage(70)) + .split(area); + + renderComponentList(frame, chunks.get(0)); + renderOptionsTable(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(); + + String modePrefix = componentFullText ? "/" : ""; + String listTitle = componentFilter.isEmpty() && !componentFullText + ? " Components " + : " Components [" + modePrefix + componentFilter + "] "; + + Table table = Table.builder() + .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) + .title(listTitle) + .build()) + .build(); + + frame.renderStatefulWidget(table, area, listTableState); + } + + private void renderOptionsTable(Frame frame, Rect area) { + Style borderStyle = focus == FOCUS_OPTIONS + ? Style.create().fg(Color.rgb(0xF6, 0x91, 0x23)) + : Style.create(); + + 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(emptyMsg, Style.create().dim())))) + .block(Block.builder() + .borderType(BorderType.ROUNDED) + .borderStyle(borderStyle) + .title(optTitle) + .build()) + .build(), + area); + return; + } + + List rows = new ArrayList<>(); + for (OptionInfo opt : filteredOptions) { + rows.add(optionToRow(opt)); + } + + 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("KIND", Style.create().bold()))); + + Table table = Table.builder() + .rows(rows) + .header(header) + .widths( + Constraint.length(25), + Constraint.length(12), + Constraint.length(4), + 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) + .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); + + 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(Span.styled(opt.kind != null ? opt.kind : "", Style.create().dim()))); + } + + private void renderFooter(Frame frame, Rect area) { + 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; + } + } + + // ---- 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; + } +} 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 new file mode 100644 index 0000000000000..db9723445bdaa --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -0,0 +1,2009 @@ +/* + * 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.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; +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.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; + private static final int MAX_TRACES = 200; + private static final int NUM_TABS = 6; + + // 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; + 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 = "*"; + + @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<>(); + + // 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 filteredLogEntries = new ArrayList<>(); + private final TableState logTableState = new TableState(); + 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 ConcurrentHashMap<>(); + private boolean showTraceHeaders = true; + private boolean showTraceBody = true; + private boolean traceFollowMode = true; + + // Selected integration for detail views + private String selectedPid; + + private volatile long lastRefresh; + + public CamelMonitor(CamelJBangMain main) { + super(main); + } + + @Override + public Integer doCall() throws Exception { + TuiHelper.preloadClasses(); + + // 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')) { + return handleTabKey(TAB_OVERVIEW); + } + if (ke.isChar('2')) { + return handleTabKey(TAB_ROUTES); + } + if (ke.isChar('3')) { + return handleTabKey(TAB_HEALTH); + } + if (ke.isChar('4')) { + return handleTabKey(TAB_ENDPOINTS); + } + if (ke.isChar('5')) { + return handleTabKey(TAB_LOG); + } + if (ke.isChar('6')) { + return handleTabKey(TAB_TRACE); + } + + // Tab cycling + if (ke.isKey(KeyCode.TAB)) { + int next = (tabsState.selected() + 1) % NUM_TABS; + if (next != TAB_OVERVIEW) { + selectCurrentIntegration(); + } + tabsState.select(next); + return true; + } + + // Tab-specific keys + int tab = tabsState.selected(); + + // Navigation (all tabs) + if (ke.isUp()) { + navigateUp(); + return true; + } + if (ke.isDown()) { + navigateDown(); + return true; + } + if (ke.isKey(KeyCode.PAGE_UP)) { + if (tab == TAB_LOG) { + logFollowMode = false; + for (int i = 0; i < 20; i++) { + logTableState.selectPrevious(); + } + } + return true; + } + if (ke.isKey(KeyCode.PAGE_DOWN)) { + if (tab == TAB_LOG) { + for (int i = 0; i < 20; i++) { + logTableState.selectNext(filteredLogEntries.size()); + } + } + return true; + } + + // Enter to drill into selected integration + 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; + logTableState.select(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(); + if (now - lastRefresh >= refreshInterval) { + refreshData(); + } + return true; + } + 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; + } + 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 -> { + logFollowMode = false; + logTableState.selectPrevious(); + } + case TAB_TRACE -> { + traceFollowMode = false; + traceTableState.selectPrevious(); + } + } + } + + 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 ? getFilteredHealthChecks(info).size() : 0); + } + case TAB_ENDPOINTS -> { + IntegrationInfo info = findSelectedIntegration(); + endpointTableState.selectNext(info != null ? info.endpoints.size() : 0); + } + case TAB_LOG -> logTableState.selectNext(filteredLogEntries.size()); + case TAB_TRACE -> { + List current = traces.get(); + traceTableState.selectNext(current.size()); + } + } + } + + // ---- 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 + " ", + " 6 Trace" + 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) { + 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); + case TAB_TRACE -> renderTrace(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(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)), + 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(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 : ""), + 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()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) + .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; + } + + // 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)) + .split(area); + + // Routes table + List routeRows = new ArrayList<>(); + 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(route.routeId != null ? route.routeId : "", Style.create().fg(Color.CYAN))), + Cell.from(route.from != null ? route.from : ""), + 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)), + Cell.from(Span.styled(String.valueOf(route.failed), failStyle)), + 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(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(), + 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()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) + .block(Block.builder().borderType(BorderType.ROUNDED) + .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 < sortedRoutes.size()) { + RouteInfo route = sortedRoutes.get(selectedRoute); + renderProcessors(frame, chunks.get(1), route); + } else if (!sortedRoutes.isEmpty()) { + renderProcessors(frame, chunks.get(1), sortedRoutes.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 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) { + 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 != 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)) + : 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 healthChecks = getFilteredHealthChecks(info); + + List rows = new ArrayList<>(); + for (HealthCheckInfo hc : 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 "; + } + + String rate = ""; + if (hc.readiness) { + rate += "R"; + } + if (hc.liveness) { + rate += rate.isEmpty() ? "L" : "/L"; + } + + rows.add(Row.from( + 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 ? hc.message : ""))); + } + + if (rows.isEmpty()) { + rows.add(Row.from( + Cell.from(""), + 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(25), + Constraint.length(12), + 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(); + + 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)); + } + } + + 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) { + IntegrationInfo info = findSelectedIntegration(); + if (info == null) { + renderNoSelection(frame, area); + return; + } + + List rows = new ArrayList<>(); + for (EndpointInfo ep : info.endpoints) { + 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 (dir) { + case "in" -> "\u2192 "; + case "out" -> "\u2190 "; + default -> "\u2194 "; + }; + + rows.add(Row.from( + 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 : ""))); + } + + 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(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(); + + 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; + } + + // Log data is refreshed in refreshData() tick handler + + // Auto-follow: select last entry + if (logFollowMode && !filteredLogEntries.isEmpty()) { + logTableState.select(filteredLogEntries.size() - 1); + } + + // Split: log table (60%) + detail (40%) + List chunks = Layout.vertical() + .constraints(Constraint.percentage(60), Constraint.fill()) + .split(area); + + // 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(); + }; + + 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)))); + } + + 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; + } + + 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); + } + + 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() { + 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 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, 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].replaceAll("\u001B\\[[;\\d]*m", ""); + if (!line.isEmpty()) { + logLines.add(line); + } + } + } catch (IOException e) { + // ignore + } + } + + private void applyLogFilters() { + filteredLogEntries.clear(); + for (String line : logLines) { + LogEntry entry = parseLogLine(line); + if (!matchesLogLevelFilter(entry.level)) { + continue; + } + filteredLogEntries.add(entry); + } + } + + // 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; + try { + 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); + } + 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"; + entry.message = line; + } + } catch (Exception e) { + entry.time = ""; + entry.level = "INFO"; + entry.message = line; + } + return entry; + } + + 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 ---- + + 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) { + 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); + }; + + 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(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()) + .highlightSpacing(Table.HighlightSpacing.ALWAYS) + .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 : ""), + 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 : ""), + 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) { + 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; + int tab = tabsState.selected(); + if (tab == 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-6", Style.create().fg(Color.YELLOW).bold()), + Span.raw(" tabs "), + Span.styled("Refresh: " + refreshLabel, Style.create().dim())); + } 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 "), + 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("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-6", 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); + + // 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) { + // 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); + } + } + } + } + + // ---- 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 JSON object: {"enabled":true,"traces":[...]} + String[] lines = content.split("\n"); + for (String line : lines) { + line = line.trim(); + if (line.isEmpty()) { + continue; + } + try { + JsonObject json = (JsonObject) Jsoner.deserialize(line); + 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 + } + } + } catch (IOException e) { + // ignore + } + } + + @SuppressWarnings("unchecked") + private TraceEntry parseTraceEntry(JsonObject json, String pid) { + TraceEntry entry = new TraceEntry(); + entry.pid = pid; + entry.uid = stringValue(json.get("uid")); + entry.exchangeId = json.getString("exchangeId"); + entry.routeId = json.getString("routeId"); + entry.nodeId = json.getString("nodeId"); + entry.location = json.getString("location"); + 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) { + entry.elapsed = n.longValue(); + } else if (elapsedObj != null) { + try { + entry.elapsed = Long.parseLong(elapsedObj.toString()); + } catch (NumberFormatException e) { + // ignore + } + } + + // Parse message object + 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 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: can be {type, value} or a plain string + Object bodyObj = message.get("body"); + 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(); + } + if (entry.body != null) { + entry.bodyPreview = entry.body.replace("\n", " ").replace("\r", ""); + } + + // Exchange properties: can be a list of {key, type, value} or a map + Object propsObj = message.get("exchangeProperties"); + 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: can be a list of {key, type, value} or a map + Object varsObj = message.get("exchangeVariables"); + 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); + } + } + + return entry; + } + + private static String stringValue(Object obj) { + return obj != null ? obj.toString() : null; + } + + // ---- Integration Parsing ---- + + @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.getIntegerOrDefault("phase", 0); + + 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.getLongOrDefault("heapMemoryUsed", 0L); + info.heapMemMax = mem.getLongOrDefault("heapMemoryMax", 0L); + } + + JsonObject threads = (JsonObject) root.get("threads"); + if (threads != null) { + info.threadCount = threads.getIntegerOrDefault("threadCount", 0); + info.peakThreadCount = threads.getIntegerOrDefault("peakThreadCount", 0); + } + + // 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"); + 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")) { + 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) { + return TuiHelper.findPids(name, this::getStatusFile); + } + + private JsonObject loadStatus(long pid) { + return TuiHelper.loadStatus(pid, this::getStatusFile); + } + + private static long extractSince(ProcessHandle ph) { + return ph.info().startInstant().map(Instant::toEpochMilli).orElse(0L); + } + + private static String truncate(String s, int max) { + return TuiHelper.truncate(s, max); + } + + 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) { + return TuiHelper.objToLong(o); + } + + // ---- 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; + boolean readiness; + boolean liveness; + String message; + } + + static class EndpointInfo { + String uri; + String component; + String direction; + String routeId; + } + + static class TraceEntry { + String pid; + String uid; + String exchangeId; + String timestamp; + String routeId; + String nodeId; + String nodeLabel; + String location; + String status; + long elapsed; + String body; + String bodyPreview; + Map headers; + Map exchangeProperties; + Map exchangeVariables; + } + + static class LogEntry { + String raw; + String time = ""; + String level = "INFO"; + String logger; + String message = ""; + } + + 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/TuiHelper.java b/dsl/camel-jbang/camel-jbang-plugin-tui/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-plugin-tui/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; + } +} 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..1132df8923bde --- /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,32 @@ +/* + * 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") +public class TuiPlugin implements Plugin { + + @Override + public void customize(CommandLine commandLine, CamelJBangMain main) { + commandLine.addSubcommand("monitor", new CommandLine(new CamelMonitor(main))); + commandLine.addSubcommand("catalog-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/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); } 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 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