From e32ef3416e77b329e5eca33c01ab29f573f42d5f Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 29 Jun 2026 14:45:37 +0000 Subject: [PATCH 1/4] chore: extract McpFacade from CamelMonitor to reduce God class Move ~48 MCP-facing accessor methods (~650 lines) from CamelMonitor into a new McpFacade class. TuiMcpServer now depends only on McpFacade, decoupling it from the full monitor. This is step 1 of the CamelMonitor decomposition plan. Key changes: - New McpFacade.java with all MCP accessor, navigation, data, and control methods plus TAB_NAMES/MORE_TAB_NAMES constants and the PendingKey record - MonitorBridge callback interface to decouple McpFacade from CamelMonitor internals - AtomicReference for thread-safe sharing between monitor event loop and MCP facade - TuiMcpServer updated to take McpFacade instead of CamelMonitor - CamelMonitorParseKeyTest updated for moved parseKey() method - All 260 tests pass, zero behavior change Co-Authored-By: Claude Opus 4.6 --- .../jbang/core/commands/tui/CamelMonitor.java | 847 +++--------------- .../jbang/core/commands/tui/McpFacade.java | 824 +++++++++++++++++ .../jbang/core/commands/tui/TuiMcpServer.java | 160 ++-- .../tui/CamelMonitorParseKeyTest.java | 62 +- 4 files changed, 1055 insertions(+), 838 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index 2fac10ed617a8..a56f39abb52d2 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -29,7 +29,6 @@ import java.util.Comparator; import java.util.Iterator; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Queue; @@ -58,7 +57,6 @@ import dev.tamboui.tui.event.Event; import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; -import dev.tamboui.tui.event.KeyModifiers; import dev.tamboui.tui.event.MouseEvent; import dev.tamboui.tui.event.PasteEvent; import dev.tamboui.tui.event.TickEvent; @@ -78,7 +76,6 @@ 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.PathUtils; -import org.apache.camel.dsl.jbang.core.common.RuntimeHelper; import org.apache.camel.dsl.jbang.core.common.VersionHelper; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; @@ -175,11 +172,12 @@ public class CamelMonitor extends CamelCommand { private volatile long screenshotMessageTime; private volatile boolean pendingScreenshot; private boolean recording; - private TapeRecorder tapeRecorder; + private final AtomicReference tapeRecorderRef = new AtomicReference<>(); private boolean mcpInjectedKey; private TuiEventLog eventLog; private TuiMcpServer mcpServer; - private final Queue pendingKeys = new ConcurrentLinkedQueue<>(); + private McpFacade mcpFacade; + private final Queue pendingKeys = new ConcurrentLinkedQueue<>(); private final List recentKeys = new ArrayList<>(); private final CaptionOverlay captionOverlay = new CaptionOverlay(); private final DrawOverlay drawOverlay = new DrawOverlay(); @@ -202,7 +200,10 @@ public class CamelMonitor extends CamelCommand { () -> recording = !recording, () -> recording, this::toggleTapeRecording, - () -> tapeRecorder != null && tapeRecorder.isActive(), + () -> { + TapeRecorder r = tapeRecorderRef.get(); + return r != null && r.isActive(); + }, this::enableBurstMode, stoppingPids); private final AtomicBoolean refreshInProgress = new AtomicBoolean(false); @@ -316,9 +317,81 @@ public Integer doCall() throws Exception { overviewTab.selectCurrentIntegration(); eventLog = new TuiEventLog(500); + mcpFacade = new McpFacade( + ctx, data, tabsState, eventLog, + captionOverlay, drawOverlay, helpOverlay, + actionsPopup, filesBrowser, + logTab, diagramTab, historyTab, + tapeRecorderRef, pendingKeys, + new McpFacade.MonitorBridge() { + @Override + public MonitorTab activeTab() { + return CamelMonitor.this.activeTab(); + } + + @Override + public Buffer lastBuffer() { + return lastBuffer; + } + + @Override + public long renderGeneration() { + return renderGeneration; + } + + @Override + public boolean isKeystrokesVisible() { + return recording; + } + + @Override + public void handleTabKey(int tabIndex) { + CamelMonitor.this.handleTabKey(tabIndex); + } + + @Override + public void selectMoreTab(int moreIndex) { + CamelMonitor.this.selectMoreTab(moreIndex); + } + + @Override + public boolean isSwitchPopupVisible() { + return showSwitchPopup; + } + + @Override + public boolean isMorePopupVisible() { + return showMorePopup; + } + + @Override + public void renderOverviewFooter(List spans) { + CamelMonitor.this.renderOverviewFooter(spans); + } + + @Override + public void insertFKeyHints(List spans) { + CamelMonitor.this.insertFKeyHints(spans); + } + + @Override + public void sendRouteCommand(String pid, String routeId, String command) { + CamelMonitor.this.sendRouteCommand(pid, routeId, command); + } + + @Override + public void restartProcess() { + restartSelectedProcess(); + } + + @Override + public void stopProcess(boolean forceKill) { + stopSelectedProcess(forceKill); + } + }); Path mcpJsonFile = null; if (mcp) { - mcpServer = new TuiMcpServer(mcpPort, this); + mcpServer = new TuiMcpServer(mcpPort, mcpFacade); try { mcpServer.start(); actionsPopup.setMcpEnabled(true, mcpPort, mcpServer::getConnectedClient, mcpServer::getActivityLog); @@ -376,10 +449,11 @@ private boolean handleEvent(Event event, TuiRunner runner) { toggleTapeRecording(); return true; } - if (tapeRecorder != null && tapeRecorder.isActive() && !mcpInjectedKey) { + TapeRecorder tr = tapeRecorderRef.get(); + if (tr != null && tr.isActive() && !mcpInjectedKey) { String label = keyLabel(ke); if (label != null) { - tapeRecorder.recordKey(label); + tr.recordKey(label); } } if (captionOverlay.isVisible()) { @@ -458,30 +532,7 @@ private boolean handlePopupKeys(KeyEvent ke) { showMorePopup = false; Integer sel = shortcutSel >= 0 ? shortcutSel : morePopupState.selected(); if (sel != null) { - lastMoreSelection = sel; - activeMoreTab = switch (sel) { - case 0 -> beansTab; - case 1 -> browseTab; - case 2 -> circuitBreakerTab; - case 3 -> classpathTab; - case 4 -> configurationTab; - case 5 -> consumersTab; - case 6 -> dataSourceTab; - case 7 -> inflightTab; - case 8 -> memoryTab; - case 9 -> metricsTab; - case 10 -> sqlQueryTab; - case 11 -> spansTab; - case 12 -> processTab; - case 13 -> startupTab; - case 14 -> threadsTab; - default -> null; - }; - if (activeMoreTab != null) { - overviewTab.selectCurrentIntegration(); - tabsState.select(TAB_MORE); - activeMoreTab.onTabSelected(); - } + selectMoreTab(sel); } return true; } @@ -799,7 +850,7 @@ private boolean handlePasteEvent(PasteEvent pe) { private boolean handleTickEvent(TuiRunner runner) { long now = System.currentTimeMillis(); boolean keyProcessed = false; - PendingKey pk; + McpFacade.PendingKey pk; while ((pk = pendingKeys.peek()) != null && now >= pk.fireAt()) { pendingKeys.poll(); mcpInjectedKey = true; @@ -934,6 +985,34 @@ private boolean handleTabKey(int tab) { return true; } + void selectMoreTab(int index) { + morePopupState.select(index); + lastMoreSelection = index; + activeMoreTab = switch (index) { + case 0 -> beansTab; + case 1 -> browseTab; + case 2 -> circuitBreakerTab; + case 3 -> classpathTab; + case 4 -> configurationTab; + case 5 -> consumersTab; + case 6 -> dataSourceTab; + case 7 -> inflightTab; + case 8 -> memoryTab; + case 9 -> metricsTab; + case 10 -> sqlQueryTab; + case 11 -> spansTab; + case 12 -> processTab; + case 13 -> startupTab; + case 14 -> threadsTab; + default -> null; + }; + if (activeMoreTab != null) { + overviewTab.selectCurrentIntegration(); + tabsState.select(TAB_MORE); + activeMoreTab.onTabSelected(); + } + } + private List selectedPidAsList() { if (ctx.selectedPid == null) { return Collections.emptyList(); @@ -2489,46 +2568,11 @@ private static void deleteMcpJson(Path path) { } } - // ---- MCP accessor methods ---- - - private static final String[] TAB_NAMES = { - "Overview", "Log", "Diagram", "Routes", "Endpoints", - "HTTP", "Health", "Inspect", "Errors", "More" - }; - - Buffer getLastBuffer() { - return lastBuffer; - } - - long getRenderGeneration() { - return renderGeneration; - } - - boolean isKeystrokesVisible() { - return recording; - } - - TapeRecorder getTapeRecorder() { - return tapeRecorder; - } - - boolean isTapeRecording() { - return tapeRecorder != null && tapeRecorder.isActive(); - } - - void startTapeRecording(String title) { - tapeRecorder = new TapeRecorder(); - tapeRecorder.start(title); - } - - void clearTapeRecorder() { - tapeRecorder = null; - } - private void toggleTapeRecording() { - if (tapeRecorder != null && tapeRecorder.isActive()) { - String tape = tapeRecorder.stop(); - tapeRecorder = null; + TapeRecorder rec = tapeRecorderRef.get(); + if (rec != null && rec.isActive()) { + String tape = rec.stop(); + tapeRecorderRef.set(null); String timestamp = java.time.LocalDateTime.now() .format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); String filename = "camel-tui-tape-" + timestamp + ".tape"; @@ -2539,662 +2583,11 @@ private void toggleTapeRecording() { captionOverlay.showCaption("Failed to save tape: " + e.getMessage(), 5); } } else { - tapeRecorder = new TapeRecorder(); - tapeRecorder.start(null); + rec = new TapeRecorder(); + rec.start(null); + tapeRecorderRef.set(rec); captionOverlay.showCaption("Tape recording started", 3); } } - TuiEventLog getEventLog() { - return eventLog; - } - - int getActiveTabIndex() { - return tabsState.selected(); - } - - String getActiveTabName() { - int idx = tabsState.selected(); - return idx >= 0 && idx < TAB_NAMES.length ? TAB_NAMES[idx] : "Unknown"; - } - - String getSelectedPid() { - return ctx != null ? ctx.selectedPid : null; - } - - String getSelectedIntegrationName() { - if (ctx == null) { - return null; - } - IntegrationInfo info = ctx.findSelectedIntegration(); - return info != null ? info.name : null; - } - - int getIntegrationCount() { - List list = data.get(); - return (int) list.stream().filter(i -> !i.vanishing).count(); - } - - boolean isCaptionVisible() { - return captionOverlay.isCaptionVisible(); - } - - void showCaption(String text) { - captionOverlay.showCaption(text); - } - - void showCaption(String text, int durationSeconds) { - captionOverlay.showCaption(text, durationSeconds); - } - - boolean isDrawVisible() { - return drawOverlay.isVisible(); - } - - void setDrawing(List cells, int durationSeconds) { - drawOverlay.setDrawing(cells, durationSeconds); - } - - void appendDrawing(List cells) { - drawOverlay.appendDrawing(cells); - } - - void clearDrawing() { - drawOverlay.clear(); - } - - private static final String[] MORE_TAB_NAMES = { - "Beans", "Browse", "Circuit Breaker", "Classpath", "Configuration", - "Consumers", "DataSource", "Inflight", "Memory", "Metrics", "SQL Query", "Spans", "Process", "Startup", - "Threads" - }; - - String navigateToTab(String tabName) { - for (int i = 0; i < TAB_NAMES.length; i++) { - if (TAB_NAMES[i].equalsIgnoreCase(tabName)) { - handleTabKey(i); - return TAB_NAMES[i]; - } - } - // Check More submenu tabs - for (int i = 0; i < MORE_TAB_NAMES.length; i++) { - if (MORE_TAB_NAMES[i].equalsIgnoreCase(tabName)) { - morePopupState.select(i); - lastMoreSelection = i; - activeMoreTab = switch (i) { - case 0 -> beansTab; - case 1 -> browseTab; - case 2 -> circuitBreakerTab; - case 3 -> classpathTab; - case 4 -> configurationTab; - case 5 -> consumersTab; - case 6 -> dataSourceTab; - case 7 -> inflightTab; - case 8 -> memoryTab; - case 9 -> metricsTab; - case 10 -> sqlQueryTab; - case 11 -> spansTab; - case 12 -> processTab; - case 13 -> startupTab; - case 14 -> threadsTab; - default -> null; - }; - if (activeMoreTab != null) { - overviewTab.selectCurrentIntegration(); - tabsState.select(TAB_MORE); - activeMoreTab.onTabSelected(); - } - return MORE_TAB_NAMES[i]; - } - } - return null; - } - - String selectIntegration(String nameOrPid) { - List infos = data.get(); - for (IntegrationInfo info : infos) { - if (info.vanishing) { - continue; - } - if (nameOrPid.equals(info.pid) - || (info.name != null && info.name.equalsIgnoreCase(nameOrPid))) { - ctx.selectedPid = info.pid; - return info.name != null ? info.name : info.pid; - } - } - return null; - } - - List getTabNames() { - List names = new ArrayList<>(); - names.addAll(List.of(TAB_NAMES)); - names.addAll(List.of(MORE_TAB_NAMES)); - return names; - } - - List getActionLabels() { - return actionsPopup.getActionLabels(); - } - - SelectionContext getSelectionContext() { - SelectionContext popup = actionsPopup.getSelectionContext(); - if (popup != null) { - return popup; - } - MonitorTab tab = activeTab(); - return tab != null ? tab.getSelectionContext() : null; - } - - List getIntegrationNames() { - return data.get().stream() - .filter(i -> !i.vanishing) - .map(i -> i.name != null ? i.name : i.pid) - .toList(); - } - - JsonObject getReadme(String name) { - List integrations = data.get(); - IntegrationInfo target = null; - if (name != null && !name.isEmpty()) { - for (IntegrationInfo info : integrations) { - if (!info.vanishing && name.equals(info.name)) { - target = info; - break; - } - } - } else { - target = ctx != null ? ctx.findSelectedIntegration() : null; - } - if (target == null) { - return null; - } - if (target.readmeFiles == null || target.readmeFiles.isEmpty()) { - return null; - } - try { - Path outputFile = ctx.getOutputFile(target.pid); - Files.deleteIfExists(outputFile); - JsonObject action = new JsonObject(); - action.put("action", "readme"); - PathUtils.writeTextSafely(action.toJson(), ctx.getActionFile(target.pid)); - return MonitorContext.pollJsonResponse(outputFile, 5000); - } catch (Exception e) { - return null; - } - } - - JsonObject getFiles(String name, String file) { - List integrations = data.get(); - IntegrationInfo target = null; - if (name != null && !name.isEmpty()) { - for (IntegrationInfo info : integrations) { - if (!info.vanishing && name.equals(info.name)) { - target = info; - break; - } - } - } else { - target = ctx != null ? ctx.findSelectedIntegration() : null; - } - if (target == null) { - return null; - } - Path dir = FilesBrowser.resolveSourceDirectory(target); - if (dir == null || !Files.isDirectory(dir)) { - return null; - } - if (file != null && !file.isEmpty()) { - Path filePath = dir.resolve(file); - if (!Files.isRegularFile(filePath)) { - return null; - } - try { - String content = Files.readString(filePath, StandardCharsets.UTF_8); - JsonObject result = new JsonObject(); - result.put("file", file); - result.put("directory", dir.toString()); - result.put("size", FilesBrowser.formatFileSize(Files.size(filePath))); - result.put("type", FilesBrowser.fileType(filePath)); - result.put("content", content); - return result; - } catch (IOException e) { - return null; - } - } - JsonArray files = new JsonArray(); - try (var stream = Files.list(dir)) { - stream.filter(Files::isRegularFile) - .sorted((a, b) -> a.getFileName().toString().compareToIgnoreCase(b.getFileName().toString())) - .limit(99) - .forEach(p -> { - JsonObject entry = new JsonObject(); - entry.put("name", p.getFileName().toString()); - try { - entry.put("size", FilesBrowser.formatFileSize(Files.size(p))); - } catch (IOException e) { - entry.put("size", "0 B"); - } - entry.put("type", FilesBrowser.fileType(p)); - files.add(entry); - }); - } catch (IOException e) { - return null; - } - if (files.isEmpty()) { - return null; - } - JsonObject result = new JsonObject(); - result.put("directory", dir.toString()); - result.put("files", files); - result.put("totalFiles", files.size()); - return result; - } - - int injectKeys(List keys, int delayMs) { - long fireAt = System.currentTimeMillis(); - int count = 0; - for (String key : keys) { - KeyEvent ke = parseKey(key); - if (ke != null) { - pendingKeys.add(new PendingKey(ke, fireAt)); - fireAt += delayMs; - count++; - } - } - return count; - } - - static KeyEvent parseKey(String key) { - if (key == null || key.isEmpty()) { - return null; - } - - boolean ctrl = false; - boolean shift = false; - String remainder = key; - while (remainder.contains("+")) { - int plus = remainder.indexOf('+'); - String mod = remainder.substring(0, plus).trim(); - remainder = remainder.substring(plus + 1).trim(); - if (mod.equalsIgnoreCase("Ctrl")) { - ctrl = true; - } else if (mod.equalsIgnoreCase("Shift")) { - shift = true; - } - } - - KeyModifiers mods = KeyModifiers.of(ctrl, false, shift); - - KeyCode code = switch (remainder.toLowerCase(Locale.ROOT)) { - case "enter", "return" -> KeyCode.ENTER; - case "esc", "escape" -> KeyCode.ESCAPE; - case "tab" -> KeyCode.TAB; - case "backspace" -> KeyCode.BACKSPACE; - case "delete", "del" -> KeyCode.DELETE; - case "up" -> KeyCode.UP; - case "down" -> KeyCode.DOWN; - case "left" -> KeyCode.LEFT; - case "right" -> KeyCode.RIGHT; - case "home" -> KeyCode.HOME; - case "end" -> KeyCode.END; - case "pageup", "pgup" -> KeyCode.PAGE_UP; - case "pagedown", "pgdn" -> KeyCode.PAGE_DOWN; - case "f1" -> KeyCode.F1; - case "f2" -> KeyCode.F2; - case "f3" -> KeyCode.F3; - case "f4" -> KeyCode.F4; - case "f5" -> KeyCode.F5; - case "f6" -> KeyCode.F6; - case "f7" -> KeyCode.F7; - case "f8" -> KeyCode.F8; - case "f9" -> KeyCode.F9; - case "f10" -> KeyCode.F10; - case "f11" -> KeyCode.F11; - case "f12" -> KeyCode.F12; - case "space" -> null; - default -> null; - }; - - if (code != null) { - return KeyEvent.ofKey(code, mods); - } - if ("space".equalsIgnoreCase(remainder)) { - return KeyEvent.ofChar(' ', mods); - } - if (remainder.length() == 1) { - return KeyEvent.ofChar(remainder.charAt(0), mods); - } - return null; - } - - JsonObject getTableData(String tabName) { - if (tabName != null && !tabName.isBlank()) { - String prev = getActiveTabName(); - String switched = navigateToTab(tabName); - if (switched == null) { - return null; - } - } - MonitorTab tab = activeTab(); - return tab != null ? tab.getTableDataAsJson() : null; - } - - boolean executeAction(String actionName) { - return actionsPopup.executeActionByName(actionName); - } - - JsonObject getLogData(int limit, String filter, String level) { - return logTab.getLogDataAsJson(limit, filter, level); - } - - JsonObject getDiagramData() { - MonitorTab tab = activeTab(); - if (tab instanceof DiagramTab dt) { - return dt.getTableDataAsJson(); - } - return diagramTab.getTableDataAsJson(); - } - - void selectTraceExchange(String exchangeId) { - historyTab.selectTraceExchange(exchangeId); - } - - JsonObject getTopologyData() { - return diagramTab.getTopologyDataAsJson(); - } - - @SuppressWarnings("unchecked") - JsonObject getSpanData(String traceId, int limit) { - String pid = ctx.selectedPid; - if (pid == null) { - JsonObject err = new JsonObject(); - err.put("error", "No integration selected"); - return err; - } - try { - Path outputFile = ctx.getOutputFile(pid); - PathUtils.deleteFile(outputFile); - - JsonObject action = new JsonObject(); - action.put("action", "span"); - action.put("dump", "true"); - action.put("limit", String.valueOf(limit)); - Path actionFile = ctx.getActionFile(pid); - PathUtils.writeTextSafely(action.toJson(), actionFile); - - JsonObject response = MonitorContext.pollJsonResponse(outputFile, 3000); - if (response != null) { - PathUtils.deleteFile(outputFile); - Boolean enabled = response.getBoolean("enabled"); - if (enabled == null || !enabled) { - JsonObject err = new JsonObject(); - err.put("error", "OpenTelemetry not enabled (requires --observe flag)"); - return err; - } - if (traceId != null && !traceId.isBlank()) { - JsonArray all = response.getCollection("spans"); - if (all != null) { - JsonArray filtered = new JsonArray(); - for (int i = 0; i < all.size(); i++) { - JsonObject span = (JsonObject) all.get(i); - String tid = span.getString("traceId"); - if (tid != null && tid.contains(traceId)) { - filtered.add(span); - } - } - response.put("spans", filtered); - } - } - return response; - } - JsonObject err = new JsonObject(); - err.put("error", "Timeout waiting for span data"); - return err; - } catch (Exception e) { - JsonObject err = new JsonObject(); - err.put("error", e.getMessage()); - return err; - } - } - - String navigateDiagramToRoute(String routeId) { - navigateToTab("Diagram"); - if (diagramTab.selectRoute(routeId)) { - return routeId; - } - return null; - } - - String navigateDiagramToNode(String routeId, String nodeId) { - navigateToTab("Diagram"); - if (diagramTab.selectNode(routeId, nodeId)) { - return nodeId; - } - return null; - } - - JsonObject getDiagramState() { - return diagramTab.getDiagramStateAsJson(); - } - - JsonArray locateText(String search) { - Buffer buf = lastBuffer; - if (buf == null || search == null || search.isEmpty()) { - return new JsonArray(); - } - String screen = ExportRequest.export(buf).text().toString(); - String[] lines = screen.split("\n", -1); - int searchWidth = 0; - for (int i = 0; i < search.length();) { - int cp = search.codePointAt(i); - searchWidth += Math.max(1, CharWidth.of(cp)); - i += Character.charCount(cp); - } - JsonArray matches = new JsonArray(); - for (int y = 0; y < lines.length; y++) { - String line = lines[y]; - int idx = line.indexOf(search); - while (idx >= 0) { - int visualCol = 0; - for (int i = 0; i < idx;) { - int cp = line.codePointAt(i); - visualCol += Math.max(1, CharWidth.of(cp)); - i += Character.charCount(cp); - } - JsonObject match = new JsonObject(); - match.put("x", visualCol); - match.put("y", y); - match.put("width", searchWidth); - match.put("height", 1); - match.put("text", search); - matches.add(match); - idx = line.indexOf(search, idx + 1); - } - if (matches.size() >= 20) { - break; - } - } - return matches; - } - - JsonObject locateNodes(List nodeIds) { - return diagramTab.locateNodes(nodeIds); - } - - JsonArray getFooterActionsAsJson() { - List spans = new ArrayList<>(); - if (helpOverlay.isVisible()) { - helpOverlay.renderFooter(spans); - } else if (captionOverlay.isCaptionVisible()) { - captionOverlay.renderFooter(spans); - } else if (filesBrowser.isVisible()) { - filesBrowser.renderFooter(spans); - } else if (showSwitchPopup || showMorePopup) { - if (showSwitchPopup) { - hint(spans, "Up/Down", "select"); - hint(spans, "Enter", "switch"); - hint(spans, "Esc", "close"); - } else { - hint(spans, "Up/Down", "select"); - hint(spans, "Enter", "open"); - hint(spans, "Esc", "close"); - } - } else { - MonitorTab tab = activeTab(); - if (tabsState.selected() == TAB_OVERVIEW) { - renderOverviewFooter(spans); - } else if (tab != null) { - tab.renderFooter(spans); - insertFKeyHints(spans); - } - } - JsonArray actions = new JsonArray(); - for (int i = 0; i + 1 < spans.size(); i += 2) { - String key = spans.get(i).content().trim(); - String rawLabel = spans.get(i + 1).content().trim(); - // compact "show BHPV" pattern: key="show", then space, then 4 single-letter spans, then trailing space - if ("show".equals(key) && i + 6 < spans.size()) { - for (int j = 0; j < 4; j++) { - Span letter = spans.get(i + 2 + j); - String ch = letter.content(); - boolean on = ch.equals(ch.toUpperCase()); - JsonObject toggle = new JsonObject(); - toggle.put("key", ch.toLowerCase()); - String label = switch (ch.toLowerCase()) { - case "b" -> "body"; - case "h" -> "headers"; - case "p" -> "properties"; - case "v" -> "variables"; - default -> ch; - }; - toggle.put("label", label); - toggle.put("state", on ? "on" : "off"); - actions.add(toggle); - } - i += 5; // skip the 7-span group (loop adds 2, we consumed 5 more) - continue; - } - JsonObject action = new JsonObject(); - action.put("key", key); - int bracket = rawLabel.indexOf('['); - if (bracket > 0 && rawLabel.endsWith("]")) { - action.put("label", rawLabel.substring(0, bracket).trim()); - action.put("state", rawLabel.substring(bracket + 1, rawLabel.length() - 1)); - } else { - action.put("label", rawLabel); - } - actions.add(action); - } - return actions; - } - - void setLogLevel(String level) { - logTab.setLogLevel(level); - } - - boolean setTabFilter(String tabName, String filter) { - if (tabName != null) { - navigateToTab(tabName); - } - MonitorTab tab = activeTab(); - return tab != null && tab.setFilter(filter); - } - - boolean setTabInputValue(String tabName, String field, String value) { - if (tabName != null) { - navigateToTab(tabName); - } - MonitorTab tab = activeTab(); - return tab != null && tab.setInputValue(field, value); - } - - String toggleTraceDisplay(String section, Boolean enabled) { - return historyTab.toggleDisplaySection(section, enabled); - } - - JsonObject sendMessage(String endpoint, String body, String headers) { - if (ctx.selectedPid == null) { - return null; - } - long pid; - try { - pid = Long.parseLong(ctx.selectedPid); - } catch (NumberFormatException e) { - return null; - } - return RuntimeHelper.sendMessage(pid, endpoint, body, headers); - } - - JsonObject executeSql(String sql, String datasource, int maxRows, int queryTimeout) { - if (ctx.selectedPid == null) { - return null; - } - long pid; - try { - pid = Long.parseLong(ctx.selectedPid); - } catch (NumberFormatException e) { - return null; - } - return RuntimeHelper.executeSqlQuery(pid, sql, datasource, maxRows, queryTimeout); - } - - JsonObject updateRow(String table, String datasource, String pkValuesJson, String colValuesJson) { - if (ctx.selectedPid == null) { - return null; - } - long pid; - try { - pid = Long.parseLong(ctx.selectedPid); - } catch (NumberFormatException e) { - return null; - } - return RuntimeHelper.executeRowUpdate(pid, table, datasource, pkValuesJson, colValuesJson); - } - - String controlIntegration(String action) { - if (action == null || action.isBlank()) { - return "Error: action is required"; - } - if (ctx.selectedPid == null) { - return "Error: no integration selected"; - } - String name = selectedName(); - return switch (action) { - case "stop-routes", "pause" -> { - if (isInfraSelected()) { - yield "Error: cannot stop routes on infra service"; - } - sendRouteCommand(ctx.selectedPid, "*", "stop"); - yield "Routes stopped for " + name; - } - case "start-routes", "resume" -> { - if (isInfraSelected()) { - yield "Error: cannot start routes on infra service"; - } - sendRouteCommand(ctx.selectedPid, "*", "start"); - yield "Routes started for " + name; - } - case "restart" -> { - if (isInfraSelected()) { - yield "Error: cannot restart infra service"; - } - restartSelectedProcess(); - yield "Restarting " + name; - } - case "stop" -> { - stopSelectedProcess(false); - yield "Stopping " + name; - } - case "kill" -> { - stopSelectedProcess(true); - yield "Killed " + name; - } - default -> "Unknown action: " + action - + ". Available: stop-routes, start-routes, restart, stop, kill"; - }; - } - - private record PendingKey(KeyEvent event, long fireAt) { - } - } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java new file mode 100644 index 0000000000000..1bd15fe728e36 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java @@ -0,0 +1,824 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Queue; +import java.util.concurrent.atomic.AtomicReference; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.export.ExportRequest; +import dev.tamboui.text.CharWidth; +import dev.tamboui.text.Span; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.KeyModifiers; +import dev.tamboui.widgets.tabs.TabsState; +import org.apache.camel.dsl.jbang.core.common.PathUtils; +import org.apache.camel.dsl.jbang.core.common.RuntimeHelper; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hint; + +/** + * Facade that exposes monitor state and actions to the MCP server. + *

+ * Extracts the MCP-facing accessor and control methods from {@link CamelMonitor} to reduce coupling and class size. + * {@link TuiMcpServer} depends only on this facade, not on the full monitor. + */ +class McpFacade { + + /** + * Pending key event queued for injection into the TUI event loop. + */ + record PendingKey(KeyEvent event, long fireAt) { + } + + /** + * Callback interface for operations that remain in {@link CamelMonitor}. + */ + interface MonitorBridge { + + MonitorTab activeTab(); + + Buffer lastBuffer(); + + long renderGeneration(); + + boolean isKeystrokesVisible(); + + void handleTabKey(int tabIndex); + + void selectMoreTab(int moreIndex); + + boolean isSwitchPopupVisible(); + + boolean isMorePopupVisible(); + + void renderOverviewFooter(List spans); + + void insertFKeyHints(List spans); + + void sendRouteCommand(String pid, String routeId, String command); + + void restartProcess(); + + void stopProcess(boolean forceKill); + } + + // Tab name constants + static final String[] TAB_NAMES = { + "Overview", "Log", "Diagram", "Routes", "Endpoints", + "HTTP", "Health", "Inspect", "Errors", "More" + }; + + static final String[] MORE_TAB_NAMES = { + "Beans", "Browse", "Circuit Breaker", "Classpath", "Configuration", + "Consumers", "DataSource", "Inflight", "Memory", "Metrics", "SQL Query", "Spans", "Process", "Startup", + "Threads" + }; + + private static final int TAB_OVERVIEW = 0; + + private final MonitorContext ctx; + private final AtomicReference> data; + private final TabsState tabsState; + private final TuiEventLog eventLog; + private final CaptionOverlay captionOverlay; + private final DrawOverlay drawOverlay; + private final HelpOverlay helpOverlay; + private final ActionsPopup actionsPopup; + private final FilesBrowser filesBrowser; + private final LogTab logTab; + private final DiagramTab diagramTab; + private final HistoryTab historyTab; + private final AtomicReference tapeRecorderRef; + private final Queue pendingKeys; + private final MonitorBridge bridge; + + McpFacade( + MonitorContext ctx, + AtomicReference> data, + TabsState tabsState, + TuiEventLog eventLog, + CaptionOverlay captionOverlay, + DrawOverlay drawOverlay, + HelpOverlay helpOverlay, + ActionsPopup actionsPopup, + FilesBrowser filesBrowser, + LogTab logTab, + DiagramTab diagramTab, + HistoryTab historyTab, + AtomicReference tapeRecorderRef, + Queue pendingKeys, + MonitorBridge bridge) { + this.ctx = ctx; + this.data = data; + this.tabsState = tabsState; + this.eventLog = eventLog; + this.captionOverlay = captionOverlay; + this.drawOverlay = drawOverlay; + this.helpOverlay = helpOverlay; + this.actionsPopup = actionsPopup; + this.filesBrowser = filesBrowser; + this.logTab = logTab; + this.diagramTab = diagramTab; + this.historyTab = historyTab; + this.tapeRecorderRef = tapeRecorderRef; + this.pendingKeys = pendingKeys; + this.bridge = bridge; + } + + // ---- Screen state ---- + + Buffer getLastBuffer() { + return bridge.lastBuffer(); + } + + long getRenderGeneration() { + return bridge.renderGeneration(); + } + + // ---- Recording / events ---- + + boolean isKeystrokesVisible() { + return bridge.isKeystrokesVisible(); + } + + TapeRecorder getTapeRecorder() { + return tapeRecorderRef.get(); + } + + boolean isTapeRecording() { + TapeRecorder rec = tapeRecorderRef.get(); + return rec != null && rec.isActive(); + } + + void startTapeRecording(String title) { + TapeRecorder rec = new TapeRecorder(); + rec.start(title); + tapeRecorderRef.set(rec); + } + + void clearTapeRecorder() { + tapeRecorderRef.set(null); + } + + TuiEventLog getEventLog() { + return eventLog; + } + + // ---- Navigation state ---- + + int getActiveTabIndex() { + return tabsState.selected(); + } + + String getActiveTabName() { + int idx = tabsState.selected(); + return idx >= 0 && idx < TAB_NAMES.length ? TAB_NAMES[idx] : "Unknown"; + } + + String getSelectedPid() { + return ctx != null ? ctx.selectedPid : null; + } + + String getSelectedIntegrationName() { + if (ctx == null) { + return null; + } + IntegrationInfo info = ctx.findSelectedIntegration(); + return info != null ? info.name : null; + } + + int getIntegrationCount() { + List list = data.get(); + return (int) list.stream().filter(i -> !i.vanishing).count(); + } + + // ---- Caption / draw overlays ---- + + boolean isCaptionVisible() { + return captionOverlay.isCaptionVisible(); + } + + void showCaption(String text) { + captionOverlay.showCaption(text); + } + + void showCaption(String text, int durationSeconds) { + captionOverlay.showCaption(text, durationSeconds); + } + + void setDrawing(List cells, int durationSeconds) { + drawOverlay.setDrawing(cells, durationSeconds); + } + + void appendDrawing(List cells) { + drawOverlay.appendDrawing(cells); + } + + void clearDrawing() { + drawOverlay.clear(); + } + + // ---- Tab navigation ---- + + String navigateToTab(String tabName) { + for (int i = 0; i < TAB_NAMES.length; i++) { + if (TAB_NAMES[i].equalsIgnoreCase(tabName)) { + bridge.handleTabKey(i); + return TAB_NAMES[i]; + } + } + // Check More submenu tabs + for (int i = 0; i < MORE_TAB_NAMES.length; i++) { + if (MORE_TAB_NAMES[i].equalsIgnoreCase(tabName)) { + bridge.selectMoreTab(i); + return MORE_TAB_NAMES[i]; + } + } + return null; + } + + String selectIntegration(String nameOrPid) { + List infos = data.get(); + for (IntegrationInfo info : infos) { + if (info.vanishing) { + continue; + } + if (nameOrPid.equals(info.pid) + || (info.name != null && info.name.equalsIgnoreCase(nameOrPid))) { + ctx.selectedPid = info.pid; + return info.name != null ? info.name : info.pid; + } + } + return null; + } + + List getTabNames() { + List names = new ArrayList<>(); + names.addAll(List.of(TAB_NAMES)); + names.addAll(List.of(MORE_TAB_NAMES)); + return names; + } + + List getActionLabels() { + return actionsPopup.getActionLabels(); + } + + SelectionContext getSelectionContext() { + SelectionContext popup = actionsPopup.getSelectionContext(); + if (popup != null) { + return popup; + } + MonitorTab tab = bridge.activeTab(); + return tab != null ? tab.getSelectionContext() : null; + } + + List getIntegrationNames() { + return data.get().stream() + .filter(i -> !i.vanishing) + .map(i -> i.name != null ? i.name : i.pid) + .toList(); + } + + // ---- Key injection ---- + + int injectKeys(List keys, int delayMs) { + long fireAt = System.currentTimeMillis(); + int count = 0; + for (String key : keys) { + KeyEvent ke = parseKey(key); + if (ke != null) { + pendingKeys.add(new PendingKey(ke, fireAt)); + fireAt += delayMs; + count++; + } + } + return count; + } + + static KeyEvent parseKey(String key) { + if (key == null || key.isEmpty()) { + return null; + } + + boolean ctrl = false; + boolean shift = false; + String remainder = key; + while (remainder.contains("+")) { + int plus = remainder.indexOf('+'); + String mod = remainder.substring(0, plus).trim(); + remainder = remainder.substring(plus + 1).trim(); + if (mod.equalsIgnoreCase("Ctrl")) { + ctrl = true; + } else if (mod.equalsIgnoreCase("Shift")) { + shift = true; + } + } + + KeyModifiers mods = KeyModifiers.of(ctrl, false, shift); + + KeyCode code = switch (remainder.toLowerCase(Locale.ROOT)) { + case "enter", "return" -> KeyCode.ENTER; + case "esc", "escape" -> KeyCode.ESCAPE; + case "tab" -> KeyCode.TAB; + case "backspace" -> KeyCode.BACKSPACE; + case "delete", "del" -> KeyCode.DELETE; + case "up" -> KeyCode.UP; + case "down" -> KeyCode.DOWN; + case "left" -> KeyCode.LEFT; + case "right" -> KeyCode.RIGHT; + case "home" -> KeyCode.HOME; + case "end" -> KeyCode.END; + case "pageup", "pgup" -> KeyCode.PAGE_UP; + case "pagedown", "pgdn" -> KeyCode.PAGE_DOWN; + case "f1" -> KeyCode.F1; + case "f2" -> KeyCode.F2; + case "f3" -> KeyCode.F3; + case "f4" -> KeyCode.F4; + case "f5" -> KeyCode.F5; + case "f6" -> KeyCode.F6; + case "f7" -> KeyCode.F7; + case "f8" -> KeyCode.F8; + case "f9" -> KeyCode.F9; + case "f10" -> KeyCode.F10; + case "f11" -> KeyCode.F11; + case "f12" -> KeyCode.F12; + case "space" -> null; + default -> null; + }; + + if (code != null) { + return KeyEvent.ofKey(code, mods); + } + if ("space".equalsIgnoreCase(remainder)) { + return KeyEvent.ofChar(' ', mods); + } + if (remainder.length() == 1) { + return KeyEvent.ofChar(remainder.charAt(0), mods); + } + return null; + } + + // ---- Data access ---- + + JsonObject getTableData(String tabName) { + if (tabName != null && !tabName.isBlank()) { + String switched = navigateToTab(tabName); + if (switched == null) { + return null; + } + } + MonitorTab tab = bridge.activeTab(); + return tab != null ? tab.getTableDataAsJson() : null; + } + + boolean executeAction(String actionName) { + return actionsPopup.executeActionByName(actionName); + } + + JsonObject getLogData(int limit, String filter, String level) { + return logTab.getLogDataAsJson(limit, filter, level); + } + + JsonObject getDiagramData() { + MonitorTab tab = bridge.activeTab(); + if (tab instanceof DiagramTab dt) { + return dt.getTableDataAsJson(); + } + return diagramTab.getTableDataAsJson(); + } + + void selectTraceExchange(String exchangeId) { + historyTab.selectTraceExchange(exchangeId); + } + + JsonObject getTopologyData() { + return diagramTab.getTopologyDataAsJson(); + } + + @SuppressWarnings("unchecked") + JsonObject getSpanData(String traceId, int limit) { + String pid = ctx.selectedPid; + if (pid == null) { + JsonObject err = new JsonObject(); + err.put("error", "No integration selected"); + return err; + } + try { + Path outputFile = ctx.getOutputFile(pid); + PathUtils.deleteFile(outputFile); + + JsonObject action = new JsonObject(); + action.put("action", "span"); + action.put("dump", "true"); + action.put("limit", String.valueOf(limit)); + Path actionFile = ctx.getActionFile(pid); + PathUtils.writeTextSafely(action.toJson(), actionFile); + + JsonObject response = MonitorContext.pollJsonResponse(outputFile, 3000); + if (response != null) { + PathUtils.deleteFile(outputFile); + Boolean enabled = response.getBoolean("enabled"); + if (enabled == null || !enabled) { + JsonObject err = new JsonObject(); + err.put("error", "OpenTelemetry not enabled (requires --observe flag)"); + return err; + } + if (traceId != null && !traceId.isBlank()) { + JsonArray all = response.getCollection("spans"); + if (all != null) { + JsonArray filtered = new JsonArray(); + for (int i = 0; i < all.size(); i++) { + JsonObject span = (JsonObject) all.get(i); + String tid = span.getString("traceId"); + if (tid != null && tid.contains(traceId)) { + filtered.add(span); + } + } + response.put("spans", filtered); + } + } + return response; + } + JsonObject err = new JsonObject(); + err.put("error", "Timeout waiting for span data"); + return err; + } catch (Exception e) { + JsonObject err = new JsonObject(); + err.put("error", e.getMessage()); + return err; + } + } + + // ---- Diagram navigation ---- + + String navigateDiagramToRoute(String routeId) { + navigateToTab("Diagram"); + if (diagramTab.selectRoute(routeId)) { + return routeId; + } + return null; + } + + String navigateDiagramToNode(String routeId, String nodeId) { + navigateToTab("Diagram"); + if (diagramTab.selectNode(routeId, nodeId)) { + return nodeId; + } + return null; + } + + JsonObject getDiagramState() { + return diagramTab.getDiagramStateAsJson(); + } + + // ---- Screen location ---- + + JsonArray locateText(String search) { + Buffer buf = bridge.lastBuffer(); + if (buf == null || search == null || search.isEmpty()) { + return new JsonArray(); + } + String screen = ExportRequest.export(buf).text().toString(); + String[] lines = screen.split("\n", -1); + int searchWidth = 0; + for (int i = 0; i < search.length();) { + int cp = search.codePointAt(i); + searchWidth += Math.max(1, CharWidth.of(cp)); + i += Character.charCount(cp); + } + JsonArray matches = new JsonArray(); + for (int y = 0; y < lines.length; y++) { + String line = lines[y]; + int idx = line.indexOf(search); + while (idx >= 0) { + int visualCol = 0; + for (int i = 0; i < idx;) { + int cp = line.codePointAt(i); + visualCol += Math.max(1, CharWidth.of(cp)); + i += Character.charCount(cp); + } + JsonObject match = new JsonObject(); + match.put("x", visualCol); + match.put("y", y); + match.put("width", searchWidth); + match.put("height", 1); + match.put("text", search); + matches.add(match); + idx = line.indexOf(search, idx + 1); + } + if (matches.size() >= 20) { + break; + } + } + return matches; + } + + JsonObject locateNodes(List nodeIds) { + return diagramTab.locateNodes(nodeIds); + } + + // ---- Footer actions ---- + + JsonArray getFooterActionsAsJson() { + List spans = new ArrayList<>(); + if (helpOverlay.isVisible()) { + helpOverlay.renderFooter(spans); + } else if (captionOverlay.isCaptionVisible()) { + captionOverlay.renderFooter(spans); + } else if (filesBrowser.isVisible()) { + filesBrowser.renderFooter(spans); + } else if (bridge.isSwitchPopupVisible() || bridge.isMorePopupVisible()) { + if (bridge.isSwitchPopupVisible()) { + hint(spans, "Up/Down", "select"); + hint(spans, "Enter", "switch"); + hint(spans, "Esc", "close"); + } else { + hint(spans, "Up/Down", "select"); + hint(spans, "Enter", "open"); + hint(spans, "Esc", "close"); + } + } else { + MonitorTab tab = bridge.activeTab(); + if (tabsState.selected() == TAB_OVERVIEW) { + bridge.renderOverviewFooter(spans); + } else if (tab != null) { + tab.renderFooter(spans); + bridge.insertFKeyHints(spans); + } + } + JsonArray actions = new JsonArray(); + for (int i = 0; i + 1 < spans.size(); i += 2) { + String key = spans.get(i).content().trim(); + String rawLabel = spans.get(i + 1).content().trim(); + // compact "show BHPV" pattern: key="show", then space, then 4 single-letter spans, then trailing space + if ("show".equals(key) && i + 6 < spans.size()) { + for (int j = 0; j < 4; j++) { + Span letter = spans.get(i + 2 + j); + String ch = letter.content(); + boolean on = ch.equals(ch.toUpperCase()); + JsonObject toggle = new JsonObject(); + toggle.put("key", ch.toLowerCase()); + String label = switch (ch.toLowerCase()) { + case "b" -> "body"; + case "h" -> "headers"; + case "p" -> "properties"; + case "v" -> "variables"; + default -> ch; + }; + toggle.put("label", label); + toggle.put("state", on ? "on" : "off"); + actions.add(toggle); + } + i += 5; // skip the 7-span group (loop adds 2, we consumed 5 more) + continue; + } + JsonObject action = new JsonObject(); + action.put("key", key); + int bracket = rawLabel.indexOf('['); + if (bracket > 0 && rawLabel.endsWith("]")) { + action.put("label", rawLabel.substring(0, bracket).trim()); + action.put("state", rawLabel.substring(bracket + 1, rawLabel.length() - 1)); + } else { + action.put("label", rawLabel); + } + actions.add(action); + } + return actions; + } + + // ---- Tab filter / input ---- + + void setLogLevel(String level) { + logTab.setLogLevel(level); + } + + boolean setTabFilter(String tabName, String filter) { + if (tabName != null) { + navigateToTab(tabName); + } + MonitorTab tab = bridge.activeTab(); + return tab != null && tab.setFilter(filter); + } + + boolean setTabInputValue(String tabName, String field, String value) { + if (tabName != null) { + navigateToTab(tabName); + } + MonitorTab tab = bridge.activeTab(); + return tab != null && tab.setInputValue(field, value); + } + + String toggleTraceDisplay(String section, Boolean enabled) { + return historyTab.toggleDisplaySection(section, enabled); + } + + // ---- Integration data ---- + + JsonObject getReadme(String name) { + List integrations = data.get(); + IntegrationInfo target = null; + if (name != null && !name.isEmpty()) { + for (IntegrationInfo info : integrations) { + if (!info.vanishing && name.equals(info.name)) { + target = info; + break; + } + } + } else { + target = ctx != null ? ctx.findSelectedIntegration() : null; + } + if (target == null) { + return null; + } + if (target.readmeFiles == null || target.readmeFiles.isEmpty()) { + return null; + } + try { + Path outputFile = ctx.getOutputFile(target.pid); + Files.deleteIfExists(outputFile); + JsonObject action = new JsonObject(); + action.put("action", "readme"); + PathUtils.writeTextSafely(action.toJson(), ctx.getActionFile(target.pid)); + return MonitorContext.pollJsonResponse(outputFile, 5000); + } catch (Exception e) { + return null; + } + } + + JsonObject getFiles(String name, String file) { + List integrations = data.get(); + IntegrationInfo target = null; + if (name != null && !name.isEmpty()) { + for (IntegrationInfo info : integrations) { + if (!info.vanishing && name.equals(info.name)) { + target = info; + break; + } + } + } else { + target = ctx != null ? ctx.findSelectedIntegration() : null; + } + if (target == null) { + return null; + } + Path dir = FilesBrowser.resolveSourceDirectory(target); + if (dir == null || !Files.isDirectory(dir)) { + return null; + } + if (file != null && !file.isEmpty()) { + Path filePath = dir.resolve(file); + if (!Files.isRegularFile(filePath)) { + return null; + } + try { + String content = Files.readString(filePath, StandardCharsets.UTF_8); + JsonObject result = new JsonObject(); + result.put("file", file); + result.put("directory", dir.toString()); + result.put("size", FilesBrowser.formatFileSize(Files.size(filePath))); + result.put("type", FilesBrowser.fileType(filePath)); + result.put("content", content); + return result; + } catch (IOException e) { + return null; + } + } + JsonArray files = new JsonArray(); + try (var stream = Files.list(dir)) { + stream.filter(Files::isRegularFile) + .sorted((a, b) -> a.getFileName().toString().compareToIgnoreCase(b.getFileName().toString())) + .limit(99) + .forEach(p -> { + JsonObject entry = new JsonObject(); + entry.put("name", p.getFileName().toString()); + try { + entry.put("size", FilesBrowser.formatFileSize(Files.size(p))); + } catch (IOException e) { + entry.put("size", "0 B"); + } + entry.put("type", FilesBrowser.fileType(p)); + files.add(entry); + }); + } catch (IOException e) { + return null; + } + if (files.isEmpty()) { + return null; + } + JsonObject result = new JsonObject(); + result.put("directory", dir.toString()); + result.put("files", files); + result.put("totalFiles", files.size()); + return result; + } + + // ---- Integration messaging / control ---- + + JsonObject sendMessage(String endpoint, String body, String headers) { + if (ctx.selectedPid == null) { + return null; + } + long pid; + try { + pid = Long.parseLong(ctx.selectedPid); + } catch (NumberFormatException e) { + return null; + } + return RuntimeHelper.sendMessage(pid, endpoint, body, headers); + } + + JsonObject executeSql(String sql, String datasource, int maxRows, int queryTimeout) { + if (ctx.selectedPid == null) { + return null; + } + long pid; + try { + pid = Long.parseLong(ctx.selectedPid); + } catch (NumberFormatException e) { + return null; + } + return RuntimeHelper.executeSqlQuery(pid, sql, datasource, maxRows, queryTimeout); + } + + JsonObject updateRow(String table, String datasource, String pkValuesJson, String colValuesJson) { + if (ctx.selectedPid == null) { + return null; + } + long pid; + try { + pid = Long.parseLong(ctx.selectedPid); + } catch (NumberFormatException e) { + return null; + } + return RuntimeHelper.executeRowUpdate(pid, table, datasource, pkValuesJson, colValuesJson); + } + + String controlIntegration(String action) { + if (action == null || action.isBlank()) { + return "Error: action is required"; + } + if (ctx.selectedPid == null) { + return "Error: no integration selected"; + } + String name = ctx.selectedName(); + return switch (action) { + case "stop-routes", "pause" -> { + if (ctx.isInfraSelected()) { + yield "Error: cannot stop routes on infra service"; + } + bridge.sendRouteCommand(ctx.selectedPid, "*", "stop"); + yield "Routes stopped for " + name; + } + case "start-routes", "resume" -> { + if (ctx.isInfraSelected()) { + yield "Error: cannot start routes on infra service"; + } + bridge.sendRouteCommand(ctx.selectedPid, "*", "start"); + yield "Routes started for " + name; + } + case "restart" -> { + if (ctx.isInfraSelected()) { + yield "Error: cannot restart infra service"; + } + bridge.restartProcess(); + yield "Restarting " + name; + } + case "stop" -> { + bridge.stopProcess(false); + yield "Stopping " + name; + } + case "kill" -> { + bridge.stopProcess(true); + yield "Killed " + name; + } + default -> "Unknown action: " + action + + ". Available: stop-routes, start-routes, restart, stop, kill"; + }; + } + +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java index 73a522768b119..0a3b71cbf7e73 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java @@ -67,16 +67,16 @@ record LogEntry(String timestamp, LogLevel level, String message, String request } private final int port; - private final CamelMonitor monitor; + private final McpFacade facade; private HttpServer server; private volatile String clientName; private volatile long lastActivity; private volatile long lastToolCallTime; private final List activityLog = new ArrayList<>(); - TuiMcpServer(int port, CamelMonitor monitor) { + TuiMcpServer(int port, McpFacade facade) { this.port = port; - this.monitor = monitor; + this.facade = facade; } void start() throws IOException { @@ -645,7 +645,7 @@ private JsonObject handleToolsCall(JsonObject request) { } private void addSelectionContext(JsonObject result) { - SelectionContext ctx = monitor.getSelectionContext(); + SelectionContext ctx = facade.getSelectionContext(); if (ctx != null) { JsonObject sel = new JsonObject(); sel.put("type", ctx.type()); @@ -660,14 +660,14 @@ private void addSelectionContext(JsonObject result) { } private void addFooterActions(JsonObject result) { - JsonArray actions = monitor.getFooterActionsAsJson(); + JsonArray actions = facade.getFooterActionsAsJson(); if (actions != null && !actions.isEmpty()) { result.put("actions", actions); } } private String callGetScreen(Map args) { - Buffer buf = monitor.getLastBuffer(); + Buffer buf = facade.getLastBuffer(); if (buf == null) { return "Screen not yet available"; } @@ -691,7 +691,7 @@ private String callGetEvents(Map args) { limit = n.intValue(); } - TuiEventLog eventLog = monitor.getEventLog(); + TuiEventLog eventLog = facade.getEventLog(); List events = eventLog.getRecent(limit); JsonArray eventsArray = new JsonArray(); @@ -711,23 +711,23 @@ private String callGetEvents(Map args) { private String callGetState() { JsonObject result = new JsonObject(); - result.put("activeTab", monitor.getActiveTabName()); - result.put("tabIndex", monitor.getActiveTabIndex()); + result.put("activeTab", facade.getActiveTabName()); + result.put("tabIndex", facade.getActiveTabIndex()); - String pid = monitor.getSelectedPid(); + String pid = facade.getSelectedPid(); if (pid != null) { result.put("selectedPid", pid); } - String name = monitor.getSelectedIntegrationName(); + String name = facade.getSelectedIntegrationName(); if (name != null) { result.put("selectedIntegration", name); } - result.put("integrationCount", monitor.getIntegrationCount()); - result.put("keystrokesVisible", monitor.isKeystrokesVisible()); - result.put("captionVisible", monitor.isCaptionVisible()); + result.put("integrationCount", facade.getIntegrationCount()); + result.put("keystrokesVisible", facade.isKeystrokesVisible()); + result.put("captionVisible", facade.isCaptionVisible()); addSelectionContext(result); addFooterActions(result); - JsonObject diagramState = monitor.getDiagramState(); + JsonObject diagramState = facade.getDiagramState(); if (diagramState != null) { result.put("diagram", diagramState); } @@ -745,17 +745,17 @@ private String callShowCaption(Map args) { duration = n.intValue(); } - TapeRecorder recorder = monitor.getTapeRecorder(); + TapeRecorder recorder = facade.getTapeRecorder(); if (recorder != null && recorder.isActive()) { recorder.resetClock(); recorder.recordCaption(text, Math.max(duration, 0)); } if (duration > 0) { - monitor.showCaption(text, duration); + facade.showCaption(text, duration); return "Caption displayed (auto-dismiss in " + duration + "s): " + text; } - monitor.showCaption(text); + facade.showCaption(text); return "Caption displayed: " + text; } @@ -768,42 +768,42 @@ private String callNavigate(Map args) { if (tab == null && integration == null && route == null && node == null) { result.put("error", "Provide at least one of: tab, integration, route, node"); - result.put("availableTabs", toJsonArray(monitor.getTabNames())); - result.put("availableIntegrations", toJsonArray(monitor.getIntegrationNames())); + result.put("availableTabs", toJsonArray(facade.getTabNames())); + result.put("availableIntegrations", toJsonArray(facade.getIntegrationNames())); return Jsoner.serialize(result); } if (integration != null) { - String selected = monitor.selectIntegration(integration); + String selected = facade.selectIntegration(integration); if (selected != null) { result.put("selectedIntegration", selected); } else { result.put("integrationError", "Not found: " + integration); - result.put("availableIntegrations", toJsonArray(monitor.getIntegrationNames())); + result.put("availableIntegrations", toJsonArray(facade.getIntegrationNames())); } } if (tab != null) { - String switched = monitor.navigateToTab(tab); + String switched = facade.navigateToTab(tab); if (switched != null) { result.put("activeTab", switched); - TapeRecorder recorder = monitor.getTapeRecorder(); + TapeRecorder recorder = facade.getTapeRecorder(); if (recorder != null && recorder.isActive()) { recorder.resetClock(); - int tabIndex = monitor.getTabNames().indexOf(switched); + int tabIndex = facade.getTabNames().indexOf(switched); if (tabIndex >= 0 && tabIndex < 9) { recorder.recordKey(String.valueOf(tabIndex + 1)); } } } else { result.put("tabError", "Unknown tab: " + tab); - result.put("availableTabs", toJsonArray(monitor.getTabNames())); + result.put("availableTabs", toJsonArray(facade.getTabNames())); } } // Diagram route/node navigation (route selection in topology doesn't need render wait) if (node == null && route != null) { - String selected = monitor.navigateDiagramToRoute(route); + String selected = facade.navigateDiagramToRoute(route); if (selected != null) { result.put("selectedRoute", route); } else { @@ -816,14 +816,14 @@ private String callNavigate(Map args) { if (node != null) { // Drill into the route first (sets topologyMode=false) if (route != null) { - monitor.navigateDiagramToNode(route, null); + facade.navigateDiagramToNode(route, null); } } - long beforeGen = monitor.getRenderGeneration(); + long beforeGen = facade.getRenderGeneration(); long deadline = System.currentTimeMillis() + 2000; while (System.currentTimeMillis() < deadline) { - if (monitor.getRenderGeneration() >= beforeGen + 2) { + if (facade.getRenderGeneration() >= beforeGen + 2) { break; } try { @@ -836,7 +836,7 @@ private String callNavigate(Map args) { // Now that the render has populated EIP node boxes, select the node if (node != null) { - String selected = monitor.navigateDiagramToNode(null, node); + String selected = facade.navigateDiagramToNode(null, node); if (selected != null) { result.put("selectedNode", node); if (route != null) { @@ -847,7 +847,7 @@ private String callNavigate(Map args) { + (route != null ? " in route " + route : "")); } } - Buffer buf = monitor.getLastBuffer(); + Buffer buf = facade.getLastBuffer(); if (buf != null) { result.put("screen", ExportRequest.export(buf).text().toString()); } @@ -873,15 +873,15 @@ private String callSendKeys(Map args) { if (delayArg instanceof Number n) { delay = Math.max(80, n.intValue()); } - TapeRecorder recorder = monitor.getTapeRecorder(); + TapeRecorder recorder = facade.getTapeRecorder(); if (recorder != null && recorder.isActive()) { recorder.resetClock(); recorder.recordKeys(keys, delay); } boolean wait = Boolean.TRUE.equals(args.get("wait")); - long beforeGen = wait ? monitor.getRenderGeneration() : 0; - int sent = monitor.injectKeys(keys, delay); + long beforeGen = wait ? facade.getRenderGeneration() : 0; + int sent = facade.injectKeys(keys, delay); if (!wait) { return "Queued " + sent + " key(s) with " + delay + "ms delay"; @@ -894,7 +894,7 @@ private String callSendKeys(Map args) { while (System.currentTimeMillis() < deadline) { long now = System.currentTimeMillis(); if (now >= lastKeyFireAt) { - long gen = monitor.getRenderGeneration(); + long gen = facade.getRenderGeneration(); if (gen >= beforeGen + sent + 2) { break; } @@ -907,7 +907,7 @@ private String callSendKeys(Map args) { } } - Buffer buf = monitor.getLastBuffer(); + Buffer buf = facade.getLastBuffer(); JsonObject result = new JsonObject(); result.put("sent", sent); result.put("delay", delay); @@ -922,17 +922,17 @@ private String callSendKeys(Map args) { private String callGetOptions() { JsonObject result = new JsonObject(); - result.put("tabs", toJsonArray(monitor.getTabNames())); - result.put("activeTab", monitor.getActiveTabName()); - result.put("activeTabIndex", monitor.getActiveTabIndex()); - result.put("integrations", toJsonArray(monitor.getIntegrationNames())); - String selected = monitor.getSelectedIntegrationName(); + result.put("tabs", toJsonArray(facade.getTabNames())); + result.put("activeTab", facade.getActiveTabName()); + result.put("activeTabIndex", facade.getActiveTabIndex()); + result.put("integrations", toJsonArray(facade.getIntegrationNames())); + String selected = facade.getSelectedIntegrationName(); if (selected != null) { result.put("selectedIntegration", selected); } - result.put("integrationCount", monitor.getIntegrationCount()); + result.put("integrationCount", facade.getIntegrationCount()); - List actions = monitor.getActionLabels(); + List actions = facade.getActionLabels(); JsonArray actionsArray = new JsonArray(); for (int i = 0; i < actions.size(); i++) { JsonObject action = new JsonObject(); @@ -969,14 +969,14 @@ private String callWaitForIdle(Map args) { requiredFrames = Math.max(1, Math.min(10, n.intValue())); } - long startGeneration = monitor.getRenderGeneration(); + long startGeneration = facade.getRenderGeneration(); long start = System.currentTimeMillis(); long deadline = start + timeout; while (System.currentTimeMillis() < deadline) { - long current = monitor.getRenderGeneration(); + long current = facade.getRenderGeneration(); if (current >= startGeneration + requiredFrames) { - Buffer buf = monitor.getLastBuffer(); + Buffer buf = facade.getLastBuffer(); JsonObject result = new JsonObject(); result.put("settled", true); result.put("waitedMs", System.currentTimeMillis() - start); @@ -1003,23 +1003,23 @@ private String callWaitForIdle(Map args) { } private String callTapeStart(Map args) { - if (monitor.isTapeRecording()) { + if (facade.isTapeRecording()) { return "Tape recording is already active. Stop it first with tui_tape_stop."; } String title = args.get("title") instanceof String s ? s : null; - monitor.startTapeRecording(title); + facade.startTapeRecording(title); return "Tape recording started" + (title != null ? ": " + title : ""); } private String callTapeStop(Map args) { - if (!monitor.isTapeRecording()) { + if (!facade.isTapeRecording()) { return "No tape recording is active. Start one with tui_tape_start."; } - TapeRecorder recorder = monitor.getTapeRecorder(); + TapeRecorder recorder = facade.getTapeRecorder(); String tape = recorder.stop(); int keyCount = recorder.getKeyCount(); long durationMs = recorder.getDurationMs(); - monitor.clearTapeRecorder(); + facade.clearTapeRecorder(); JsonObject result = new JsonObject(); result.put("tape", tape); @@ -1047,7 +1047,7 @@ private String callSleep(Map args) { int seconds = secArg instanceof Number n ? n.intValue() : 3; seconds = Math.max(1, Math.min(30, seconds)); - TapeRecorder recorder = monitor.getTapeRecorder(); + TapeRecorder recorder = facade.getTapeRecorder(); if (recorder != null && recorder.isActive()) { recorder.resetClock(); recorder.recordSleep(seconds * 1000L); @@ -1116,9 +1116,9 @@ private String callDraw(Map args) { } if (append) { - monitor.appendDrawing(drawCells); + facade.appendDrawing(drawCells); } else { - monitor.setDrawing(drawCells, duration); + facade.setDrawing(drawCells, duration); } return "Drawing " + drawCells.size() + " cell(s)" @@ -1127,13 +1127,13 @@ private String callDraw(Map args) { } private String callDrawClear() { - monitor.clearDrawing(); + facade.clearDrawing(); return "Drawing cleared"; } private String callGetTable(Map args) { String tab = args.get("tab") instanceof String s ? s : null; - JsonObject data = monitor.getTableData(tab); + JsonObject data = facade.getTableData(tab); if (data == null) { return "No table data available" + (tab != null ? " for tab: " + tab : ""); } @@ -1145,7 +1145,7 @@ private String callAction(Map args) { if (action == null || action.isBlank()) { return "Error: action is required"; } - boolean executed = monitor.executeAction(action); + boolean executed = facade.executeAction(action); if (executed) { return "Action '" + action + "' executed"; } @@ -1161,12 +1161,12 @@ private String callGetLog(Map args) { } String filter = args.get("filter") instanceof String s ? s : null; String level = args.get("level") instanceof String s ? s : null; - JsonObject data = monitor.getLogData(limit, filter, level); + JsonObject data = facade.getLogData(limit, filter, level); return Jsoner.serialize(data); } private String callGetErrors() { - JsonObject data = monitor.getTableData("Errors"); + JsonObject data = facade.getTableData("Errors"); if (data == null) { JsonObject empty = new JsonObject(); empty.put("tab", "Errors"); @@ -1178,7 +1178,7 @@ private String callGetErrors() { } private String callGetDiagram() { - JsonObject data = monitor.getDiagramData(); + JsonObject data = facade.getDiagramData(); if (data == null) { return "No diagram available. Navigate to the Diagram tab first."; } @@ -1188,10 +1188,10 @@ private String callGetDiagram() { private String callGetHistory(Map args) { String exchangeId = args.get("exchangeId") instanceof String s ? s : null; if (exchangeId != null && !exchangeId.isBlank()) { - monitor.navigateToTab("History"); - monitor.selectTraceExchange(exchangeId); + facade.navigateToTab("History"); + facade.selectTraceExchange(exchangeId); } - JsonObject data = monitor.getTableData("History"); + JsonObject data = facade.getTableData("History"); if (data == null) { return "No history data available. Ensure the History tab has data."; } @@ -1199,7 +1199,7 @@ private String callGetHistory(Map args) { } private String callGetTopology() { - JsonObject data = monitor.getTopologyData(); + JsonObject data = facade.getTopologyData(); if (data == null) { return "No topology data available. The Diagram tab may not have loaded yet."; } @@ -1212,7 +1212,7 @@ private String callGetSpans(Map args) { if (args.get("limit") instanceof Number n) { limit = n.intValue(); } - JsonObject data = monitor.getSpanData(traceId, limit); + JsonObject data = facade.getSpanData(traceId, limit); return Jsoner.serialize(data); } @@ -1223,7 +1223,7 @@ private String callSendMessage(Map args) { } String body = args.get("body") instanceof String s ? s : null; String headers = args.get("headers") instanceof String s ? s : null; - JsonObject response = monitor.sendMessage(endpoint, body, headers); + JsonObject response = facade.sendMessage(endpoint, body, headers); if (response == null) { return "Error: no integration selected or PID unavailable"; } @@ -1238,7 +1238,7 @@ private String callExecuteSql(Map args) { String datasource = args.get("datasource") instanceof String s ? s : null; int maxRows = args.get("maxRows") instanceof Number n ? n.intValue() : 100; int queryTimeout = args.get("queryTimeout") instanceof Number n ? n.intValue() : 30; - JsonObject response = monitor.executeSql(query, datasource, maxRows, queryTimeout); + JsonObject response = facade.executeSql(query, datasource, maxRows, queryTimeout); if (response == null) { return "Error: no integration selected or PID unavailable"; } @@ -1259,7 +1259,7 @@ private String callUpdateRow(Map args) { return "Error: columnValues is required (JSON object)"; } String datasource = args.get("datasource") instanceof String s ? s : null; - JsonObject response = monitor.updateRow(table, datasource, pkValues, colValues); + JsonObject response = facade.updateRow(table, datasource, pkValues, colValues); if (response == null) { return "Error: no integration selected or PID unavailable"; } @@ -1276,14 +1276,14 @@ private String callSetLogLevel(Map args) { && !"DEBUG".equals(level) && !"TRACE".equals(level)) { return "Error: invalid level '" + level + "'. Must be ERROR, WARN, INFO, DEBUG, or TRACE"; } - monitor.setLogLevel(level); + facade.setLogLevel(level); return "Log level set to " + level; } private String callFilter(Map args) { String filter = args.get("filter") instanceof String s ? s : ""; String tab = args.get("tab") instanceof String s ? s : null; - boolean applied = monitor.setTabFilter(tab, filter); + boolean applied = facade.setTabFilter(tab, filter); if (!applied) { return "This tab does not support text filtering"; } @@ -1297,7 +1297,7 @@ private String callSetInput(Map args) { } String value = args.get("value") instanceof String s ? s : ""; String tab = args.get("tab") instanceof String s ? s : null; - boolean applied = monitor.setTabInputValue(tab, field, value); + boolean applied = facade.setTabInputValue(tab, field, value); if (!applied) { return "Error: field '" + field + "' not found on " + (tab != null ? tab : "active") + " tab"; } @@ -1310,7 +1310,7 @@ private String callToggleTraceDisplay(Map args) { return "Error: section is required (headers, properties, variables, body, wrap)"; } Boolean enabled = args.get("enabled") instanceof Boolean b ? b : null; - String result = monitor.toggleTraceDisplay(section, enabled); + String result = facade.toggleTraceDisplay(section, enabled); if (result == null) { return "Error: unknown section '" + section + "'. Must be headers, properties, variables, body, or wrap"; } @@ -1319,7 +1319,7 @@ private String callToggleTraceDisplay(Map args) { private String callGetReadme(Map args) { String name = args.get("name") instanceof String s ? s : null; - JsonObject response = monitor.getReadme(name); + JsonObject response = facade.getReadme(name); if (response == null) { return name != null ? "No README found for integration '" + name + "'" @@ -1338,13 +1338,13 @@ private String callControl(Map args) { if (action == null || action.isBlank()) { return "Error: action is required"; } - return monitor.controlIntegration(action); + return facade.controlIntegration(action); } private String callGetFiles(Map args) { String name = args.get("name") instanceof String s ? s : null; String file = args.get("file") instanceof String s ? s : null; - JsonObject response = monitor.getFiles(name, file); + JsonObject response = facade.getFiles(name, file); if (response == null) { return name != null ? "No source files found for integration '" + name + "'" @@ -1364,11 +1364,11 @@ private String callLocate(Map args) { JsonObject result = new JsonObject(); if (text != null) { - JsonArray matches = monitor.locateText(text); + JsonArray matches = facade.locateText(text); result.put("matches", matches); } else if (node != null || nodes != null) { List ids = nodes != null ? nodes : List.of(node); - JsonObject located = monitor.locateNodes(ids); + JsonObject located = facade.locateNodes(ids); if (located != null) { result.put("matches", located.get("matches")); if (located.containsKey("bounds")) { @@ -1421,9 +1421,9 @@ private String callDrawShape(Map args) { boolean append = args.get("append") instanceof Boolean b && b; if (append) { - monitor.appendDrawing(cells); + facade.appendDrawing(cells); } else { - monitor.setDrawing(cells, duration); + facade.setDrawing(cells, duration); } return "Drew " + shape + " at (" + x + "," + y + ")"; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorParseKeyTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorParseKeyTest.java index 656ab31674901..cade44fb0baf1 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorParseKeyTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitorParseKeyTest.java @@ -29,7 +29,7 @@ class CamelMonitorParseKeyTest { @Test void parseKeySingleChar() { - KeyEvent ke = CamelMonitor.parseKey("a"); + KeyEvent ke = McpFacade.parseKey("a"); assertNotNull(ke); assertEquals(KeyCode.CHAR, ke.code()); assertEquals('a', ke.character()); @@ -37,92 +37,92 @@ void parseKeySingleChar() { @Test void parseKeyEnter() { - KeyEvent ke = CamelMonitor.parseKey("enter"); + KeyEvent ke = McpFacade.parseKey("enter"); assertNotNull(ke); assertEquals(KeyCode.ENTER, ke.code()); } @Test void parseKeyReturn() { - KeyEvent ke = CamelMonitor.parseKey("return"); + KeyEvent ke = McpFacade.parseKey("return"); assertNotNull(ke); assertEquals(KeyCode.ENTER, ke.code()); } @Test void parseKeyEscape() { - KeyEvent ke = CamelMonitor.parseKey("esc"); + KeyEvent ke = McpFacade.parseKey("esc"); assertNotNull(ke); assertEquals(KeyCode.ESCAPE, ke.code()); } @Test void parseKeyEscapeFull() { - KeyEvent ke = CamelMonitor.parseKey("escape"); + KeyEvent ke = McpFacade.parseKey("escape"); assertNotNull(ke); assertEquals(KeyCode.ESCAPE, ke.code()); } @Test void parseKeyTab() { - KeyEvent ke = CamelMonitor.parseKey("tab"); + KeyEvent ke = McpFacade.parseKey("tab"); assertNotNull(ke); assertEquals(KeyCode.TAB, ke.code()); } @Test void parseKeyBackspace() { - KeyEvent ke = CamelMonitor.parseKey("backspace"); + KeyEvent ke = McpFacade.parseKey("backspace"); assertNotNull(ke); assertEquals(KeyCode.BACKSPACE, ke.code()); } @Test void parseKeyDelete() { - KeyEvent ke = CamelMonitor.parseKey("delete"); + KeyEvent ke = McpFacade.parseKey("delete"); assertNotNull(ke); assertEquals(KeyCode.DELETE, ke.code()); } @Test void parseKeyDeleteShort() { - KeyEvent ke = CamelMonitor.parseKey("del"); + KeyEvent ke = McpFacade.parseKey("del"); assertNotNull(ke); assertEquals(KeyCode.DELETE, ke.code()); } @Test void parseKeyArrows() { - assertEquals(KeyCode.UP, CamelMonitor.parseKey("up").code()); - assertEquals(KeyCode.DOWN, CamelMonitor.parseKey("down").code()); - assertEquals(KeyCode.LEFT, CamelMonitor.parseKey("left").code()); - assertEquals(KeyCode.RIGHT, CamelMonitor.parseKey("right").code()); + assertEquals(KeyCode.UP, McpFacade.parseKey("up").code()); + assertEquals(KeyCode.DOWN, McpFacade.parseKey("down").code()); + assertEquals(KeyCode.LEFT, McpFacade.parseKey("left").code()); + assertEquals(KeyCode.RIGHT, McpFacade.parseKey("right").code()); } @Test void parseKeyHomeEnd() { - assertEquals(KeyCode.HOME, CamelMonitor.parseKey("home").code()); - assertEquals(KeyCode.END, CamelMonitor.parseKey("end").code()); + assertEquals(KeyCode.HOME, McpFacade.parseKey("home").code()); + assertEquals(KeyCode.END, McpFacade.parseKey("end").code()); } @Test void parseKeyPageUpDown() { - assertEquals(KeyCode.PAGE_UP, CamelMonitor.parseKey("pageup").code()); - assertEquals(KeyCode.PAGE_UP, CamelMonitor.parseKey("pgup").code()); - assertEquals(KeyCode.PAGE_DOWN, CamelMonitor.parseKey("pagedown").code()); - assertEquals(KeyCode.PAGE_DOWN, CamelMonitor.parseKey("pgdn").code()); + assertEquals(KeyCode.PAGE_UP, McpFacade.parseKey("pageup").code()); + assertEquals(KeyCode.PAGE_UP, McpFacade.parseKey("pgup").code()); + assertEquals(KeyCode.PAGE_DOWN, McpFacade.parseKey("pagedown").code()); + assertEquals(KeyCode.PAGE_DOWN, McpFacade.parseKey("pgdn").code()); } @Test void parseKeyFKeys() { - assertEquals(KeyCode.F1, CamelMonitor.parseKey("f1").code()); - assertEquals(KeyCode.F6, CamelMonitor.parseKey("f6").code()); - assertEquals(KeyCode.F12, CamelMonitor.parseKey("f12").code()); + assertEquals(KeyCode.F1, McpFacade.parseKey("f1").code()); + assertEquals(KeyCode.F6, McpFacade.parseKey("f6").code()); + assertEquals(KeyCode.F12, McpFacade.parseKey("f12").code()); } @Test void parseKeySpace() { - KeyEvent ke = CamelMonitor.parseKey("space"); + KeyEvent ke = McpFacade.parseKey("space"); assertNotNull(ke); assertEquals(KeyCode.CHAR, ke.code()); assertEquals(' ', ke.character()); @@ -130,7 +130,7 @@ void parseKeySpace() { @Test void parseKeyCtrlModifier() { - KeyEvent ke = CamelMonitor.parseKey("Ctrl+c"); + KeyEvent ke = McpFacade.parseKey("Ctrl+c"); assertNotNull(ke); assertEquals(KeyCode.CHAR, ke.code()); assertEquals('c', ke.character()); @@ -139,7 +139,7 @@ void parseKeyCtrlModifier() { @Test void parseKeyShiftModifier() { - KeyEvent ke = CamelMonitor.parseKey("Shift+F6"); + KeyEvent ke = McpFacade.parseKey("Shift+F6"); assertNotNull(ke); assertEquals(KeyCode.F6, ke.code()); assertTrue(ke.hasShift()); @@ -147,7 +147,7 @@ void parseKeyShiftModifier() { @Test void parseKeyCtrlAndShift() { - KeyEvent ke = CamelMonitor.parseKey("Ctrl+Shift+a"); + KeyEvent ke = McpFacade.parseKey("Ctrl+Shift+a"); assertNotNull(ke); assertTrue(ke.hasCtrl()); assertTrue(ke.hasShift()); @@ -155,21 +155,21 @@ void parseKeyCtrlAndShift() { @Test void parseKeyNullReturnsNull() { - assertNull(CamelMonitor.parseKey(null)); + assertNull(McpFacade.parseKey(null)); } @Test void parseKeyEmptyReturnsNull() { - assertNull(CamelMonitor.parseKey("")); + assertNull(McpFacade.parseKey("")); } @Test void parseKeyCaseInsensitive() { - KeyEvent ke1 = CamelMonitor.parseKey("ENTER"); + KeyEvent ke1 = McpFacade.parseKey("ENTER"); assertNotNull(ke1); assertEquals(KeyCode.ENTER, ke1.code()); - KeyEvent ke2 = CamelMonitor.parseKey("Enter"); + KeyEvent ke2 = McpFacade.parseKey("Enter"); assertNotNull(ke2); assertEquals(KeyCode.ENTER, ke2.code()); } @@ -177,6 +177,6 @@ void parseKeyCaseInsensitive() { @Test void parseKeyUnknownMultiCharReturnsNull() { // Multi-character string that is not a known key name - assertNull(CamelMonitor.parseKey("xyz")); + assertNull(McpFacade.parseKey("xyz")); } } From b016c673ed607d63b9652a7af06e746d278966b4 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 29 Jun 2026 16:13:46 +0000 Subject: [PATCH 2/4] chore: extract RecordingManager, PopupManager and simplify event dispatch Continue CamelMonitor God class decomposition (Steps 2, 3, 6): - Extract RecordingManager (~280 lines): screenshot capture, tape recording, keystroke display, screen buffer tracking, key label utility - Extract PopupManager (~430 lines): switch-integration, more-tabs, and kill-confirm popup state management, key handling, and rendering - Simplify event dispatch: move overview-specific key handlers (p, x, X, r, d, f) into OverviewTab via OverviewActions callback interface, removing duplicate handlers from CamelMonitor.handleTabKeys - Update McpFacade to use RecordingManager instead of direct bridge callbacks CamelMonitor reduced from ~2,593 to ~2,167 lines (16% reduction). All 260 tests pass. Pure refactor, zero behavior change. Co-Authored-By: Claude Opus 4.6 --- .../jbang/core/commands/tui/CamelMonitor.java | 620 +++--------------- .../jbang/core/commands/tui/McpFacade.java | 36 +- .../jbang/core/commands/tui/OverviewTab.java | 56 ++ .../jbang/core/commands/tui/PopupManager.java | 429 ++++++++++++ .../core/commands/tui/RecordingManager.java | 280 ++++++++ 5 files changed, 874 insertions(+), 547 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManager.java diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index a56f39abb52d2..892b01b1e5924 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -22,8 +22,6 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -41,8 +39,6 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; -import dev.tamboui.buffer.Buffer; -import dev.tamboui.export.ExportRequest; import dev.tamboui.layout.Constraint; import dev.tamboui.layout.Layout; import dev.tamboui.layout.Rect; @@ -60,15 +56,6 @@ import dev.tamboui.tui.event.MouseEvent; import dev.tamboui.tui.event.PasteEvent; import dev.tamboui.tui.event.TickEvent; -import dev.tamboui.widgets.Clear; -import dev.tamboui.widgets.block.Block; -import dev.tamboui.widgets.block.BorderType; -import dev.tamboui.widgets.block.Borders; -import dev.tamboui.widgets.block.Title; -import dev.tamboui.widgets.list.ListItem; -import dev.tamboui.widgets.list.ListState; -import dev.tamboui.widgets.list.ListWidget; -import dev.tamboui.widgets.list.ScrollMode; import dev.tamboui.widgets.paragraph.Paragraph; import dev.tamboui.widgets.tabs.Tabs; import dev.tamboui.widgets.tabs.TabsState; @@ -162,24 +149,15 @@ public class CamelMonitor extends CamelCommand { // selectedPid is stored on ctx (MonitorContext) so tabs can access it private volatile long lastRefresh; - private boolean showKillConfirm; private String monitorNotification; private boolean monitorNotificationError; private long monitorNotificationExpiry; - private volatile Buffer lastBuffer; - private volatile long renderGeneration; - private volatile String screenshotMessage; - private volatile long screenshotMessageTime; - private volatile boolean pendingScreenshot; - private boolean recording; - private final AtomicReference tapeRecorderRef = new AtomicReference<>(); private boolean mcpInjectedKey; - private TuiEventLog eventLog; private TuiMcpServer mcpServer; private McpFacade mcpFacade; private final Queue pendingKeys = new ConcurrentLinkedQueue<>(); - private final List recentKeys = new ArrayList<>(); private final CaptionOverlay captionOverlay = new CaptionOverlay(); + private final RecordingManager recordingManager = new RecordingManager(captionOverlay); private final DrawOverlay drawOverlay = new DrawOverlay(); private final HelpOverlay helpOverlay = new HelpOverlay(); private final ShellPanel shellPanel = new ShellPanel(); @@ -196,14 +174,11 @@ public class CamelMonitor extends CamelCommand { .filter(i -> !i.vanishing) .collect(Collectors.toList()), captionOverlay, - () -> pendingScreenshot = true, - () -> recording = !recording, - () -> recording, - this::toggleTapeRecording, - () -> { - TapeRecorder r = tapeRecorderRef.get(); - return r != null && r.isActive(); - }, + () -> recordingManager.requestScreenshot(), + () -> recordingManager.toggleRecording(), + () -> recordingManager.isRecording(), + () -> recordingManager.toggleTapeRecording(), + () -> recordingManager.isTapeRecording(), this::enableBurstMode, stoppingPids); private final AtomicBoolean refreshInProgress = new AtomicBoolean(false); @@ -235,18 +210,9 @@ public class CamelMonitor extends CamelCommand { private DataSourceTab dataSourceTab; private SqlQueryTab sqlQueryTab; - // "Switch integration" popup state - private boolean showSwitchPopup; - private final ListState switchPopupState = new ListState(); - private final FilesBrowser filesBrowser = new FilesBrowser(); - - // "More" dropdown state - private boolean showMorePopup; - private final ListState morePopupState = new ListState(); + private PopupManager popupManager; private MonitorTab activeMoreTab; - private int lastMoreSelection; - private Line[] currentTabLabels; private ClassLoader classLoader; @@ -271,7 +237,7 @@ public Integer doCall() throws Exception { System.setProperty("tamboui.record.fps", "10"); } - recording = record != null; + recordingManager.init(record != null); // to make ServiceLoader work with tamboui for downloaded JARs Thread.currentThread().setContextClassLoader(classLoader); @@ -309,6 +275,61 @@ public Integer doCall() throws Exception { overviewTab = new OverviewTab( ctx, metrics, stoppingPids, this::resetIntegrationTabState); + popupManager = new PopupManager( + ctx, this::getNonVanishingIntegrations, filesBrowser, + new PopupManager.PopupCallbacks() { + @Override + public void selectMoreTab(int index) { + CamelMonitor.this.selectMoreTab(index); + } + + @Override + public void resetIntegrationTabState() { + CamelMonitor.this.resetIntegrationTabState(); + } + + @Override + public void refreshLogData() { + CamelMonitor.this.refreshLogData(); + } + + @Override + public void stopSelectedProcess(boolean forceKill) { + CamelMonitor.this.stopSelectedProcess(forceKill); + } + }); + + overviewTab.setActions(new OverviewTab.OverviewActions() { + @Override + public void sendRouteCommand(String pid, String routeId, String command) { + CamelMonitor.this.sendRouteCommand(pid, routeId, command); + } + + @Override + public void stopSelectedProcess(boolean forceKill) { + CamelMonitor.this.stopSelectedProcess(forceKill); + } + + @Override + public void restartSelectedProcess() { + CamelMonitor.this.restartSelectedProcess(); + } + + @Override + public void showKillConfirm() { + popupManager.showKillConfirm(); + } + + @Override + public void openDoc(IntegrationInfo info) { + actionsPopup.openDoc(info); + } + + @Override + public void openFilesPopup() { + CamelMonitor.this.openFilesPopup(); + } + }); // Initial data load (synchronous before TUI starts) refreshDataSync(); @@ -316,34 +337,18 @@ public Integer doCall() throws Exception { // Auto-select if there's exactly one integration running overviewTab.selectCurrentIntegration(); - eventLog = new TuiEventLog(500); mcpFacade = new McpFacade( - ctx, data, tabsState, eventLog, + ctx, data, tabsState, recordingManager, captionOverlay, drawOverlay, helpOverlay, actionsPopup, filesBrowser, logTab, diagramTab, historyTab, - tapeRecorderRef, pendingKeys, + pendingKeys, new McpFacade.MonitorBridge() { @Override public MonitorTab activeTab() { return CamelMonitor.this.activeTab(); } - @Override - public Buffer lastBuffer() { - return lastBuffer; - } - - @Override - public long renderGeneration() { - return renderGeneration; - } - - @Override - public boolean isKeystrokesVisible() { - return recording; - } - @Override public void handleTabKey(int tabIndex) { CamelMonitor.this.handleTabKey(tabIndex); @@ -356,12 +361,12 @@ public void selectMoreTab(int moreIndex) { @Override public boolean isSwitchPopupVisible() { - return showSwitchPopup; + return popupManager.isSwitchPopupVisible(); } @Override public boolean isMorePopupVisible() { - return showMorePopup; + return popupManager.isMorePopupVisible(); } @Override @@ -433,36 +438,18 @@ public void stopProcess(boolean forceKill) { private boolean handleEvent(Event event, TuiRunner runner) { if (event instanceof KeyEvent ke) { - if (eventLog != null) { - String elabel = keyLabel(ke); - if (elabel != null) { - eventLog.record(elabel, elabel); - } - } - if (recording) { - String label = keyLabel(ke); - if (label != null) { - recentKeys.add(new KeyRecord(label, System.currentTimeMillis())); - } - } + recordingManager.recordKey(ke, mcpInjectedKey); if (ke.hasCtrl() && ke.isChar('r')) { - toggleTapeRecording(); + recordingManager.toggleTapeRecording(); return true; } - TapeRecorder tr = tapeRecorderRef.get(); - if (tr != null && tr.isActive() && !mcpInjectedKey) { - String label = keyLabel(ke); - if (label != null) { - tr.recordKey(label); - } - } if (captionOverlay.isVisible()) { if (captionOverlay.handleKeyEvent(ke)) { return true; } } if (ke.hasCtrl() && ke.isChar('k')) { - recording = !recording; + recordingManager.toggleRecording(); return true; } if (ke.hasCtrl() && ke.isChar('t')) { @@ -483,7 +470,7 @@ private boolean handleEvent(Event event, TuiRunner runner) { if (actionsPopup.isVisible()) { return actionsPopup.handleKeyEvent(ke); } - if (handlePopupKeys(ke)) { + if (popupManager.handleKeyEvent(ke, tabsState.selected(), TAB_LOG)) { return true; } if (handleGlobalKeys(ke, runner)) { @@ -507,79 +494,6 @@ private boolean handleEvent(Event event, TuiRunner runner) { return false; } - private boolean handlePopupKeys(KeyEvent ke) { - if (filesBrowser.isVisible()) { - return filesBrowser.handleKeyEvent(ke); - } - if (showMorePopup) { - if (ke.isCancel()) { - showMorePopup = false; - return true; - } - if (ke.isUp()) { - morePopupState.selectPrevious(); - return true; - } - if (ke.isDown()) { - morePopupState.selectNext(15); - return true; - } - int shortcutSel = morePopupShortcut(ke); - if (shortcutSel >= 0) { - morePopupState.select(shortcutSel); - } - if (ke.isConfirm() || shortcutSel >= 0) { - showMorePopup = false; - Integer sel = shortcutSel >= 0 ? shortcutSel : morePopupState.selected(); - if (sel != null) { - selectMoreTab(sel); - } - return true; - } - return true; - } - if (showSwitchPopup) { - if (ke.isCancel()) { - showSwitchPopup = false; - return true; - } - List switchList = getNonVanishingIntegrations(); - if (ke.isUp()) { - switchPopupState.selectPrevious(); - return true; - } - if (ke.isDown()) { - switchPopupState.selectNext(switchList.size()); - return true; - } - if (ke.isConfirm()) { - showSwitchPopup = false; - Integer sel = switchPopupState.selected(); - if (sel != null && sel >= 0 && sel < switchList.size()) { - IntegrationInfo chosen = switchList.get(sel); - ctx.selectedPid = chosen.pid; - ctx.lastSelectedName = chosen.name; - resetIntegrationTabState(); - if (tabsState.selected() == TAB_LOG) { - refreshLogData(); - } - } - return true; - } - return true; - } - if (showKillConfirm) { - if (ke.isConfirm()) { - showKillConfirm = false; - stopSelectedProcess(true); - } else { - showKillConfirm = false; - } - return true; - } - return false; - } - private boolean handleGlobalKeys(KeyEvent ke, TuiRunner runner) { if (ke.isCancel()) { MonitorTab tab = activeTab(); @@ -673,7 +587,7 @@ private boolean handleGlobalKeys(KeyEvent ke, TuiRunner runner) { return true; } if (ke.isKey(KeyCode.F5) && ke.hasShift()) { - takeScreenshot(); + recordingManager.takeScreenshot(); return true; } if (opensHelp(ke, textEditing)) { @@ -704,16 +618,7 @@ private boolean handleGlobalKeys(KeyEvent ke, TuiRunner runner) { return true; } if (ke.isKey(KeyCode.F3)) { - List switchList = getNonVanishingIntegrations(); - if (switchList.size() > 1) { - showSwitchPopup = true; - for (int i = 0; i < switchList.size(); i++) { - if (switchList.get(i).pid.equals(ctx.selectedPid)) { - switchPopupState.select(i); - break; - } - } - } + popupManager.openSwitchPopup(ctx.selectedPid, getNonVanishingIntegrations()); return true; } return false; @@ -786,37 +691,6 @@ private boolean handleTabKeys(KeyEvent ke) { } return true; } - if (tab == TAB_OVERVIEW && ke.isChar('p') && ctx.selectedPid != null && !isInfraSelected()) { - IntegrationInfo selInfo = findSelectedIntegration(); - if (selInfo != null) { - String cmd = selInfo.routeStarted > 0 ? "stop" : "start"; - sendRouteCommand(ctx.selectedPid, "*", cmd); - } - return true; - } - if (tab == TAB_OVERVIEW && ke.isChar('x') && ctx.selectedPid != null) { - stopSelectedProcess(false); - return true; - } - if (tab == TAB_OVERVIEW && ke.isChar('X') && ctx.selectedPid != null) { - showKillConfirm = true; - return true; - } - if (tab == TAB_OVERVIEW && ke.isChar('r') && ctx.selectedPid != null && !isInfraSelected()) { - restartSelectedProcess(); - return true; - } - if (tab == TAB_OVERVIEW && ke.isChar('d') && ctx.selectedPid != null && !isInfraSelected()) { - IntegrationInfo selInfo = findSelectedIntegration(); - if (selInfo != null && selInfo.readmeFiles != null && !selInfo.readmeFiles.isEmpty()) { - actionsPopup.openDoc(selInfo); - return true; - } - } - if (tab == TAB_OVERVIEW && ke.isChar('f') && ctx.selectedPid != null && !isInfraSelected()) { - openFilesPopup(); - return true; - } if (activeTab != null && activeTab.handleKeyEvent(ke)) { return true; } @@ -864,10 +738,7 @@ private boolean handleTickEvent(TuiRunner runner) { actionsPopup.tick(now); drawOverlay.tick(now); captionOverlay.tick(now); - if (recording && !recentKeys.isEmpty()) { - long cutoff = now - 2000; - recentKeys.removeIf(k -> k.timestamp() < cutoff); - } + recordingManager.tickRecentKeys(now); boolean anyDiagramShowing = routesTab.isShowDiagram() || diagramTab.isShowDiagram(); long interval = anyDiagramShowing ? Math.max(refreshInterval, 1000) : refreshInterval; if (now - lastRefresh >= interval) { @@ -879,65 +750,6 @@ private boolean handleTickEvent(TuiRunner runner) { return true; } - private String keyLabel(KeyEvent ke) { - if (ke.isKey(KeyCode.ENTER)) { - return "Enter"; - } - if (ke.isKey(KeyCode.ESCAPE)) { - return "Esc"; - } - if (ke.isKey(KeyCode.TAB)) { - return ke.hasShift() ? "⇧Tab" : "Tab"; - } - if (ke.isKey(KeyCode.UP)) { - return "↑"; - } - if (ke.isKey(KeyCode.DOWN)) { - return "↓"; - } - if (ke.isKey(KeyCode.LEFT)) { - return "←"; - } - if (ke.isKey(KeyCode.RIGHT)) { - return "→"; - } - if (ke.isKey(KeyCode.PAGE_UP)) { - return "PgUp"; - } - if (ke.isKey(KeyCode.PAGE_DOWN)) { - return "PgDn"; - } - if (ke.isKey(KeyCode.HOME)) { - return "Home"; - } - if (ke.isKey(KeyCode.END)) { - return "End"; - } - if (ke.isKey(KeyCode.BACKSPACE)) { - return "⌫"; - } - for (int i = 1; i <= 12; i++) { - try { - KeyCode fKey = KeyCode.valueOf("F" + i); - if (ke.isKey(fKey)) { - return "F" + i; - } - } catch (IllegalArgumentException e) { - break; - } - } - if (ke.code() == KeyCode.CHAR) { - String s = ke.string(); - if (" ".equals(s)) { - return "Space"; - } - if (!s.isEmpty()) { - return s; - } - } - return null; - } - private boolean handleTabKey(int tab) { if (tab != TAB_OVERVIEW) { overviewTab.selectCurrentIntegration(); @@ -974,20 +786,16 @@ private boolean handleTabKey(int tab) { errorsTab.onTabSelected(); } if (tab == TAB_MORE) { - showMorePopup = !showMorePopup; - if (showMorePopup) { - morePopupState.select(lastMoreSelection); - } + popupManager.openMorePopup(); return true; } - showMorePopup = false; + popupManager.closeMorePopup(); tabsState.select(tab); return true; } void selectMoreTab(int index) { - morePopupState.select(index); - lastMoreSelection = index; + popupManager.selectMorePopupEntry(index); activeMoreTab = switch (index) { case 0 -> beansTab; case 1 -> browseTab; @@ -1096,8 +904,8 @@ private void render(Frame frame) { if (drawOverlay.isVisible()) { drawOverlay.render(frame, contentArea); } - if (showKillConfirm) { - renderKillConfirm(frame, contentArea); + if (popupManager.isKillConfirmVisible()) { + popupManager.renderKillConfirm(frame, contentArea); } actionsPopup.render(frame, contentArea); if (captionOverlay.isCaptionVisible()) { @@ -1108,13 +916,8 @@ private void render(Frame frame) { } renderFooter(frame, mainChunks.get(3)); - lastBuffer = frame.buffer(); - renderGeneration++; - - if (pendingScreenshot) { - pendingScreenshot = false; - takeScreenshot(); - } + recordingManager.updateBuffer(frame.buffer()); + recordingManager.processPendingScreenshot(); } private void renderHeader(Frame frame, Rect area) { @@ -1262,7 +1065,7 @@ private void renderTabs(Frame frame, Rect area) { Line.from(" 9 Errors "), Line.from(" 0 More▾ "), }; - currentTabLabels = labels; + popupManager.setCurrentTabLabels(labels); Tabs tabs = Tabs.builder() .titles(labels) @@ -1306,12 +1109,12 @@ private void renderContent(Frame frame, Rect area) { MonitorTab tab = activeTab(); tab.render(frame, area); // Render "More" popup overlay when visible - if (showMorePopup) { - renderMorePopup(frame, area); + if (popupManager.isMorePopupVisible()) { + popupManager.renderMorePopup(frame, area); } // Render "Switch integration" popup overlay when visible - if (showSwitchPopup) { - renderSwitchPopup(frame, area); + if (popupManager.isSwitchPopupVisible()) { + popupManager.renderSwitchPopup(frame, area); } // Render "Files" popup overlay when visible if (filesBrowser.isVisible()) { @@ -1399,107 +1202,6 @@ private void computeTabBadges(String[] badgeTexts, Style[] badgeStyles) { } } - private void renderMorePopup(Frame frame, Rect area) { - int popupW = 22; - int popupH = 17; - // Position just below the "0 More▾" tab label - int dividerW = CharWidth.of(" | "); - int tabBarX = 0; - Line[] tabLabels = currentTabLabels; - if (tabLabels != null) { - for (int i = 0; i < tabLabels.length - 1; i++) { - tabBarX += tabLabels[i].width(); - tabBarX += dividerW; - } - } - int x = area.left() + tabBarX; - int y = area.top(); - if (x + popupW > area.right()) { - x = Math.max(area.left(), area.right() - popupW); - } - Rect popup = new Rect(x, y, Math.min(popupW, area.width() - (x - area.left())), Math.min(popupH, area.height())); - - frame.renderWidget(Clear.INSTANCE, popup); - - Style keyStyle = Style.EMPTY.fg(Color.YELLOW).bold(); - ListItem[] items = { - ListItem.from(Line.from(Span.raw(" "), Span.styled("B", keyStyle), Span.raw("eans"))), - ListItem.from(Line.from(Span.raw(" Bro"), Span.styled("w", keyStyle), Span.raw("se"))), - ListItem.from(Line.from(Span.raw(" "), Span.styled("C", keyStyle), Span.raw("ircuit Breaker"))), - ListItem.from(Line.from(Span.raw(" Cl"), Span.styled("a", keyStyle), Span.raw("sspath"))), - ListItem.from(Line.from(Span.raw(" Confi"), Span.styled("g", keyStyle), Span.raw("uration"))), - ListItem.from(Line.from(Span.raw(" Co"), Span.styled("n", keyStyle), Span.raw("sumers"))), - ListItem.from(Line.from(Span.raw(" "), Span.styled("D", keyStyle), Span.raw("ataSource"))), - ListItem.from(Line.from(Span.raw(" "), Span.styled("I", keyStyle), Span.raw("nflight"))), - ListItem.from(Line.from(Span.raw(" "), Span.styled("M", keyStyle), Span.raw("emory"))), - ListItem.from(Line.from(Span.raw(" M"), Span.styled("e", keyStyle), Span.raw("trics"))), - ListItem.from(Line.from(Span.raw(" S"), Span.styled("Q", keyStyle), Span.raw("L Query"))), - ListItem.from(Line.from(Span.raw(" "), Span.styled("O", keyStyle), Span.raw("Tel Spans"))), - ListItem.from(Line.from(Span.raw(" "), Span.styled("P", keyStyle), Span.raw("rocess"))), - ListItem.from(Line.from(Span.raw(" "), Span.styled("S", keyStyle), Span.raw("tartup"))), - ListItem.from(Line.from(Span.raw(" "), Span.styled("T", keyStyle), Span.raw("hreads"))), - }; - ListWidget list = ListWidget.builder() - .items(items) - .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue()) - .highlightSymbol("") - .scrollMode(ScrollMode.NONE) - .block(Block.builder() - .borderType(BorderType.ROUNDED).borders(Borders.ALL) - .title(Title.from(Line.from(Span.styled(" More Tabs ", Style.EMPTY.fg(Color.YELLOW).bold())))) - .build()) - .build(); - frame.renderStatefulWidget(list, popup, morePopupState); - } - - private void renderSwitchPopup(Frame frame, Rect area) { - List integrations = getNonVanishingIntegrations(); - if (integrations.isEmpty()) { - showSwitchPopup = false; - return; - } - - int maxLabelLen = integrations.stream() - .mapToInt(i -> { - String n = i.name != null ? i.name : "?"; - return n.length() + i.pid.length() + 14; - }) - .max().orElse(30); - int popupW = Math.min(area.width() - 4, Math.max(40, maxLabelLen + 4)); - int popupH = Math.min(area.height() - 4, integrations.size() + 2); - - int x = area.left() + Math.max(0, (area.width() - popupW) / 2); - int y = area.top() + 2; - Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height() - 2)); - - frame.renderWidget(Clear.INSTANCE, popup); - - ListItem[] items = new ListItem[integrations.size()]; - for (int i = 0; i < integrations.size(); i++) { - IntegrationInfo info = integrations.get(i); - String name = info.name != null ? info.name : "?"; - boolean current = info.pid.equals(ctx.selectedPid); - String label = String.format(" 🐪 %s (pid:%s)%s", name, info.pid, current ? " ●" : ""); - if (current) { - items[i] = ListItem.from(Line.from(Span.styled(label, Style.EMPTY.fg(Color.CYAN)))); - } else { - items[i] = ListItem.from(label); - } - } - - ListWidget list = ListWidget.builder() - .items(items) - .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue()) - .highlightSymbol("") - .scrollMode(ScrollMode.NONE) - .block(Block.builder() - .borderType(BorderType.ROUNDED).borders(Borders.ALL) - .title(Title.from(Line.from(Span.styled(" Switch Integration ", Style.EMPTY.fg(Color.YELLOW).bold())))) - .build()) - .build(); - frame.renderStatefulWidget(list, popup, switchPopupState); - } - private void openFilesPopup() { IntegrationInfo info = findSelectedIntegration(); if (info != null) { @@ -1514,55 +1216,6 @@ private List getNonVanishingIntegrations() { .collect(Collectors.toList()); } - private static int morePopupShortcut(KeyEvent ke) { - if (ke.isChar('b')) { - return 0; - } - if (ke.isChar('w')) { - return 1; - } - if (ke.isChar('c')) { - return 2; - } - if (ke.isChar('a')) { - return 3; - } - if (ke.isChar('g')) { - return 4; - } - if (ke.isChar('n')) { - return 5; - } - if (ke.isChar('d')) { - return 6; - } - if (ke.isChar('i')) { - return 7; - } - if (ke.isChar('m')) { - return 8; - } - if (ke.isChar('e')) { - return 9; - } - if (ke.isChar('q')) { - return 10; - } - if (ke.isChar('o')) { - return 11; - } - if (ke.isChar('p')) { - return 12; - } - if (ke.isChar('s')) { - return 13; - } - if (ke.isChar('t')) { - return 14; - } - return -1; - } - private MonitorTab activeTab() { return switch (tabsState.selected()) { case TAB_OVERVIEW -> overviewTab; @@ -1771,49 +1424,15 @@ private void sendRouteCommand(String pid, String routeId, String command) { PathUtils.writeTextSafely(root.toJson(), actionFile); } - private void renderKillConfirm(Frame frame, Rect area) { - String name = selectedName(); - String msg = " Kill " + name + " (PID: " + ctx.selectedPid + ")? "; - int popupW = Math.max(34, msg.length() + 4); - int popupH = 6; - int x = area.left() + Math.max(0, (area.width() - popupW) / 2); - int y = area.top() + Math.max(0, (area.height() - popupH) / 2); - Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height())); - - frame.renderWidget(Clear.INSTANCE, popup); - Block block = Block.builder() - .borderType(BorderType.ROUNDED).borders(Borders.ALL) - .borderStyle(Style.EMPTY.fg(Color.LIGHT_RED)) - .title(" Confirm Kill ") - .build(); - frame.renderWidget(block, popup); - Rect inner = block.inner(popup); - frame.renderWidget( - Paragraph.builder() - .text(Text.from( - Line.from(Span.raw("")), - Line.from(Span.styled(msg, Style.EMPTY.fg(Color.LIGHT_RED).bold())), - Line.from(Span.raw("")), - Line.from( - Span.raw(" "), - Span.styled("Enter", Style.EMPTY.bold()), - Span.raw(" confirm "), - Span.styled("Esc", Style.EMPTY.bold()), - Span.raw(" cancel")))) - .build(), - inner); - } - private void renderFooter(Frame frame, Rect area) { // Show screenshot flash message briefly - String msg = screenshotMessage; - if (msg != null && System.currentTimeMillis() - screenshotMessageTime < 5000) { + String msg = recordingManager.screenshotFlashMessage(); + if (msg != null) { frame.renderWidget( Paragraph.from(Line.from(Span.styled(" " + msg, Style.EMPTY.fg(Color.GREEN)))), area); return; } - screenshotMessage = null; List spans = new ArrayList<>(); int fKeyTotal = 0; @@ -1832,11 +1451,11 @@ private void renderFooter(Frame frame, Rect area) { if (filesBrowser.isVisible()) { filesBrowser.renderFooter(spans); - } else if (showSwitchPopup) { + } else if (popupManager.isSwitchPopupVisible()) { hint(spans, "Up/Down", "select"); hint(spans, "Enter", "switch"); hint(spans, "Esc", "close"); - } else if (showMorePopup) { + } else if (popupManager.isMorePopupVisible()) { hint(spans, "Up/Down", "select"); hint(spans, "Enter", "open"); hint(spans, "Esc", "close"); @@ -1855,11 +1474,12 @@ private void renderFooter(Frame frame, Rect area) { List rightSpans = new ArrayList<>(); - if (recording && !recentKeys.isEmpty()) { + if (recordingManager.isRecording() && !recordingManager.getRecentKeys().isEmpty()) { long now = System.currentTimeMillis(); + List recentKeys = recordingManager.getRecentKeys(); int maxKeys = Math.min(recentKeys.size(), 8); - List visible = recentKeys.subList(recentKeys.size() - maxKeys, recentKeys.size()); - for (KeyRecord kr : visible) { + List visible = recentKeys.subList(recentKeys.size() - maxKeys, recentKeys.size()); + for (RecordingManager.KeyRecord kr : visible) { long age = now - kr.timestamp(); Style style = age < 1000 ? Style.EMPTY.fg(Color.WHITE).bold().onBlue() @@ -1932,7 +1552,7 @@ private int insertFKeyHints(List spans) { hint(fKeySpans, "F1", "help"); } hint(fKeySpans, "F2", "actions"); - if (getNonVanishingIntegrations().size() > 1) { + if (popupManager.getNonVanishingIntegrations().size() > 1) { hint(fKeySpans, "F3", "switch"); } hint(fKeySpans, "F6", "shell"); @@ -2053,7 +1673,8 @@ private void refreshDataSync() { private boolean scanIntegrations() { List infos = new ArrayList<>(); long now = System.currentTimeMillis(); - boolean wantFullScan = tabsState.selected() == TAB_OVERVIEW || showSwitchPopup || cachedPids.isEmpty(); + boolean wantFullScan = tabsState.selected() == TAB_OVERVIEW || popupManager.isSwitchPopupVisible() + || cachedPids.isEmpty(); long scanInterval = isBurstMode() ? 1000 : 2000; boolean fullScan = wantFullScan && (now - lastFullScanTime >= scanInterval); List pids; @@ -2507,31 +2128,6 @@ private JsonObject loadStatus(long pid) { return TuiHelper.loadStatus(pid, this::getStatusFile); } - private void takeScreenshot() { - Buffer buf = lastBuffer; - if (buf == null) { - return; - } - try { - String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); - String baseName = "camel-tui-screenshot-" + timestamp; - Path svgPath = Path.of(baseName + ".svg"); - Path txtPath = Path.of(baseName + ".txt"); - Path ansPath = Path.of(baseName + ".ans"); - ExportRequest.export(buf).svg().toFile(svgPath); - ExportRequest.export(buf).text().toFile(txtPath); - ExportRequest.export(buf).text().options(o -> o.styles(true)).toFile(ansPath); - screenshotMessage = "Screenshot saved to " + svgPath.toAbsolutePath() + " (and .txt, .ans)"; - screenshotMessageTime = System.currentTimeMillis(); - } catch (IOException e) { - screenshotMessage = "Screenshot failed: " + e.getMessage(); - screenshotMessageTime = System.currentTimeMillis(); - } - } - - record KeyRecord(String label, long timestamp) { - } - record VanishingInfo(IntegrationInfo info, long startTime) { } @@ -2568,26 +2164,4 @@ private static void deleteMcpJson(Path path) { } } - private void toggleTapeRecording() { - TapeRecorder rec = tapeRecorderRef.get(); - if (rec != null && rec.isActive()) { - String tape = rec.stop(); - tapeRecorderRef.set(null); - String timestamp = java.time.LocalDateTime.now() - .format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); - String filename = "camel-tui-tape-" + timestamp + ".tape"; - try { - java.nio.file.Files.writeString(java.nio.file.Path.of(filename), tape); - captionOverlay.showCaption("Tape saved: " + filename, 5); - } catch (java.io.IOException e) { - captionOverlay.showCaption("Failed to save tape: " + e.getMessage(), 5); - } - } else { - rec = new TapeRecorder(); - rec.start(null); - tapeRecorderRef.set(rec); - captionOverlay.showCaption("Tape recording started", 3); - } - } - } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java index 1bd15fe728e36..eaf52596a0320 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java @@ -62,12 +62,6 @@ interface MonitorBridge { MonitorTab activeTab(); - Buffer lastBuffer(); - - long renderGeneration(); - - boolean isKeystrokesVisible(); - void handleTabKey(int tabIndex); void selectMoreTab(int moreIndex); @@ -104,7 +98,7 @@ interface MonitorBridge { private final MonitorContext ctx; private final AtomicReference> data; private final TabsState tabsState; - private final TuiEventLog eventLog; + private final RecordingManager recordingManager; private final CaptionOverlay captionOverlay; private final DrawOverlay drawOverlay; private final HelpOverlay helpOverlay; @@ -113,7 +107,6 @@ interface MonitorBridge { private final LogTab logTab; private final DiagramTab diagramTab; private final HistoryTab historyTab; - private final AtomicReference tapeRecorderRef; private final Queue pendingKeys; private final MonitorBridge bridge; @@ -121,7 +114,7 @@ interface MonitorBridge { MonitorContext ctx, AtomicReference> data, TabsState tabsState, - TuiEventLog eventLog, + RecordingManager recordingManager, CaptionOverlay captionOverlay, DrawOverlay drawOverlay, HelpOverlay helpOverlay, @@ -130,13 +123,12 @@ interface MonitorBridge { LogTab logTab, DiagramTab diagramTab, HistoryTab historyTab, - AtomicReference tapeRecorderRef, Queue pendingKeys, MonitorBridge bridge) { this.ctx = ctx; this.data = data; this.tabsState = tabsState; - this.eventLog = eventLog; + this.recordingManager = recordingManager; this.captionOverlay = captionOverlay; this.drawOverlay = drawOverlay; this.helpOverlay = helpOverlay; @@ -145,7 +137,6 @@ interface MonitorBridge { this.logTab = logTab; this.diagramTab = diagramTab; this.historyTab = historyTab; - this.tapeRecorderRef = tapeRecorderRef; this.pendingKeys = pendingKeys; this.bridge = bridge; } @@ -153,40 +144,37 @@ interface MonitorBridge { // ---- Screen state ---- Buffer getLastBuffer() { - return bridge.lastBuffer(); + return recordingManager.getLastBuffer(); } long getRenderGeneration() { - return bridge.renderGeneration(); + return recordingManager.getRenderGeneration(); } // ---- Recording / events ---- boolean isKeystrokesVisible() { - return bridge.isKeystrokesVisible(); + return recordingManager.isRecording(); } TapeRecorder getTapeRecorder() { - return tapeRecorderRef.get(); + return recordingManager.getTapeRecorder(); } boolean isTapeRecording() { - TapeRecorder rec = tapeRecorderRef.get(); - return rec != null && rec.isActive(); + return recordingManager.isTapeRecording(); } void startTapeRecording(String title) { - TapeRecorder rec = new TapeRecorder(); - rec.start(title); - tapeRecorderRef.set(rec); + recordingManager.startTapeRecording(title); } void clearTapeRecorder() { - tapeRecorderRef.set(null); + recordingManager.clearTapeRecorder(); } TuiEventLog getEventLog() { - return eventLog; + return recordingManager.getEventLog(); } // ---- Navigation state ---- @@ -499,7 +487,7 @@ JsonObject getDiagramState() { // ---- Screen location ---- JsonArray locateText(String search) { - Buffer buf = bridge.lastBuffer(); + Buffer buf = recordingManager.getLastBuffer(); if (buf == null || search == null || search.isEmpty()) { return new JsonArray(); } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java index f10de9aac7de2..b45dea4e93a52 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java @@ -53,6 +53,23 @@ class OverviewTab implements MonitorTab { + /** + * Callback interface for process control actions triggered from overview key shortcuts. + */ + interface OverviewActions { + void sendRouteCommand(String pid, String routeId, String command); + + void stopSelectedProcess(boolean forceKill); + + void restartSelectedProcess(); + + void showKillConfirm(); + + void openDoc(IntegrationInfo info); + + void openFilesPopup(); + } + private static final long VANISH_DURATION_MS = 6000; private static final int MAX_SPARKLINE_POINTS = 60; private static final String[] SORT_COLUMNS = { "pid", "name", "version", "status", "total", "fail" }; @@ -67,6 +84,7 @@ class OverviewTab implements MonitorTab { private final Map cpuLoadAvg; private final Set stoppingPids; private final Runnable onPidChanged; + private OverviewActions actions; final TableState tableState = new TableState(); int dividerIndex = -1; @@ -89,6 +107,10 @@ class OverviewTab implements MonitorTab { this.onPidChanged = onPidChanged; } + void setActions(OverviewActions actions) { + this.actions = actions; + } + @Override public boolean handleKeyEvent(KeyEvent ke) { if (ke.isChar('s')) { @@ -105,6 +127,40 @@ public boolean handleKeyEvent(KeyEvent ke) { chartMode = (chartMode + 1) % 3; return true; } + // Process control keys + if (actions != null) { + if (ke.isChar('p') && ctx.selectedPid != null && !ctx.isInfraSelected()) { + IntegrationInfo selInfo = ctx.findSelectedIntegration(); + if (selInfo != null) { + String cmd = selInfo.routeStarted > 0 ? "stop" : "start"; + actions.sendRouteCommand(ctx.selectedPid, "*", cmd); + } + return true; + } + if (ke.isChar('x') && ctx.selectedPid != null) { + actions.stopSelectedProcess(false); + return true; + } + if (ke.isChar('X') && ctx.selectedPid != null) { + actions.showKillConfirm(); + return true; + } + if (ke.isChar('r') && ctx.selectedPid != null && !ctx.isInfraSelected()) { + actions.restartSelectedProcess(); + return true; + } + if (ke.isChar('d') && ctx.selectedPid != null && !ctx.isInfraSelected()) { + IntegrationInfo selInfo = ctx.findSelectedIntegration(); + if (selInfo != null && selInfo.readmeFiles != null && !selInfo.readmeFiles.isEmpty()) { + actions.openDoc(selInfo); + return true; + } + } + if (ke.isChar('f') && ctx.selectedPid != null && !ctx.isInfraSelected()) { + actions.openFilesPopup(); + return true; + } + } return false; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java new file mode 100644 index 0000000000000..6a615d8080374 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java @@ -0,0 +1,429 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.List; +import java.util.function.Supplier; + +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +import dev.tamboui.style.Style; +import dev.tamboui.terminal.Frame; +import dev.tamboui.text.CharWidth; +import dev.tamboui.text.Line; +import dev.tamboui.text.Span; +import dev.tamboui.text.Text; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.widgets.Clear; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.block.Borders; +import dev.tamboui.widgets.block.Title; +import dev.tamboui.widgets.list.ListItem; +import dev.tamboui.widgets.list.ListState; +import dev.tamboui.widgets.list.ListWidget; +import dev.tamboui.widgets.list.ScrollMode; +import dev.tamboui.widgets.paragraph.Paragraph; + +/** + * Manages the switch-integration, more-tabs, and kill-confirm popups. Extracted from {@link CamelMonitor} to reduce + * class size. + */ +class PopupManager { + + /** + * Callback interface for actions the popup triggers in CamelMonitor. + */ + interface PopupCallbacks { + void selectMoreTab(int index); + + void resetIntegrationTabState(); + + void refreshLogData(); + + void stopSelectedProcess(boolean forceKill); + } + + private final MonitorContext ctx; + private final Supplier> nonVanishingIntegrationsSupplier; + private final PopupCallbacks callbacks; + private final FilesBrowser filesBrowser; + + // "Switch integration" popup state + private boolean showSwitchPopup; + private final ListState switchPopupState = new ListState(); + + // "More" dropdown state + private boolean showMorePopup; + private final ListState morePopupState = new ListState(); + private int lastMoreSelection; + private Line[] currentTabLabels; + + // Kill confirm + private boolean showKillConfirm; + + PopupManager(MonitorContext ctx, Supplier> nonVanishingIntegrationsSupplier, + FilesBrowser filesBrowser, PopupCallbacks callbacks) { + this.ctx = ctx; + this.nonVanishingIntegrationsSupplier = nonVanishingIntegrationsSupplier; + this.filesBrowser = filesBrowser; + this.callbacks = callbacks; + } + + // ---- State queries ---- + + boolean isAnyPopupVisible() { + return showSwitchPopup || showMorePopup || showKillConfirm || filesBrowser.isVisible(); + } + + boolean isSwitchPopupVisible() { + return showSwitchPopup; + } + + boolean isMorePopupVisible() { + return showMorePopup; + } + + boolean isKillConfirmVisible() { + return showKillConfirm; + } + + int getLastMoreSelection() { + return lastMoreSelection; + } + + Line[] getCurrentTabLabels() { + return currentTabLabels; + } + + void setCurrentTabLabels(Line[] labels) { + this.currentTabLabels = labels; + } + + // ---- Open/close ---- + + void openSwitchPopup(String currentPid, List integrations) { + if (integrations.size() > 1) { + showSwitchPopup = true; + for (int i = 0; i < integrations.size(); i++) { + if (integrations.get(i).pid.equals(currentPid)) { + switchPopupState.select(i); + break; + } + } + } + } + + void openMorePopup() { + showMorePopup = !showMorePopup; + if (showMorePopup) { + morePopupState.select(lastMoreSelection); + } + } + + void closeMorePopup() { + showMorePopup = false; + } + + void showKillConfirm() { + showKillConfirm = true; + } + + void selectMorePopupEntry(int index) { + morePopupState.select(index); + lastMoreSelection = index; + } + + // ---- Key handling ---- + + boolean handleKeyEvent(KeyEvent ke, int selectedTab, int tabLog) { + if (filesBrowser.isVisible()) { + return filesBrowser.handleKeyEvent(ke); + } + if (showMorePopup) { + return handleMorePopupKeys(ke); + } + if (showSwitchPopup) { + return handleSwitchPopupKeys(ke, selectedTab, tabLog); + } + if (showKillConfirm) { + return handleKillConfirmKeys(ke); + } + return false; + } + + private boolean handleMorePopupKeys(KeyEvent ke) { + if (ke.isCancel()) { + showMorePopup = false; + return true; + } + if (ke.isUp()) { + morePopupState.selectPrevious(); + return true; + } + if (ke.isDown()) { + morePopupState.selectNext(15); + return true; + } + int shortcutSel = morePopupShortcut(ke); + if (shortcutSel >= 0) { + morePopupState.select(shortcutSel); + } + if (ke.isConfirm() || shortcutSel >= 0) { + showMorePopup = false; + Integer sel = shortcutSel >= 0 ? shortcutSel : morePopupState.selected(); + if (sel != null) { + callbacks.selectMoreTab(sel); + } + return true; + } + return true; + } + + private boolean handleSwitchPopupKeys(KeyEvent ke, int selectedTab, int tabLog) { + if (ke.isCancel()) { + showSwitchPopup = false; + return true; + } + List switchList = nonVanishingIntegrationsSupplier.get(); + if (ke.isUp()) { + switchPopupState.selectPrevious(); + return true; + } + if (ke.isDown()) { + switchPopupState.selectNext(switchList.size()); + return true; + } + if (ke.isConfirm()) { + showSwitchPopup = false; + Integer sel = switchPopupState.selected(); + if (sel != null && sel >= 0 && sel < switchList.size()) { + IntegrationInfo chosen = switchList.get(sel); + ctx.selectedPid = chosen.pid; + ctx.lastSelectedName = chosen.name; + callbacks.resetIntegrationTabState(); + if (selectedTab == tabLog) { + callbacks.refreshLogData(); + } + } + return true; + } + return true; + } + + private boolean handleKillConfirmKeys(KeyEvent ke) { + if (ke.isConfirm()) { + showKillConfirm = false; + callbacks.stopSelectedProcess(true); + } else { + showKillConfirm = false; + } + return true; + } + + // ---- Rendering ---- + + void renderMorePopup(Frame frame, Rect area) { + int popupW = 22; + int popupH = 17; + // Position just below the "0 More▾" tab label + int dividerW = CharWidth.of(" | "); + int tabBarX = 0; + Line[] tabLabels = currentTabLabels; + if (tabLabels != null) { + for (int i = 0; i < tabLabels.length - 1; i++) { + tabBarX += tabLabels[i].width(); + tabBarX += dividerW; + } + } + int x = area.left() + tabBarX; + int y = area.top(); + if (x + popupW > area.right()) { + x = Math.max(area.left(), area.right() - popupW); + } + Rect popup = new Rect(x, y, Math.min(popupW, area.width() - (x - area.left())), Math.min(popupH, area.height())); + + frame.renderWidget(Clear.INSTANCE, popup); + + Style keyStyle = Style.EMPTY.fg(Color.YELLOW).bold(); + ListItem[] items = { + ListItem.from(Line.from(Span.raw(" "), Span.styled("B", keyStyle), Span.raw("eans"))), + ListItem.from(Line.from(Span.raw(" Bro"), Span.styled("w", keyStyle), Span.raw("se"))), + ListItem.from(Line.from(Span.raw(" "), Span.styled("C", keyStyle), Span.raw("ircuit Breaker"))), + ListItem.from(Line.from(Span.raw(" Cl"), Span.styled("a", keyStyle), Span.raw("sspath"))), + ListItem.from(Line.from(Span.raw(" Confi"), Span.styled("g", keyStyle), Span.raw("uration"))), + ListItem.from(Line.from(Span.raw(" Co"), Span.styled("n", keyStyle), Span.raw("sumers"))), + ListItem.from(Line.from(Span.raw(" "), Span.styled("D", keyStyle), Span.raw("ataSource"))), + ListItem.from(Line.from(Span.raw(" "), Span.styled("I", keyStyle), Span.raw("nflight"))), + ListItem.from(Line.from(Span.raw(" "), Span.styled("M", keyStyle), Span.raw("emory"))), + ListItem.from(Line.from(Span.raw(" M"), Span.styled("e", keyStyle), Span.raw("trics"))), + ListItem.from(Line.from(Span.raw(" S"), Span.styled("Q", keyStyle), Span.raw("L Query"))), + ListItem.from(Line.from(Span.raw(" "), Span.styled("O", keyStyle), Span.raw("Tel Spans"))), + ListItem.from(Line.from(Span.raw(" "), Span.styled("P", keyStyle), Span.raw("rocess"))), + ListItem.from(Line.from(Span.raw(" "), Span.styled("S", keyStyle), Span.raw("tartup"))), + ListItem.from(Line.from(Span.raw(" "), Span.styled("T", keyStyle), Span.raw("hreads"))), + }; + ListWidget list = ListWidget.builder() + .items(items) + .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue()) + .highlightSymbol("") + .scrollMode(ScrollMode.NONE) + .block(Block.builder() + .borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(Title.from(Line.from(Span.styled(" More Tabs ", Style.EMPTY.fg(Color.YELLOW).bold())))) + .build()) + .build(); + frame.renderStatefulWidget(list, popup, morePopupState); + } + + void renderSwitchPopup(Frame frame, Rect area) { + List integrations = nonVanishingIntegrationsSupplier.get(); + if (integrations.isEmpty()) { + showSwitchPopup = false; + return; + } + + int maxLabelLen = integrations.stream() + .mapToInt(i -> { + String n = i.name != null ? i.name : "?"; + return n.length() + i.pid.length() + 14; + }) + .max().orElse(30); + int popupW = Math.min(area.width() - 4, Math.max(40, maxLabelLen + 4)); + int popupH = Math.min(area.height() - 4, integrations.size() + 2); + + int x = area.left() + Math.max(0, (area.width() - popupW) / 2); + int y = area.top() + 2; + Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height() - 2)); + + frame.renderWidget(Clear.INSTANCE, popup); + + ListItem[] items = new ListItem[integrations.size()]; + for (int i = 0; i < integrations.size(); i++) { + IntegrationInfo info = integrations.get(i); + String name = info.name != null ? info.name : "?"; + boolean current = info.pid.equals(ctx.selectedPid); + String label = String.format(" 🐪 %s (pid:%s)%s", name, info.pid, current ? " ●" : ""); + if (current) { + items[i] = ListItem.from(Line.from(Span.styled(label, Style.EMPTY.fg(Color.CYAN)))); + } else { + items[i] = ListItem.from(label); + } + } + + ListWidget listWidget = ListWidget.builder() + .items(items) + .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue()) + .highlightSymbol("") + .scrollMode(ScrollMode.NONE) + .block(Block.builder() + .borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(Title.from( + Line.from(Span.styled(" Switch Integration ", Style.EMPTY.fg(Color.YELLOW).bold())))) + .build()) + .build(); + frame.renderStatefulWidget(listWidget, popup, switchPopupState); + } + + void renderKillConfirm(Frame frame, Rect area) { + String name = ctx.selectedName(); + String msg = " Kill " + name + " (PID: " + ctx.selectedPid + ")? "; + int popupW = Math.max(34, msg.length() + 4); + int popupH = 6; + int x = area.left() + Math.max(0, (area.width() - popupW) / 2); + int y = area.top() + Math.max(0, (area.height() - popupH) / 2); + Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height())); + + frame.renderWidget(Clear.INSTANCE, popup); + Block block = Block.builder() + .borderType(BorderType.ROUNDED).borders(Borders.ALL) + .borderStyle(Style.EMPTY.fg(Color.LIGHT_RED)) + .title(" Confirm Kill ") + .build(); + frame.renderWidget(block, popup); + Rect inner = block.inner(popup); + frame.renderWidget( + Paragraph.builder() + .text(Text.from( + Line.from(Span.raw("")), + Line.from(Span.styled(msg, Style.EMPTY.fg(Color.LIGHT_RED).bold())), + Line.from(Span.raw("")), + Line.from( + Span.raw(" "), + Span.styled("Enter", Style.EMPTY.bold()), + Span.raw(" confirm "), + Span.styled("Esc", Style.EMPTY.bold()), + Span.raw(" cancel")))) + .build(), + inner); + } + + // ---- Static utilities ---- + + static int morePopupShortcut(KeyEvent ke) { + if (ke.isChar('b')) { + return 0; + } + if (ke.isChar('w')) { + return 1; + } + if (ke.isChar('c')) { + return 2; + } + if (ke.isChar('a')) { + return 3; + } + if (ke.isChar('g')) { + return 4; + } + if (ke.isChar('n')) { + return 5; + } + if (ke.isChar('d')) { + return 6; + } + if (ke.isChar('i')) { + return 7; + } + if (ke.isChar('m')) { + return 8; + } + if (ke.isChar('e')) { + return 9; + } + if (ke.isChar('q')) { + return 10; + } + if (ke.isChar('o')) { + return 11; + } + if (ke.isChar('p')) { + return 12; + } + if (ke.isChar('s')) { + return 13; + } + if (ke.isChar('t')) { + return 14; + } + return -1; + } + + List getNonVanishingIntegrations() { + return nonVanishingIntegrationsSupplier.get(); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManager.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManager.java new file mode 100644 index 0000000000000..9aea7a863a2c8 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RecordingManager.java @@ -0,0 +1,280 @@ +/* + * 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.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.export.ExportRequest; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; + +/** + * Manages screenshot capture, tape recording, keystroke display, and screen buffer tracking. Extracted from + * {@link CamelMonitor} to reduce class size. + */ +class RecordingManager { + + record KeyRecord(String label, long timestamp) { + } + + private final CaptionOverlay captionOverlay; + + private boolean recording; + private final AtomicReference tapeRecorderRef = new AtomicReference<>(); + private volatile Buffer lastBuffer; + private volatile long renderGeneration; + private volatile String screenshotMessage; + private volatile long screenshotMessageTime; + private volatile boolean pendingScreenshot; + private final List recentKeys = new ArrayList<>(); + private TuiEventLog eventLog; + + RecordingManager(CaptionOverlay captionOverlay) { + this.captionOverlay = captionOverlay; + } + + void init(boolean initialRecording) { + this.recording = initialRecording; + this.eventLog = new TuiEventLog(500); + } + + // ---- Key recording ---- + + void recordKey(KeyEvent ke, boolean mcpInjected) { + String label = keyLabel(ke); + if (label != null) { + if (eventLog != null) { + eventLog.record(label, label); + } + if (recording) { + recentKeys.add(new KeyRecord(label, System.currentTimeMillis())); + } + TapeRecorder tr = tapeRecorderRef.get(); + if (tr != null && tr.isActive() && !mcpInjected) { + tr.recordKey(label); + } + } + } + + // ---- Recording state ---- + + boolean isRecording() { + return recording; + } + + void toggleRecording() { + recording = !recording; + } + + // ---- Screenshot ---- + + void requestScreenshot() { + pendingScreenshot = true; + } + + boolean processPendingScreenshot() { + if (pendingScreenshot) { + pendingScreenshot = false; + takeScreenshot(); + return true; + } + return false; + } + + void takeScreenshot() { + Buffer buf = lastBuffer; + if (buf == null) { + return; + } + try { + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); + String baseName = "camel-tui-screenshot-" + timestamp; + Path svgPath = Path.of(baseName + ".svg"); + Path txtPath = Path.of(baseName + ".txt"); + Path ansPath = Path.of(baseName + ".ans"); + ExportRequest.export(buf).svg().toFile(svgPath); + ExportRequest.export(buf).text().toFile(txtPath); + ExportRequest.export(buf).text().options(o -> o.styles(true)).toFile(ansPath); + screenshotMessage = "Screenshot saved to " + svgPath.toAbsolutePath() + " (and .txt, .ans)"; + screenshotMessageTime = System.currentTimeMillis(); + } catch (IOException e) { + screenshotMessage = "Screenshot failed: " + e.getMessage(); + screenshotMessageTime = System.currentTimeMillis(); + } + } + + // ---- Buffer tracking ---- + + void updateBuffer(Buffer buffer) { + lastBuffer = buffer; + renderGeneration++; + } + + long getRenderGeneration() { + return renderGeneration; + } + + Buffer getLastBuffer() { + return lastBuffer; + } + + // ---- Screenshot flash message ---- + + String screenshotFlashMessage() { + String msg = screenshotMessage; + if (msg != null && System.currentTimeMillis() - screenshotMessageTime < 5000) { + return msg; + } + screenshotMessage = null; + return null; + } + + // ---- Tape recording ---- + + AtomicReference tapeRecorderRef() { + return tapeRecorderRef; + } + + TapeRecorder getTapeRecorder() { + return tapeRecorderRef.get(); + } + + boolean isTapeRecording() { + TapeRecorder rec = tapeRecorderRef.get(); + return rec != null && rec.isActive(); + } + + void startTapeRecording(String title) { + TapeRecorder rec = new TapeRecorder(); + rec.start(title); + tapeRecorderRef.set(rec); + } + + void clearTapeRecorder() { + tapeRecorderRef.set(null); + } + + void toggleTapeRecording() { + TapeRecorder rec = tapeRecorderRef.get(); + if (rec != null && rec.isActive()) { + String tape = rec.stop(); + tapeRecorderRef.set(null); + String timestamp = LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); + String filename = "camel-tui-tape-" + timestamp + ".tape"; + try { + Files.writeString(Path.of(filename), tape); + captionOverlay.showCaption("Tape saved: " + filename, 5); + } catch (IOException e) { + captionOverlay.showCaption("Failed to save tape: " + e.getMessage(), 5); + } + } else { + rec = new TapeRecorder(); + rec.start(null); + tapeRecorderRef.set(rec); + captionOverlay.showCaption("Tape recording started", 3); + } + } + + // ---- Event log ---- + + TuiEventLog getEventLog() { + return eventLog; + } + + // ---- Recent keys ---- + + List getRecentKeys() { + return recentKeys; + } + + void tickRecentKeys(long now) { + if (recording && !recentKeys.isEmpty()) { + long cutoff = now - 2000; + recentKeys.removeIf(k -> k.timestamp() < cutoff); + } + } + + // ---- Key label utility ---- + + static String keyLabel(KeyEvent ke) { + if (ke.isKey(KeyCode.ENTER)) { + return "Enter"; + } + if (ke.isKey(KeyCode.ESCAPE)) { + return "Esc"; + } + if (ke.isKey(KeyCode.TAB)) { + return ke.hasShift() ? "⇧Tab" : "Tab"; + } + if (ke.isKey(KeyCode.UP)) { + return "↑"; + } + if (ke.isKey(KeyCode.DOWN)) { + return "↓"; + } + if (ke.isKey(KeyCode.LEFT)) { + return "←"; + } + if (ke.isKey(KeyCode.RIGHT)) { + return "→"; + } + if (ke.isKey(KeyCode.PAGE_UP)) { + return "PgUp"; + } + if (ke.isKey(KeyCode.PAGE_DOWN)) { + return "PgDn"; + } + if (ke.isKey(KeyCode.HOME)) { + return "Home"; + } + if (ke.isKey(KeyCode.END)) { + return "End"; + } + if (ke.isKey(KeyCode.BACKSPACE)) { + return "⌫"; + } + for (int i = 1; i <= 12; i++) { + try { + KeyCode fKey = KeyCode.valueOf("F" + i); + if (ke.isKey(fKey)) { + return "F" + i; + } + } catch (IllegalArgumentException e) { + break; + } + } + if (ke.code() == KeyCode.CHAR) { + String s = ke.string(); + if (" ".equals(s)) { + return "Space"; + } + if (!s.isEmpty()) { + return s; + } + } + return null; + } +} From 470ef4045d8767f7008abccffade589f6d0eee1b Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 29 Jun 2026 16:42:19 +0000 Subject: [PATCH 3/4] chore: extract DataRefreshService and TabRegistry from CamelMonitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4: Extract DataRefreshService (~666 lines) — moves all background data polling, vanishing lifecycle, auto-select, and infra/trace/span/error data loading out of CamelMonitor. Uses RefreshContext callback interface (6 methods) to decouple from monitor state. Step 5: Extract TabRegistry (~305 lines) — moves tab instance lifecycle, switching logic, and tab index constants. Uses TabCallbacks interface (7 methods) for event dispatch back to the monitor. Updates McpFacade to accept TabRegistry instead of individual tab references. CamelMonitor reduced from ~2,167 to ~1,568 lines. All 260 tests pass. Co-Authored-By: Claude Opus 4.6 --- .../jbang/core/commands/tui/CamelMonitor.java | 1005 ++++------------- .../core/commands/tui/DataRefreshService.java | 666 +++++++++++ .../jbang/core/commands/tui/McpFacade.java | 32 +- .../jbang/core/commands/tui/TabRegistry.java | 305 +++++ 4 files changed, 1187 insertions(+), 821 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index 892b01b1e5924..eea628bd75b59 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -18,25 +18,17 @@ import java.io.File; import java.io.IOException; -import java.io.RandomAccessFile; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; -import java.util.Iterator; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.Queue; -import java.util.Set; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import dev.tamboui.layout.Constraint; @@ -64,44 +56,27 @@ import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; import org.apache.camel.dsl.jbang.core.common.PathUtils; import org.apache.camel.dsl.jbang.core.common.VersionHelper; -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 sun.misc.Signal; import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*; +import static org.apache.camel.dsl.jbang.core.commands.tui.TabRegistry.*; @Command(name = "monitor", description = "Live 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_TRACES = 200; - private static final int NUM_TABS = 10; - // Compact tab bar (10 labels + 9 "|" dividers) needs 88 chars — that is the true minimum private static final int MIN_WIDTH = 88; private static final int MIN_HEIGHT = 24; // Full tab bar (10 labels + 9 " | " dividers) needs 126 chars; use compact below that private static final int TABS_FULL_MIN_WIDTH = 126; - // Tab indices - private static final int TAB_OVERVIEW = 0; - private static final int TAB_LOG = 1; - private static final int TAB_DIAGRAM = 2; - private static final int TAB_ROUTES = 3; - private static final int TAB_ENDPOINTS = 4; - private static final int TAB_HTTP = 5; - private static final int TAB_HEALTH = 6; - private static final int TAB_HISTORY = 7; - private static final int TAB_ERRORS = 8; - private static final int TAB_MORE = 9; - @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1") String name = "*"; @@ -125,30 +100,12 @@ public class CamelMonitor extends CamelCommand { int mcpPort = 8123; // State - private final AtomicReference> data = new AtomicReference<>(Collections.emptyList()); - private final AtomicReference> infraData = new AtomicReference<>(Collections.emptyList()); - private final Map vanishing = new ConcurrentHashMap<>(); - private final Map vanishingInfra = new ConcurrentHashMap<>(); private final TabsState tabsState = new TabsState(TAB_OVERVIEW); - - // Sparkline/chart history for all metric families - private final MetricsCollector metrics = new MetricsCollector(); - - // Cached PID list — full process scan throttled to every 2 seconds (1 second in burst mode) - private volatile List cachedPids = Collections.emptyList(); - private volatile long lastFullScanTime; - private volatile long burstModeUntil; - final Set stoppingPids = ConcurrentHashMap.newKeySet(); - - // Trace/history data — shared between CamelMonitor and tabs - private final AtomicReference> traces = new AtomicReference<>(Collections.emptyList()); - private final Map traceFilePositions = new ConcurrentHashMap<>(); - // OTel span data — shared between CamelMonitor and SpansTab - private final AtomicReference> otelSpans = new AtomicReference<>(List.of()); + private TabRegistry tabRegistry; // selectedPid is stored on ctx (MonitorContext) so tabs can access it - private volatile long lastRefresh; + private DataRefreshService dataService; private String monitorNotification; private boolean monitorNotificationError; private long monitorNotificationExpiry; @@ -162,57 +119,13 @@ public class CamelMonitor extends CamelCommand { private final HelpOverlay helpOverlay = new HelpOverlay(); private final ShellPanel shellPanel = new ShellPanel(); - private final ActionsPopup actionsPopup = new ActionsPopup( - () -> data.get().stream() - .filter(i -> !i.vanishing && i.name != null) - .map(i -> i.name) - .collect(Collectors.toSet()), - () -> data.get().stream() - .filter(i -> !i.vanishing) - .collect(Collectors.toList()), - () -> infraData.get().stream() - .filter(i -> !i.vanishing) - .collect(Collectors.toList()), - captionOverlay, - () -> recordingManager.requestScreenshot(), - () -> recordingManager.toggleRecording(), - () -> recordingManager.isRecording(), - () -> recordingManager.toggleTapeRecording(), - () -> recordingManager.isTapeRecording(), - this::enableBurstMode, stoppingPids); - - private final AtomicBoolean refreshInProgress = new AtomicBoolean(false); + private ActionsPopup actionsPopup; private TuiRunner runner; private MonitorContext ctx; - private LogTab logTab; - private DiagramTab diagramTab; - private RoutesTab routesTab; - private ConsumersTab consumersTab; - private EndpointsTab endpointsTab; - private HttpTab httpTab; - private HealthTab healthTab; - private HistoryTab historyTab; - private CircuitBreakerTab circuitBreakerTab; - private ErrorsTab errorsTab; - private MetricsTab metricsTab; - private StartupTab startupTab; - private ConfigurationTab configurationTab; - private BeansTab beansTab; - private BrowseTab browseTab; - private ClasspathTab classpathTab; - private InflightTab inflightTab; - private MemoryTab memoryTab; - private ThreadsTab threadsTab; - private SpansTab spansTab; - private ProcessTab processTab; - private OverviewTab overviewTab; - private DataSourceTab dataSourceTab; - private SqlQueryTab sqlQueryTab; private final FilesBrowser filesBrowser = new FilesBrowser(); private PopupManager popupManager; - private MonitorTab activeMoreTab; private ClassLoader classLoader; @@ -242,45 +155,122 @@ public Integer doCall() throws Exception { // to make ServiceLoader work with tamboui for downloaded JARs Thread.currentThread().setContextClassLoader(classLoader); + // Create data refresh service first — tabs and popups reference its state + dataService = new DataRefreshService( + name, + new DataRefreshService.RefreshContext() { + @Override + public int selectedTab() { + return tabRegistry != null ? tabRegistry.selectedTabIndex() : tabsState.selected(); + } + + @Override + public boolean isSwitchPopupVisible() { + return popupManager != null && popupManager.isSwitchPopupVisible(); + } + + @Override + public String getPendingAutoSelect() { + return actionsPopup != null ? actionsPopup.getPendingAutoSelect() : null; + } + + @Override + public void clearPendingAutoSelect() { + if (actionsPopup != null) { + actionsPopup.clearPendingAutoSelect(); + } + } + + @Override + public void onInfraAutoSelected(int tableIndex, String pid) { + if (tabRegistry != null) { + tabRegistry.overviewTab().tableState.select(tableIndex); + } + ctx.selectedPid = pid; + } + + @Override + public boolean isInfraSelected() { + return CamelMonitor.this.isInfraSelected(); + } + }, + this::getStatusFile, + this::getErrorFile); + // Create shared context and tab instances - ctx = new MonitorContext(data, infraData); + ctx = new MonitorContext(dataService.data(), dataService.infraData()); + dataService.setContext(ctx); + + actionsPopup = new ActionsPopup( + () -> dataService.data().get().stream() + .filter(i -> !i.vanishing && i.name != null) + .map(i -> i.name) + .collect(Collectors.toSet()), + () -> dataService.data().get().stream() + .filter(i -> !i.vanishing) + .collect(Collectors.toList()), + () -> dataService.infraData().get().stream() + .filter(i -> !i.vanishing) + .collect(Collectors.toList()), + captionOverlay, + () -> recordingManager.requestScreenshot(), + () -> recordingManager.toggleRecording(), + () -> recordingManager.isRecording(), + () -> recordingManager.toggleTapeRecording(), + () -> recordingManager.isTapeRecording(), + dataService::enableBurstMode, dataService.stoppingPids()); + actionsPopup.setContext(ctx); actionsPopup.setResetStatsAction(this::resetStats); shellPanel.setContext(ctx); actionsPopup.setOpenShellAction(shellPanel::open); actionsPopup.setBrowseFilesAction(this::openFilesPopup); - logTab = new LogTab(ctx); - diagramTab = new DiagramTab(ctx); - routesTab = new RoutesTab(ctx); - consumersTab = new ConsumersTab(ctx); - dataSourceTab = new DataSourceTab(ctx); - sqlQueryTab = new SqlQueryTab(ctx); - endpointsTab = new EndpointsTab(ctx, metrics); - httpTab = new HttpTab(ctx); - healthTab = new HealthTab(ctx); - historyTab = new HistoryTab(ctx, traces, traceFilePositions); - circuitBreakerTab = new CircuitBreakerTab(ctx, metrics); - errorsTab = new ErrorsTab(ctx); - metricsTab = new MetricsTab(ctx); - startupTab = new StartupTab(ctx); - configurationTab = new ConfigurationTab(ctx); - beansTab = new BeansTab(ctx); - browseTab = new BrowseTab(ctx); - classpathTab = new ClasspathTab(ctx); - inflightTab = new InflightTab(ctx); - memoryTab = new MemoryTab(ctx, metrics); - threadsTab = new ThreadsTab(ctx); - spansTab = new SpansTab(ctx, otelSpans); - processTab = new ProcessTab(ctx); - overviewTab = new OverviewTab( - ctx, metrics, stoppingPids, - this::resetIntegrationTabState); + + tabRegistry = new TabRegistry(tabsState); + tabRegistry.initTabs(ctx, dataService, this::resetIntegrationTabState); + tabRegistry.setCallbacks(new TabRegistry.TabCallbacks() { + @Override + public void refreshLogData() { + CamelMonitor.this.refreshLogData(); + } + + @Override + public void refreshHistoryData(List pids) { + dataService.loadHistoryData(pids); + } + + @Override + public void refreshTraceData(List pids) { + dataService.refreshTraceData(pids); + } + + @Override + public void refreshErrorData(List pids) { + dataService.refreshErrorData(pids); + } + + @Override + public void openMorePopup() { + popupManager.openMorePopup(); + } + + @Override + public void closeMorePopup() { + popupManager.closeMorePopup(); + } + + @Override + public void selectMorePopupEntry(int index) { + popupManager.selectMorePopupEntry(index); + } + }); + popupManager = new PopupManager( ctx, this::getNonVanishingIntegrations, filesBrowser, new PopupManager.PopupCallbacks() { @Override public void selectMoreTab(int index) { - CamelMonitor.this.selectMoreTab(index); + tabRegistry.selectMoreTab(index); } @Override @@ -299,7 +289,7 @@ public void stopSelectedProcess(boolean forceKill) { } }); - overviewTab.setActions(new OverviewTab.OverviewActions() { + tabRegistry.overviewTab().setActions(new OverviewTab.OverviewActions() { @Override public void sendRouteCommand(String pid, String routeId, String command) { CamelMonitor.this.sendRouteCommand(pid, routeId, command); @@ -332,31 +322,31 @@ public void openFilesPopup() { }); // Initial data load (synchronous before TUI starts) - refreshDataSync(); + dataService.refreshSync(this::refreshLogData, this::refreshConditionalData); // Auto-select if there's exactly one integration running - overviewTab.selectCurrentIntegration(); + tabRegistry.overviewTab().selectCurrentIntegration(); mcpFacade = new McpFacade( - ctx, data, tabsState, recordingManager, + ctx, dataService.data(), tabsState, recordingManager, captionOverlay, drawOverlay, helpOverlay, actionsPopup, filesBrowser, - logTab, diagramTab, historyTab, + tabRegistry, pendingKeys, new McpFacade.MonitorBridge() { @Override public MonitorTab activeTab() { - return CamelMonitor.this.activeTab(); + return tabRegistry.activeTab(); } @Override public void handleTabKey(int tabIndex) { - CamelMonitor.this.handleTabKey(tabIndex); + tabRegistry.handleTabKey(tabIndex, ctx, dataService); } @Override public void selectMoreTab(int moreIndex) { - CamelMonitor.this.selectMoreTab(moreIndex); + tabRegistry.selectMoreTab(moreIndex); } @Override @@ -415,8 +405,8 @@ public void stopProcess(boolean forceKill) { actionsPopup.setScheduler(tui.scheduler()); actionsPopup.setResetScreenAction(() -> tui.terminal().clear()); // Preload diagram data if an integration was auto-selected - routesTab.preloadDiagram(); - diagramTab.preloadDiagram(); + tabRegistry.routesTab().preloadDiagram(); + tabRegistry.diagramTab().preloadDiagram(); // Intercept Ctrl+C: quit the TUI cleanly instead of letting // the JVM tear down the classloader while we're still running Signal.handle(new Signal("INT"), sig -> tui.quit()); @@ -470,7 +460,7 @@ private boolean handleEvent(Event event, TuiRunner runner) { if (actionsPopup.isVisible()) { return actionsPopup.handleKeyEvent(ke); } - if (popupManager.handleKeyEvent(ke, tabsState.selected(), TAB_LOG)) { + if (popupManager.handleKeyEvent(ke, tabRegistry.selectedTabIndex(), TAB_LOG)) { return true; } if (handleGlobalKeys(ke, runner)) { @@ -496,11 +486,11 @@ private boolean handleEvent(Event event, TuiRunner runner) { private boolean handleGlobalKeys(KeyEvent ke, TuiRunner runner) { if (ke.isCancel()) { - MonitorTab tab = activeTab(); + MonitorTab tab = tabRegistry.activeTab(); if (tab != null && tab.handleEscape()) { return true; } - if (tabsState.selected() != TAB_OVERVIEW) { + if (tabRegistry.selectedTabIndex() != TAB_OVERVIEW) { tabsState.select(TAB_OVERVIEW); return true; } @@ -511,12 +501,16 @@ private boolean handleGlobalKeys(KeyEvent ke, TuiRunner runner) { } return true; } - boolean probeEditing = tabsState.selected() == TAB_HTTP && httpTab.isProbeMode(); - boolean logSearchActive = tabsState.selected() == TAB_LOG && logTab.isSearchInputActive(); - boolean spanFilterActive = tabsState.selected() == TAB_MORE && activeMoreTab == spansTab - && spansTab.isFilterInputActive(); - boolean sqlInputActive = tabsState.selected() == TAB_MORE && activeMoreTab == sqlQueryTab - && sqlQueryTab.isInputActive(); + boolean probeEditing = tabRegistry.selectedTabIndex() == TAB_HTTP + && tabRegistry.httpTab().isProbeMode(); + boolean logSearchActive = tabRegistry.selectedTabIndex() == TAB_LOG + && tabRegistry.logTab().isSearchInputActive(); + boolean spanFilterActive = tabRegistry.selectedTabIndex() == TAB_MORE + && tabRegistry.getActiveMoreTab() == tabRegistry.spansTab() + && tabRegistry.spansTab().isFilterInputActive(); + boolean sqlInputActive = tabRegistry.selectedTabIndex() == TAB_MORE + && tabRegistry.getActiveMoreTab() == tabRegistry.sqlQueryTab() + && tabRegistry.sqlQueryTab().isInputActive(); boolean textEditing = probeEditing || logSearchActive || spanFilterActive || sqlInputActive; if (!textEditing && (ke.isCharIgnoreCase('q') || ke.isCtrlC())) { runner.quit(); @@ -528,46 +522,46 @@ private boolean handleGlobalKeys(KeyEvent ke, TuiRunner runner) { } if (!textEditing) { if (ke.isChar('1')) { - return handleTabKey(TAB_OVERVIEW); + return tabRegistry.handleTabKey(TAB_OVERVIEW, ctx, dataService); } if (ke.isChar('2')) { - return handleTabKey(TAB_LOG); + return tabRegistry.handleTabKey(TAB_LOG, ctx, dataService); } if (!isInfraSelected()) { if (ke.isChar('3')) { - return handleTabKey(TAB_DIAGRAM); + return tabRegistry.handleTabKey(TAB_DIAGRAM, ctx, dataService); } if (ke.isChar('4')) { - return handleTabKey(TAB_ROUTES); + return tabRegistry.handleTabKey(TAB_ROUTES, ctx, dataService); } if (ke.isChar('5')) { - return handleTabKey(TAB_ENDPOINTS); + return tabRegistry.handleTabKey(TAB_ENDPOINTS, ctx, dataService); } if (ke.isChar('6')) { - return handleTabKey(TAB_HTTP); + return tabRegistry.handleTabKey(TAB_HTTP, ctx, dataService); } if (ke.isChar('7')) { - return handleTabKey(TAB_HEALTH); + return tabRegistry.handleTabKey(TAB_HEALTH, ctx, dataService); } if (ke.isChar('8')) { - return handleTabKey(TAB_HISTORY); + return tabRegistry.handleTabKey(TAB_HISTORY, ctx, dataService); } if (ke.isChar('9')) { - return handleTabKey(TAB_ERRORS); + return tabRegistry.handleTabKey(TAB_ERRORS, ctx, dataService); } if (ke.isChar('0')) { - return handleTabKey(TAB_MORE); + return tabRegistry.handleTabKey(TAB_MORE, ctx, dataService); } } } if (ke.isFocusPrevious() && !textEditing) { if (isInfraSelected()) { - int prev = tabsState.selected() == TAB_OVERVIEW ? TAB_LOG : TAB_OVERVIEW; + int prev = tabRegistry.selectedTabIndex() == TAB_OVERVIEW ? TAB_LOG : TAB_OVERVIEW; tabsState.select(prev); } else { - int prev = (tabsState.selected() - 1 + NUM_TABS) % NUM_TABS; + int prev = (tabRegistry.selectedTabIndex() - 1 + NUM_TABS) % NUM_TABS; if (prev != TAB_OVERVIEW) { - overviewTab.selectCurrentIntegration(); + tabRegistry.overviewTab().selectCurrentIntegration(); } tabsState.select(prev); } @@ -575,12 +569,12 @@ private boolean handleGlobalKeys(KeyEvent ke, TuiRunner runner) { } if (ke.isFocusNext() && !textEditing) { if (isInfraSelected()) { - int next = tabsState.selected() == TAB_OVERVIEW ? TAB_LOG : TAB_OVERVIEW; + int next = tabRegistry.selectedTabIndex() == TAB_OVERVIEW ? TAB_LOG : TAB_OVERVIEW; tabsState.select(next); } else { - int next = (tabsState.selected() + 1) % NUM_TABS; + int next = (tabRegistry.selectedTabIndex() + 1) % NUM_TABS; if (next != TAB_OVERVIEW) { - overviewTab.selectCurrentIntegration(); + tabRegistry.overviewTab().selectCurrentIntegration(); } tabsState.select(next); } @@ -593,7 +587,7 @@ private boolean handleGlobalKeys(KeyEvent ke, TuiRunner runner) { if (opensHelp(ke, textEditing)) { // Only opens the overlay: while it is visible, dispatch delegates to // helpOverlay.handleKeyEvent (which handles F1/?/q/Esc to close) before reaching here. - MonitorTab tab = activeTab(); + MonitorTab tab = tabRegistry.activeTab(); if (tab != null) { String help = tab.getHelpText(); if (help != null) { @@ -611,8 +605,8 @@ private boolean handleGlobalKeys(KeyEvent ke, TuiRunner runner) { return true; } if (ke.isKey(KeyCode.F2)) { - if (tabsState.selected() == TAB_ROUTES && routesTab != null) { - actionsPopup.setPreSelectedRouteId(routesTab.selectedRouteId()); + if (tabRegistry.selectedTabIndex() == TAB_ROUTES && tabRegistry.routesTab() != null) { + actionsPopup.setPreSelectedRouteId(tabRegistry.routesTab().selectedRouteId()); } actionsPopup.open(); return true; @@ -633,20 +627,20 @@ static boolean opensHelp(KeyEvent ke, boolean textEditing) { } private boolean handleTabKeys(KeyEvent ke) { - MonitorTab activeTab = activeTab(); + MonitorTab activeTab = tabRegistry.activeTab(); if (ke.isUp()) { if (activeTab != null && activeTab.handleKeyEvent(ke)) { return true; } - navigateUp(); + tabRegistry.navigateUp(); return true; } if (ke.isDown()) { if (activeTab != null && activeTab.handleKeyEvent(ke)) { return true; } - navigateDown(); + tabRegistry.navigateDown(); return true; } if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) { @@ -682,9 +676,9 @@ private boolean handleTabKeys(KeyEvent ke) { } } - int tab = tabsState.selected(); + int tab = tabRegistry.selectedTabIndex(); if (ke.isConfirm() && tab == TAB_OVERVIEW) { - overviewTab.selectCurrentIntegration(); + tabRegistry.overviewTab().selectCurrentIntegration(); if (ctx.selectedPid != null) { tabsState.select(TAB_LOG); refreshLogData(); @@ -702,20 +696,21 @@ private boolean handlePasteEvent(PasteEvent pe) { actionsPopup.handlePaste(pe.text()); return true; } - if (httpTab.isProbeMode()) { - httpTab.handlePaste(pe.text()); + if (tabRegistry.httpTab().isProbeMode()) { + tabRegistry.httpTab().handlePaste(pe.text()); return true; } - if (logTab.isSearchInputActive()) { - logTab.handlePaste(pe.text()); + if (tabRegistry.logTab().isSearchInputActive()) { + tabRegistry.logTab().handlePaste(pe.text()); return true; } if (filesBrowser.isSourceViewerPasteActive()) { filesBrowser.handlePaste(pe.text()); return true; } - if (activeMoreTab == sqlQueryTab && sqlQueryTab.isInputActive()) { - sqlQueryTab.handlePaste(pe.text()); + if (tabRegistry.getActiveMoreTab() == tabRegistry.sqlQueryTab() + && tabRegistry.sqlQueryTab().isInputActive()) { + tabRegistry.sqlQueryTab().handlePaste(pe.text()); return true; } return false; @@ -739,132 +734,20 @@ private boolean handleTickEvent(TuiRunner runner) { drawOverlay.tick(now); captionOverlay.tick(now); recordingManager.tickRecentKeys(now); - boolean anyDiagramShowing = routesTab.isShowDiagram() || diagramTab.isShowDiagram(); + boolean anyDiagramShowing = tabRegistry.routesTab().isShowDiagram() + || tabRegistry.diagramTab().isShowDiagram(); long interval = anyDiagramShowing ? Math.max(refreshInterval, 1000) : refreshInterval; - if (now - lastRefresh >= interval) { - refreshData(); - routesTab.refreshDiagramIfNeeded(); - diagramTab.refreshDiagramIfNeeded(); - return true; - } - return true; - } - - private boolean handleTabKey(int tab) { - if (tab != TAB_OVERVIEW) { - overviewTab.selectCurrentIntegration(); - routesTab.preloadDiagram(); - diagramTab.preloadDiagram(); - } - if (tab == TAB_LOG) { - refreshLogData(); - logTab.onTabSelected(); - } - if (tab == TAB_ROUTES && routesTab != null && routesTab.isShowDiagram()) { - routesTab.closeDiagram(); - } - if (tab == TAB_DIAGRAM) { - diagramTab.onTabSelected(); - } - if (tab == TAB_HISTORY && ctx.selectedPid != null) { - try { - long pid = Long.parseLong(ctx.selectedPid); - refreshHistoryData(List.of(pid)); - refreshTraceData(List.of(pid)); - } catch (NumberFormatException e) { - // ignore - } - historyTab.onTabSelected(); - } - if (tab == TAB_ERRORS && ctx.selectedPid != null) { - try { - long pid = Long.parseLong(ctx.selectedPid); - refreshErrorData(List.of(pid)); - } catch (NumberFormatException e) { - // ignore - } - errorsTab.onTabSelected(); - } - if (tab == TAB_MORE) { - popupManager.openMorePopup(); + if (now - dataService.lastRefresh() >= interval) { + dataService.refresh(runner, this::refreshLogData, this::refreshConditionalData); + tabRegistry.routesTab().refreshDiagramIfNeeded(); + tabRegistry.diagramTab().refreshDiagramIfNeeded(); return true; } - popupManager.closeMorePopup(); - tabsState.select(tab); return true; } - void selectMoreTab(int index) { - popupManager.selectMorePopupEntry(index); - activeMoreTab = switch (index) { - case 0 -> beansTab; - case 1 -> browseTab; - case 2 -> circuitBreakerTab; - case 3 -> classpathTab; - case 4 -> configurationTab; - case 5 -> consumersTab; - case 6 -> dataSourceTab; - case 7 -> inflightTab; - case 8 -> memoryTab; - case 9 -> metricsTab; - case 10 -> sqlQueryTab; - case 11 -> spansTab; - case 12 -> processTab; - case 13 -> startupTab; - case 14 -> threadsTab; - default -> null; - }; - if (activeMoreTab != null) { - overviewTab.selectCurrentIntegration(); - tabsState.select(TAB_MORE); - activeMoreTab.onTabSelected(); - } - } - - private List selectedPidAsList() { - if (ctx.selectedPid == null) { - return Collections.emptyList(); - } - try { - return List.of(Long.parseLong(ctx.selectedPid)); - } catch (NumberFormatException e) { - return Collections.emptyList(); - } - } - private void resetIntegrationTabState() { - diagramTab.onIntegrationChanged(); - routesTab.onIntegrationChanged(); - httpTab.onIntegrationChanged(); - logTab.onIntegrationChanged(); - historyTab.onIntegrationChanged(); - beansTab.onIntegrationChanged(); - browseTab.onIntegrationChanged(); - threadsTab.onIntegrationChanged(); - startupTab.onIntegrationChanged(); - configurationTab.onIntegrationChanged(); - consumersTab.onIntegrationChanged(); - dataSourceTab.onIntegrationChanged(); - sqlQueryTab.onIntegrationChanged(); - circuitBreakerTab.onIntegrationChanged(); - inflightTab.onIntegrationChanged(); - spansTab.onIntegrationChanged(); - processTab.onIntegrationChanged(); - otelSpans.set(List.of()); - - filesBrowser.reset(); - - // Preload diagram data in background so it's ready when the user switches tabs - routesTab.preloadDiagram(); - diagramTab.preloadDiagram(); - } - - private void navigateUp() { - activeTab().navigateUp(); - } - - private void navigateDown() { - activeTab().navigateDown(); + tabRegistry.resetIntegrationTabState(dataService, filesBrowser); } // ---- Rendering ---- @@ -921,7 +804,7 @@ private void render(Frame frame) { } private void renderHeader(Frame frame, Rect area) { - List infos = data.get(); + List infos = dataService.data().get(); String camelVersion = VersionHelper.extractCamelVersion(); long activeCount = infos.stream().filter(i -> !i.vanishing).count(); @@ -931,7 +814,7 @@ private void renderHeader(Frame frame, Rect area) { titleSpans.add(Span.styled(camelVersion != null ? "v" + camelVersion : "", Style.EMPTY.fg(Color.GREEN))); titleSpans.add(Span.raw(" ")); titleSpans.add(Span.styled(activeCount + " integration(s)", Style.EMPTY.fg(Color.CYAN))); - long activeInfra = infraData.get().stream().filter(i -> !i.vanishing).count(); + long activeInfra = dataService.infraData().get().stream().filter(i -> !i.vanishing).count(); if (activeInfra > 0) { titleSpans.add(Span.raw(" ")); titleSpans.add(Span.styled(activeInfra + " infra(s)", Style.EMPTY.fg(Color.MAGENTA))); @@ -1045,7 +928,7 @@ private void renderTabs(Frame frame, Rect area) { Line.from("1 Overview"), Line.from("2 Log"), Line.from("3 Diagram"), - Line.from(routesTab.isTopMode() ? "4 Top " : "4 Route"), + Line.from(tabRegistry.routesTab().isTopMode() ? "4 Top " : "4 Route"), Line.from("5 Endpoint"), Line.from("6 HTTP"), Line.from("7 Health"), @@ -1057,7 +940,7 @@ private void renderTabs(Frame frame, Rect area) { Line.from(" 1 Overview "), Line.from(" 2 Log "), Line.from(" 3 Diagram "), - Line.from(routesTab.isTopMode() ? " 4 Top " : " 4 Route "), + Line.from(tabRegistry.routesTab().isTopMode() ? " 4 Top " : " 4 Route "), Line.from(" 5 Endpoint "), Line.from(" 6 HTTP "), Line.from(" 7 Health "), @@ -1106,7 +989,7 @@ private void renderContent(Frame frame, Rect area) { // from the previous tab (e.g. RED error text in the log detail) can bleed through when // switching tabs if TamboUI's buffer diff does not reset every cell in the region. frame.buffer().clear(area); - MonitorTab tab = activeTab(); + MonitorTab tab = tabRegistry.activeTab(); tab.render(frame, area); // Render "More" popup overlay when visible if (popupManager.isMorePopupVisible()) { @@ -1131,7 +1014,7 @@ private void computeTabBadges(String[] badgeTexts, Style[] badgeStyles) { badgeStyles[j] = yellow; } - List infos = data.get(); + List infos = dataService.data().get(); long activeCount = infos.stream().filter(i -> !i.vanishing).count(); IntegrationInfo sel = findSelectedIntegration(); boolean hasSelection = ctx.selectedPid != null && sel != null; @@ -1163,13 +1046,13 @@ private void computeTabBadges(String[] badgeTexts, Style[] badgeStyles) { badgeTexts[TAB_HEALTH] = "(" + healthCount + ")"; } } - boolean hasTraces = hasSelection && !traces.get().isEmpty(); + boolean hasTraces = hasSelection && !dataService.traces().get().isEmpty(); if (hasTraces) { badgeTexts[TAB_HISTORY] = "(*)"; badgeStyles[TAB_HISTORY] = cyan; } else { long historyCount = hasSelection - ? historyTab.historyEntries.stream() + ? tabRegistry.historyTab().historyEntries.stream() .map(e -> { if (e.headers != null) { Object bid = e.headers.get("breadcrumbId"); @@ -1210,30 +1093,14 @@ private void openFilesPopup() { } private List getNonVanishingIntegrations() { - return data.get().stream() + return dataService.data().get().stream() .filter(i -> !i.vanishing && i.name != null) .sorted(Comparator.comparing(i -> i.name, String.CASE_INSENSITIVE_ORDER)) .collect(Collectors.toList()); } - private MonitorTab activeTab() { - return switch (tabsState.selected()) { - case TAB_OVERVIEW -> overviewTab; - case TAB_LOG -> logTab; - case TAB_DIAGRAM -> diagramTab; - case TAB_ROUTES -> routesTab; - case TAB_ENDPOINTS -> endpointsTab; - case TAB_HEALTH -> healthTab; - case TAB_HISTORY -> historyTab; - case TAB_HTTP -> httpTab; - case TAB_ERRORS -> errorsTab; - case TAB_MORE -> activeMoreTab; - default -> null; - }; - } - private void stopSelectedProcess(boolean forceKill) { - enableBurstMode(); + dataService.enableBurstMode(); if (ctx.selectedPid == null) { return; } @@ -1268,7 +1135,7 @@ private void stopSelectedProcess(boolean forceKill) { PathUtils.deleteFile(camelDir.resolve(pidStr + "-debug.json")); PathUtils.deleteFile(camelDir.resolve(pidStr + "-receive.json")); } else { - stoppingPids.add(pidStr); + dataService.stoppingPids().add(pidStr); ph.destroy(); } }); @@ -1276,7 +1143,7 @@ private void stopSelectedProcess(boolean forceKill) { } private void restartSelectedProcess() { - enableBurstMode(); + dataService.enableBurstMode(); if (ctx.selectedPid == null || isInfraSelected()) { return; } @@ -1357,14 +1224,6 @@ private void restartSelectedProcess() { } } - private void enableBurstMode() { - burstModeUntil = System.currentTimeMillis() + 20_000; - } - - private boolean isBurstMode() { - return System.currentTimeMillis() < burstModeUntil; - } - private void setNotification(String message, boolean error) { monitorNotification = message; monitorNotificationError = error; @@ -1411,11 +1270,11 @@ private void resetStats() { root.put("action", "reset-stats"); Path actionFile = ctx.getActionFile(pid); PathUtils.writeTextSafely(root.toJson(), actionFile); - metrics.resetStats(pid); + dataService.metrics().resetStats(pid); } private void sendRouteCommand(String pid, String routeId, String command) { - enableBurstMode(); + dataService.enableBurstMode(); JsonObject root = new JsonObject(); root.put("action", "route"); root.put("id", routeId); @@ -1462,9 +1321,9 @@ private void renderFooter(Frame frame, Rect area) { } else if (shellPanel.isOpen()) { shellPanel.renderFooter(spans); } else { - MonitorTab tab = activeTab(); + MonitorTab tab = tabRegistry.activeTab(); - if (tabsState.selected() == TAB_OVERVIEW) { + if (tabRegistry.selectedTabIndex() == TAB_OVERVIEW) { fKeyTotal = renderOverviewFooter(spans); } else { tab.renderFooter(spans); @@ -1546,7 +1405,7 @@ private void renderFooter(Frame frame, Rect area) { private int insertFKeyHints(List spans) { int insertPos = Math.min(2, spans.size()); List fKeySpans = new ArrayList<>(); - MonitorTab tab = activeTab(); + MonitorTab tab = tabRegistry.activeTab(); boolean hasHelp = tab != null && tab.getHelpText() != null; if (hasHelp) { hint(fKeySpans, "F1", "help"); @@ -1589,7 +1448,7 @@ private int renderOverviewFooter(List spans) { actionsPopup.renderFooter(spans); return 0; } - overviewTab.renderFooter(spans); + tabRegistry.overviewTab().renderFooter(spans); int fKeyTotal = insertFKeyHints(spans); // Process action hints if (ctx.selectedPid != null && !isInfraSelected()) { @@ -1617,7 +1476,7 @@ private int renderOverviewFooter(List spans) { // ---- Data Loading ---- private void refreshLogData() { - if (tabsState.selected() != TAB_LOG) { + if (tabRegistry.selectedTabIndex() != TAB_LOG) { return; } String logPid = null; @@ -1634,471 +1493,27 @@ private void refreshLogData() { } } if (logPid != null) { - logTab.refreshFromFile(logPid, logFileName); - } - } - - private void refreshData() { - if (runner == null) { - refreshDataSync(); - return; - } - if (!refreshInProgress.compareAndSet(false, true)) { - return; - } - lastRefresh = System.currentTimeMillis(); - String currentSelectedPid = ctx.selectedPid; - runner.scheduler().execute(() -> { - try { - refreshDataSync(); - } finally { - refreshInProgress.set(false); - } - }); - } - - private void refreshDataSync() { - lastRefresh = System.currentTimeMillis(); - try { - refreshLogData(); - boolean fullScan = scanIntegrations(); - List infos = data.get(); - handleAutoSelect(infos, fullScan); - refreshConditionalData(); - } catch (Exception e) { - // ignore refresh errors - } - } - - private boolean scanIntegrations() { - List infos = new ArrayList<>(); - long now = System.currentTimeMillis(); - boolean wantFullScan = tabsState.selected() == TAB_OVERVIEW || popupManager.isSwitchPopupVisible() - || cachedPids.isEmpty(); - long scanInterval = isBurstMode() ? 1000 : 2000; - boolean fullScan = wantFullScan && (now - lastFullScanTime >= scanInterval); - List pids; - if (fullScan) { - pids = findPids(name); - cachedPids = pids; - lastFullScanTime = now; - } else { - pids = cachedPids; - } - - List refreshPids; - if (!fullScan && ctx.selectedPid != null) { - try { - refreshPids = List.of(Long.parseLong(ctx.selectedPid)); - } catch (NumberFormatException e) { - refreshPids = pids; - } - } else { - refreshPids = pids; - } - for (Long pid : refreshPids) { - JsonObject root = loadStatus(pid); - if (root != null) { - ProcessHandle ph = ProcessHandle.of(pid).orElse(null); - if (ph == null) { - continue; - } - IntegrationInfo info = StatusParser.parseIntegration(ph, root); - if (info != null) { - infos.add(info); - metrics.updateThroughputHistory(info); - metrics.updateEndpointHistory(info); - metrics.updateCbHistory(info); - metrics.updateHeapHistory(info); - metrics.updateLoadMetrics(ph, info); - } - } - } - if (!fullScan && ctx.selectedPid != null) { - List previous = data.get(); - for (IntegrationInfo prev : previous) { - if (!prev.vanishing && !ctx.selectedPid.equals(prev.pid)) { - infos.add(prev); - } - } - } - - handleVanishing(infos, now); - data.set(infos); - return fullScan; - } - - private void handleVanishing(List infos, long now) { - 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)) { - boolean wasExplicitStop = stoppingPids.remove(prev.pid); - if (wasExplicitStop) { - metrics.removeVanished(prev.pid); - } else { - vanishing.put(prev.pid, new VanishingInfo(prev, 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(); - metrics.removeVanished(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(); - } - } - } - - private void handleAutoSelect(List infos, boolean fullScan) { - if (ctx.selectedPid != null && !isInfraSelected()) { - boolean stillAlive = infos.stream() - .anyMatch(i -> ctx.selectedPid.equals(i.pid) && !i.vanishing); - if (!stillAlive) { - IntegrationInfo gone = infos.stream() - .filter(i -> ctx.selectedPid.equals(i.pid)) - .findFirst().orElse(null); - if (gone != null) { - ctx.lastSelectedName = gone.name; - } - ctx.selectedPid = null; - } - } - - String autoSelect = actionsPopup.getPendingAutoSelect(); - if (autoSelect != null) { - for (IntegrationInfo info : infos) { - if (!info.vanishing && autoSelect.equalsIgnoreCase(info.name)) { - ctx.selectedPid = info.pid; - ctx.lastSelectedName = null; - actionsPopup.clearPendingAutoSelect(); - break; - } - } - } - - if (ctx.selectedPid == null && ctx.lastSelectedName != null && !isInfraSelected()) { - for (IntegrationInfo info : infos) { - if (!info.vanishing && ctx.lastSelectedName.equalsIgnoreCase(info.name)) { - ctx.selectedPid = info.pid; - ctx.lastSelectedName = null; - break; - } - } - } - - if (fullScan) { - refreshInfraData(); - } - - if (ctx.selectedPid == null && !infraData.get().isEmpty() - && infos.stream().noneMatch(i -> !i.vanishing)) { - List infras = infraData.get(); - if (!infras.isEmpty()) { - int firstInfraIndex = infos.size() + (infras.size() > 0 ? 1 : 0); - overviewTab.tableState.select(firstInfraIndex); - ctx.selectedPid = infras.get(0).pid; - } + tabRegistry.logTab().refreshFromFile(logPid, logFileName); } } private void refreshConditionalData() { - List selectedPids = selectedPidAsList(); - if (tabsState.selected() == TAB_ERRORS && !selectedPids.isEmpty()) { - refreshErrorData(selectedPids); - } - if (tabsState.selected() == TAB_HISTORY && !selectedPids.isEmpty()) { - if (historyTab.historyRefreshRequested) { - historyTab.historyRefreshRequested = false; - refreshHistoryData(selectedPids); - } - refreshTraceData(selectedPids); - } - if (tabsState.selected() == TAB_MORE && activeMoreTab == spansTab - && ctx.selectedPid != null && spansTab.spanRefreshRequested) { - spansTab.spanRefreshRequested = false; - refreshSpanData(); - } - } - - @SuppressWarnings("unchecked") - private void refreshSpanData() { - String pid = ctx.selectedPid; - if (pid == null) { - return; - } - try { - // Send action to request span data - Path outputFile = ctx.getOutputFile(pid); - PathUtils.deleteFile(outputFile); - - JsonObject action = new JsonObject(); - action.put("action", "span"); - action.put("dump", "true"); - action.put("limit", "500"); - Path actionFile = ctx.getActionFile(pid); - PathUtils.writeTextSafely(action.toJson(), actionFile); - - // Poll for response - JsonObject response = MonitorContext.pollJsonResponse(outputFile, 3000); - if (response != null) { - Boolean enabled = response.getBoolean("enabled"); - if (enabled != null && enabled) { - JsonArray arr = response.getCollection("spans"); - if (arr != null) { - List entries = new ArrayList<>(); - for (int i = 0; i < arr.size(); i++) { - JsonObject spanObj = (JsonObject) arr.get(i); - entries.add(SpanEntry.fromJson(spanObj)); - } - otelSpans.set(entries); - } - } - PathUtils.deleteFile(outputFile); - } - } catch (Exception e) { - // ignore - } - } - - @SuppressWarnings("unchecked") - private void refreshInfraData() { - List infraInfos = new ArrayList<>(); - try { - Path camelDir = CommandLineHelper.getCamelDir(); - if (Files.isDirectory(camelDir)) { - try (var files = Files.list(camelDir)) { - List jsonFiles = files - .filter(p -> { - String n = p.getFileName().toString(); - return n.startsWith("infra-") && n.endsWith(".json"); - }) - .toList(); - for (Path jsonFile : jsonFiles) { - String fn = jsonFile.getFileName().toString(); - // Format: infra-{alias}-{pid}.json - String withoutExt = fn.substring(0, fn.lastIndexOf('.')); - int lastDash = withoutExt.lastIndexOf('-'); - if (lastDash <= 6) { - continue; - } - String alias = withoutExt.substring(6, lastDash); - String pidStr = withoutExt.substring(lastDash + 1); - long pid; - try { - pid = Long.parseLong(pidStr); - } catch (NumberFormatException e) { - continue; - } - boolean alive = ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false); - - InfraInfo info = new InfraInfo(); - info.pid = pidStr; - info.alias = alias; - info.alive = alive; - try { - String json = Files.readString(jsonFile); - Object parsed = Jsoner.deserialize(json); - if (parsed instanceof Map map) { - for (Map.Entry e : map.entrySet()) { - info.properties.put(String.valueOf(e.getKey()), e.getValue()); - } - } - } catch (Exception e) { - // ignore parse errors - } - info.serviceVersion = StatusParser.objToString(info.properties.get("serviceVersion")); - infraInfos.add(info); - } - } - } - } catch (IOException e) { - // ignore - } - - // Handle vanishing infra services - Set liveInfraPids = infraInfos.stream().map(i -> i.pid).collect(Collectors.toSet()); - List previousInfra = infraData.get(); - for (InfraInfo prev : previousInfra) { - if (!prev.vanishing && !liveInfraPids.contains(prev.pid) && !vanishingInfra.containsKey(prev.pid)) { - vanishingInfra.put(prev.pid, new VanishingInfraInfo(prev, System.currentTimeMillis())); - } - } - long now = System.currentTimeMillis(); - Iterator> infraIt = vanishingInfra.entrySet().iterator(); - while (infraIt.hasNext()) { - Map.Entry entry = infraIt.next(); - if (now - entry.getValue().startTime > VANISH_DURATION_MS) { - infraIt.remove(); - } else if (!liveInfraPids.contains(entry.getKey())) { - InfraInfo ghost = entry.getValue().info; - ghost.vanishing = true; - ghost.vanishStart = entry.getValue().startTime; - infraInfos.add(ghost); - } else { - infraIt.remove(); - } - } - - infraInfos.sort((a, b) -> a.alias.compareToIgnoreCase(b.alias)); - infraData.set(infraInfos); - } - - // ---- 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 = StatusParser.parseTraceEntry(traceJson, pid); - if (entry != null) { - allTraces.add(entry); - } - } - } - } else { - // Fallback: try parsing the line itself as a trace entry - TraceEntry entry = StatusParser.parseTraceEntry(json, pid); - if (entry != null) { - allTraces.add(entry); - } - } - } catch (Exception e) { - // skip malformed lines - } - } - } catch (IOException e) { - // ignore + List selectedPids = dataService.selectedPidAsList(); + if (tabRegistry.selectedTabIndex() == TAB_ERRORS && !selectedPids.isEmpty()) { + dataService.refreshErrorData(selectedPids); } - } - - private JsonObject loadErrorFile(long pid) { - return TuiHelper.loadStatus(pid, this::getErrorFile); - } - - @SuppressWarnings("unchecked") - private void refreshHistoryData(List pids) { - List allEntries = new ArrayList<>(); - for (Long pid : pids) { - Path historyFile = CommandLineHelper.getCamelDir().resolve(pid + "-history.json"); - if (!Files.exists(historyFile)) { - continue; - } - try { - String content = Files.readString(historyFile); - if (content == null || content.isBlank()) { - continue; - } - JsonObject json = (JsonObject) Jsoner.deserialize(content); - Object tracesArray = json.get("traces"); - if (tracesArray instanceof List traceList) { - for (Object traceObj : traceList) { - if (traceObj instanceof JsonObject traceJson) { - HistoryEntry entry = StatusParser.parseHistoryEntry(traceJson, Long.toString(pid)); - if (entry != null) { - allEntries.add(entry); - } - } - } - } - } catch (Exception e) { - // ignore + if (tabRegistry.selectedTabIndex() == TAB_HISTORY && !selectedPids.isEmpty()) { + if (tabRegistry.historyTab().historyRefreshRequested) { + tabRegistry.historyTab().historyRefreshRequested = false; + tabRegistry.historyTab().historyEntries = dataService.loadHistoryData(selectedPids); } + dataService.refreshTraceData(selectedPids); } - historyTab.historyEntries = allEntries; - } - - private void refreshErrorData(List pids) { - IntegrationInfo sel = findSelectedIntegration(); - if (sel == null) { - return; - } - try { - long pid = Long.parseLong(sel.pid); - JsonObject root = loadErrorFile(pid); - if (root != null) { - List parsed = StatusParser.parseErrors(root); - sel.errors.clear(); - sel.errors.addAll(parsed); - } - } catch (Exception e) { - // ignore + if (tabRegistry.selectedTabIndex() == TAB_MORE + && tabRegistry.getActiveMoreTab() == tabRegistry.spansTab() + && ctx.selectedPid != null && tabRegistry.spansTab().spanRefreshRequested) { + tabRegistry.spansTab().spanRefreshRequested = false; + dataService.refreshSpanData(); } } @@ -2120,20 +1535,6 @@ private String selectedName() { return ctx.selectedName(); } - private List findPids(String name) { - return TuiHelper.findPids(name, this::getStatusFile); - } - - private JsonObject loadStatus(long pid) { - return TuiHelper.loadStatus(pid, this::getStatusFile); - } - - record VanishingInfo(IntegrationInfo info, long startTime) { - } - - record VanishingInfraInfo(InfraInfo info, long startTime) { - } - // ---- MCP .mcp.json lifecycle ---- private static Path writeMcpJson(int port) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java new file mode 100644 index 0000000000000..2ab9ac79382ac --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java @@ -0,0 +1,666 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.stream.Collectors; + +import dev.tamboui.tui.TuiRunner; +import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; +import org.apache.camel.dsl.jbang.core.common.PathUtils; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +/** + * Manages all data refresh logic for the TUI monitor: integration scanning, infra services, traces, spans, errors, and + * history data. + *

+ * Extracted from {@link CamelMonitor} to reduce class size and isolate data-loading concerns from event handling and + * rendering. + */ +class DataRefreshService { + + private static final long VANISH_DURATION_MS = 6000; + private static final int MAX_TRACES = 200; + + /** + * Callback interface for operations that remain in {@link CamelMonitor} (UI state queries needed during refresh). + */ + interface RefreshContext { + int selectedTab(); + + boolean isSwitchPopupVisible(); + + String getPendingAutoSelect(); + + void clearPendingAutoSelect(); + + void onInfraAutoSelected(int tableIndex, String pid); + + boolean isInfraSelected(); + } + + // State + private final AtomicReference> data = new AtomicReference<>(Collections.emptyList()); + private final AtomicReference> infraData = new AtomicReference<>(Collections.emptyList()); + private final Map vanishing = new ConcurrentHashMap<>(); + private final Map vanishingInfra = new ConcurrentHashMap<>(); + + // Sparkline/chart history for all metric families + private final MetricsCollector metrics = new MetricsCollector(); + + // Cached PID list -- full process scan throttled to every 2 seconds (1 second in burst mode) + private volatile List cachedPids = Collections.emptyList(); + private volatile long lastFullScanTime; + private volatile long burstModeUntil; + final Set stoppingPids = ConcurrentHashMap.newKeySet(); + + // Trace/history data -- shared between CamelMonitor and tabs + private final AtomicReference> traces = new AtomicReference<>(Collections.emptyList()); + private final Map traceFilePositions = new ConcurrentHashMap<>(); + // OTel span data -- shared between CamelMonitor and SpansTab + private final AtomicReference> otelSpans = new AtomicReference<>(List.of()); + + private volatile long lastRefresh; + private final AtomicBoolean refreshInProgress = new AtomicBoolean(false); + + private MonitorContext ctx; + private final String name; + private final RefreshContext refreshCtx; + private final Function statusFileResolver; + private final Function errorFileResolver; + + // Tab index constants (mirrored from CamelMonitor to avoid coupling) + private static final int TAB_OVERVIEW = 0; + + DataRefreshService( + String name, + RefreshContext refreshCtx, + Function statusFileResolver, + Function errorFileResolver) { + this.name = name; + this.refreshCtx = refreshCtx; + this.statusFileResolver = statusFileResolver; + this.errorFileResolver = errorFileResolver; + } + + /** + * Set the monitor context. Called after construction to break the circular dependency between DataRefreshService + * (owns the AtomicReferences) and MonitorContext (needs those references). + */ + void setContext(MonitorContext ctx) { + this.ctx = ctx; + } + + // ---- Public accessors ---- + + AtomicReference> data() { + return data; + } + + AtomicReference> infraData() { + return infraData; + } + + AtomicReference> traces() { + return traces; + } + + Map traceFilePositions() { + return traceFilePositions; + } + + AtomicReference> otelSpans() { + return otelSpans; + } + + MetricsCollector metrics() { + return metrics; + } + + Set stoppingPids() { + return stoppingPids; + } + + long lastRefresh() { + return lastRefresh; + } + + // ---- Burst mode ---- + + void enableBurstMode() { + burstModeUntil = System.currentTimeMillis() + 20_000; + } + + boolean isBurstMode() { + return System.currentTimeMillis() < burstModeUntil; + } + + // ---- Refresh orchestration ---- + + /** + * Trigger an asynchronous data refresh. If a refresh is already in progress, the call is a no-op. Falls back to + * synchronous refresh when no TUI runner is available. + * + * @param runner the TUI runner for scheduling background work + * @param logRefresher callback to refresh log data (stays on CamelMonitor) + * @param conditionalRefresher callback to refresh tab-conditional data (stays on CamelMonitor) + */ + void refresh(TuiRunner runner, Runnable logRefresher, Runnable conditionalRefresher) { + if (runner == null) { + refreshSync(logRefresher, conditionalRefresher); + return; + } + if (!refreshInProgress.compareAndSet(false, true)) { + return; + } + lastRefresh = System.currentTimeMillis(); + runner.scheduler().execute(() -> { + try { + refreshSync(logRefresher, conditionalRefresher); + } finally { + refreshInProgress.set(false); + } + }); + } + + /** + * Perform a synchronous data refresh cycle: log data, integration scan, auto-select, and conditional tab data. + */ + void refreshSync(Runnable logRefresher, Runnable conditionalRefresher) { + lastRefresh = System.currentTimeMillis(); + try { + logRefresher.run(); + boolean fullScan = scanIntegrations(); + List infos = data.get(); + handleAutoSelect(infos, fullScan); + conditionalRefresher.run(); + } catch (Exception e) { + // ignore refresh errors + } + } + + // ---- PID helpers ---- + + List selectedPidAsList() { + if (ctx.selectedPid == null) { + return Collections.emptyList(); + } + try { + return List.of(Long.parseLong(ctx.selectedPid)); + } catch (NumberFormatException e) { + return Collections.emptyList(); + } + } + + // ---- Integration scanning ---- + + private boolean scanIntegrations() { + List infos = new ArrayList<>(); + long now = System.currentTimeMillis(); + boolean wantFullScan = refreshCtx.selectedTab() == TAB_OVERVIEW || refreshCtx.isSwitchPopupVisible() + || cachedPids.isEmpty(); + long scanInterval = isBurstMode() ? 1000 : 2000; + boolean fullScan = wantFullScan && (now - lastFullScanTime >= scanInterval); + List pids; + if (fullScan) { + pids = findPids(name); + cachedPids = pids; + lastFullScanTime = now; + } else { + pids = cachedPids; + } + + List refreshPids; + if (!fullScan && ctx.selectedPid != null) { + try { + refreshPids = List.of(Long.parseLong(ctx.selectedPid)); + } catch (NumberFormatException e) { + refreshPids = pids; + } + } else { + refreshPids = pids; + } + for (Long pid : refreshPids) { + JsonObject root = loadStatus(pid); + if (root != null) { + ProcessHandle ph = ProcessHandle.of(pid).orElse(null); + if (ph == null) { + continue; + } + IntegrationInfo info = StatusParser.parseIntegration(ph, root); + if (info != null) { + infos.add(info); + metrics.updateThroughputHistory(info); + metrics.updateEndpointHistory(info); + metrics.updateCbHistory(info); + metrics.updateHeapHistory(info); + metrics.updateLoadMetrics(ph, info); + } + } + } + if (!fullScan && ctx.selectedPid != null) { + List previous = data.get(); + for (IntegrationInfo prev : previous) { + if (!prev.vanishing && !ctx.selectedPid.equals(prev.pid)) { + infos.add(prev); + } + } + } + + handleVanishing(infos, now); + data.set(infos); + return fullScan; + } + + private void handleVanishing(List infos, long now) { + 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)) { + boolean wasExplicitStop = stoppingPids.remove(prev.pid); + if (wasExplicitStop) { + metrics.removeVanished(prev.pid); + } else { + vanishing.put(prev.pid, new VanishingInfo(prev, 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(); + metrics.removeVanished(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(); + } + } + } + + private void handleAutoSelect(List infos, boolean fullScan) { + if (ctx.selectedPid != null && !refreshCtx.isInfraSelected()) { + boolean stillAlive = infos.stream() + .anyMatch(i -> ctx.selectedPid.equals(i.pid) && !i.vanishing); + if (!stillAlive) { + IntegrationInfo gone = infos.stream() + .filter(i -> ctx.selectedPid.equals(i.pid)) + .findFirst().orElse(null); + if (gone != null) { + ctx.lastSelectedName = gone.name; + } + ctx.selectedPid = null; + } + } + + String autoSelect = refreshCtx.getPendingAutoSelect(); + if (autoSelect != null) { + for (IntegrationInfo info : infos) { + if (!info.vanishing && autoSelect.equalsIgnoreCase(info.name)) { + ctx.selectedPid = info.pid; + ctx.lastSelectedName = null; + refreshCtx.clearPendingAutoSelect(); + break; + } + } + } + + if (ctx.selectedPid == null && ctx.lastSelectedName != null && !refreshCtx.isInfraSelected()) { + for (IntegrationInfo info : infos) { + if (!info.vanishing && ctx.lastSelectedName.equalsIgnoreCase(info.name)) { + ctx.selectedPid = info.pid; + ctx.lastSelectedName = null; + break; + } + } + } + + if (fullScan) { + refreshInfraData(); + } + + if (ctx.selectedPid == null && !infraData.get().isEmpty() + && infos.stream().noneMatch(i -> !i.vanishing)) { + List infras = infraData.get(); + if (!infras.isEmpty()) { + int firstInfraIndex = infos.size() + (infras.size() > 0 ? 1 : 0); + refreshCtx.onInfraAutoSelected(firstInfraIndex, infras.get(0).pid); + } + } + } + + // ---- Infra data ---- + + @SuppressWarnings("unchecked") + private void refreshInfraData() { + List infraInfos = new ArrayList<>(); + try { + Path camelDir = CommandLineHelper.getCamelDir(); + if (Files.isDirectory(camelDir)) { + try (var files = Files.list(camelDir)) { + List jsonFiles = files + .filter(p -> { + String n = p.getFileName().toString(); + return n.startsWith("infra-") && n.endsWith(".json"); + }) + .toList(); + for (Path jsonFile : jsonFiles) { + String fn = jsonFile.getFileName().toString(); + // Format: infra-{alias}-{pid}.json + String withoutExt = fn.substring(0, fn.lastIndexOf('.')); + int lastDash = withoutExt.lastIndexOf('-'); + if (lastDash <= 6) { + continue; + } + String alias = withoutExt.substring(6, lastDash); + String pidStr = withoutExt.substring(lastDash + 1); + long pid; + try { + pid = Long.parseLong(pidStr); + } catch (NumberFormatException e) { + continue; + } + boolean alive = ProcessHandle.of(pid).map(ProcessHandle::isAlive).orElse(false); + + InfraInfo info = new InfraInfo(); + info.pid = pidStr; + info.alias = alias; + info.alive = alive; + try { + String json = Files.readString(jsonFile); + Object parsed = Jsoner.deserialize(json); + if (parsed instanceof Map map) { + for (Map.Entry e : map.entrySet()) { + info.properties.put(String.valueOf(e.getKey()), e.getValue()); + } + } + } catch (Exception e) { + // ignore parse errors + } + info.serviceVersion = StatusParser.objToString(info.properties.get("serviceVersion")); + infraInfos.add(info); + } + } + } + } catch (IOException e) { + // ignore + } + + // Handle vanishing infra services + Set liveInfraPids = infraInfos.stream().map(i -> i.pid).collect(Collectors.toSet()); + List previousInfra = infraData.get(); + for (InfraInfo prev : previousInfra) { + if (!prev.vanishing && !liveInfraPids.contains(prev.pid) && !vanishingInfra.containsKey(prev.pid)) { + vanishingInfra.put(prev.pid, new VanishingInfraInfo(prev, System.currentTimeMillis())); + } + } + long now = System.currentTimeMillis(); + Iterator> infraIt = vanishingInfra.entrySet().iterator(); + while (infraIt.hasNext()) { + Map.Entry entry = infraIt.next(); + if (now - entry.getValue().startTime > VANISH_DURATION_MS) { + infraIt.remove(); + } else if (!liveInfraPids.contains(entry.getKey())) { + InfraInfo ghost = entry.getValue().info; + ghost.vanishing = true; + ghost.vanishStart = entry.getValue().startTime; + infraInfos.add(ghost); + } else { + infraIt.remove(); + } + } + + infraInfos.sort((a, b) -> a.alias.compareToIgnoreCase(b.alias)); + infraData.set(infraInfos); + } + + // ---- Trace data ---- + + 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 = StatusParser.parseTraceEntry(traceJson, pid); + if (entry != null) { + allTraces.add(entry); + } + } + } + } else { + // Fallback: try parsing the line itself as a trace entry + TraceEntry entry = StatusParser.parseTraceEntry(json, pid); + if (entry != null) { + allTraces.add(entry); + } + } + } catch (Exception e) { + // skip malformed lines + } + } + } catch (IOException e) { + // ignore + } + } + + // ---- Span data ---- + + @SuppressWarnings("unchecked") + void refreshSpanData() { + String pid = ctx.selectedPid; + if (pid == null) { + return; + } + try { + // Send action to request span data + Path outputFile = ctx.getOutputFile(pid); + PathUtils.deleteFile(outputFile); + + JsonObject action = new JsonObject(); + action.put("action", "span"); + action.put("dump", "true"); + action.put("limit", "500"); + Path actionFile = ctx.getActionFile(pid); + PathUtils.writeTextSafely(action.toJson(), actionFile); + + // Poll for response + JsonObject response = MonitorContext.pollJsonResponse(outputFile, 3000); + if (response != null) { + Boolean enabled = response.getBoolean("enabled"); + if (enabled != null && enabled) { + JsonArray arr = response.getCollection("spans"); + if (arr != null) { + List entries = new ArrayList<>(); + for (int i = 0; i < arr.size(); i++) { + JsonObject spanObj = (JsonObject) arr.get(i); + entries.add(SpanEntry.fromJson(spanObj)); + } + otelSpans.set(entries); + } + } + PathUtils.deleteFile(outputFile); + } + } catch (Exception e) { + // ignore + } + } + + // ---- Error data ---- + + void refreshErrorData(List pids) { + IntegrationInfo sel = ctx.findSelectedIntegration(); + if (sel == null) { + return; + } + try { + long pid = Long.parseLong(sel.pid); + JsonObject root = loadErrorFile(pid); + if (root != null) { + List parsed = StatusParser.parseErrors(root); + sel.errors.clear(); + sel.errors.addAll(parsed); + } + } catch (Exception e) { + // ignore + } + } + + // ---- History data ---- + + /** + * Load history data for the given PIDs and return the parsed entries. The caller is responsible for storing the + * entries (e.g. on HistoryTab). + */ + @SuppressWarnings("unchecked") + List loadHistoryData(List pids) { + List allEntries = new ArrayList<>(); + for (Long pid : pids) { + Path historyFile = CommandLineHelper.getCamelDir().resolve(pid + "-history.json"); + if (!Files.exists(historyFile)) { + continue; + } + try { + String content = Files.readString(historyFile); + if (content == null || content.isBlank()) { + continue; + } + JsonObject json = (JsonObject) Jsoner.deserialize(content); + Object tracesArray = json.get("traces"); + if (tracesArray instanceof List traceList) { + for (Object traceObj : traceList) { + if (traceObj instanceof JsonObject traceJson) { + HistoryEntry entry = StatusParser.parseHistoryEntry(traceJson, Long.toString(pid)); + if (entry != null) { + allEntries.add(entry); + } + } + } + } + } catch (Exception e) { + // ignore + } + } + return allEntries; + } + + // ---- Helpers ---- + + private List findPids(String name) { + return TuiHelper.findPids(name, statusFileResolver); + } + + private JsonObject loadStatus(long pid) { + return TuiHelper.loadStatus(pid, statusFileResolver); + } + + private JsonObject loadErrorFile(long pid) { + return TuiHelper.loadStatus(pid, errorFileResolver); + } + + record VanishingInfo(IntegrationInfo info, long startTime) { + } + + record VanishingInfraInfo(InfraInfo info, long startTime) { + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java index eaf52596a0320..84d617dc107e6 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java @@ -104,9 +104,7 @@ interface MonitorBridge { private final HelpOverlay helpOverlay; private final ActionsPopup actionsPopup; private final FilesBrowser filesBrowser; - private final LogTab logTab; - private final DiagramTab diagramTab; - private final HistoryTab historyTab; + private final TabRegistry tabRegistry; private final Queue pendingKeys; private final MonitorBridge bridge; @@ -120,9 +118,7 @@ interface MonitorBridge { HelpOverlay helpOverlay, ActionsPopup actionsPopup, FilesBrowser filesBrowser, - LogTab logTab, - DiagramTab diagramTab, - HistoryTab historyTab, + TabRegistry tabRegistry, Queue pendingKeys, MonitorBridge bridge) { this.ctx = ctx; @@ -134,9 +130,7 @@ interface MonitorBridge { this.helpOverlay = helpOverlay; this.actionsPopup = actionsPopup; this.filesBrowser = filesBrowser; - this.logTab = logTab; - this.diagramTab = diagramTab; - this.historyTab = historyTab; + this.tabRegistry = tabRegistry; this.pendingKeys = pendingKeys; this.bridge = bridge; } @@ -389,7 +383,7 @@ boolean executeAction(String actionName) { } JsonObject getLogData(int limit, String filter, String level) { - return logTab.getLogDataAsJson(limit, filter, level); + return tabRegistry.logTab().getLogDataAsJson(limit, filter, level); } JsonObject getDiagramData() { @@ -397,15 +391,15 @@ JsonObject getDiagramData() { if (tab instanceof DiagramTab dt) { return dt.getTableDataAsJson(); } - return diagramTab.getTableDataAsJson(); + return tabRegistry.diagramTab().getTableDataAsJson(); } void selectTraceExchange(String exchangeId) { - historyTab.selectTraceExchange(exchangeId); + tabRegistry.historyTab().selectTraceExchange(exchangeId); } JsonObject getTopologyData() { - return diagramTab.getTopologyDataAsJson(); + return tabRegistry.diagramTab().getTopologyDataAsJson(); } @SuppressWarnings("unchecked") @@ -466,7 +460,7 @@ JsonObject getSpanData(String traceId, int limit) { String navigateDiagramToRoute(String routeId) { navigateToTab("Diagram"); - if (diagramTab.selectRoute(routeId)) { + if (tabRegistry.diagramTab().selectRoute(routeId)) { return routeId; } return null; @@ -474,14 +468,14 @@ String navigateDiagramToRoute(String routeId) { String navigateDiagramToNode(String routeId, String nodeId) { navigateToTab("Diagram"); - if (diagramTab.selectNode(routeId, nodeId)) { + if (tabRegistry.diagramTab().selectNode(routeId, nodeId)) { return nodeId; } return null; } JsonObject getDiagramState() { - return diagramTab.getDiagramStateAsJson(); + return tabRegistry.diagramTab().getDiagramStateAsJson(); } // ---- Screen location ---- @@ -527,7 +521,7 @@ JsonArray locateText(String search) { } JsonObject locateNodes(List nodeIds) { - return diagramTab.locateNodes(nodeIds); + return tabRegistry.diagramTab().locateNodes(nodeIds); } // ---- Footer actions ---- @@ -602,7 +596,7 @@ JsonArray getFooterActionsAsJson() { // ---- Tab filter / input ---- void setLogLevel(String level) { - logTab.setLogLevel(level); + tabRegistry.logTab().setLogLevel(level); } boolean setTabFilter(String tabName, String filter) { @@ -622,7 +616,7 @@ boolean setTabInputValue(String tabName, String field, String value) { } String toggleTraceDisplay(String section, Boolean enabled) { - return historyTab.toggleDisplaySection(section, enabled); + return tabRegistry.historyTab().toggleDisplaySection(section, enabled); } // ---- Integration data ---- diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java new file mode 100644 index 0000000000000..68a948ed51e28 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java @@ -0,0 +1,305 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.List; + +import dev.tamboui.widgets.tabs.TabsState; + +/** + * Owns all tab instances, tab index constants, and tab navigation logic. Extracted from {@link CamelMonitor} to reduce + * class size. + */ +class TabRegistry { + + // Tab indices + static final int TAB_OVERVIEW = 0; + static final int TAB_LOG = 1; + static final int TAB_DIAGRAM = 2; + static final int TAB_ROUTES = 3; + static final int TAB_ENDPOINTS = 4; + static final int TAB_HTTP = 5; + static final int TAB_HEALTH = 6; + static final int TAB_HISTORY = 7; + static final int TAB_ERRORS = 8; + static final int TAB_MORE = 9; + + static final int NUM_TABS = 10; + + /** + * Callbacks for operations that remain in {@link CamelMonitor} or other collaborators. + */ + interface TabCallbacks { + void refreshLogData(); + + void refreshHistoryData(List pids); + + void refreshTraceData(List pids); + + void refreshErrorData(List pids); + + void openMorePopup(); + + void closeMorePopup(); + + void selectMorePopupEntry(int index); + } + + private final TabsState tabsState; + private TabCallbacks callbacks; + + // Tab instances + private LogTab logTab; + private DiagramTab diagramTab; + private RoutesTab routesTab; + private ConsumersTab consumersTab; + private EndpointsTab endpointsTab; + private HttpTab httpTab; + private HealthTab healthTab; + private HistoryTab historyTab; + private CircuitBreakerTab circuitBreakerTab; + private ErrorsTab errorsTab; + private MetricsTab metricsTab; + private StartupTab startupTab; + private ConfigurationTab configurationTab; + private BeansTab beansTab; + private BrowseTab browseTab; + private ClasspathTab classpathTab; + private InflightTab inflightTab; + private MemoryTab memoryTab; + private ThreadsTab threadsTab; + private SpansTab spansTab; + private ProcessTab processTab; + private OverviewTab overviewTab; + private DataSourceTab dataSourceTab; + private SqlQueryTab sqlQueryTab; + + private MonitorTab activeMoreTab; + + TabRegistry(TabsState tabsState) { + this.tabsState = tabsState; + } + + void setCallbacks(TabCallbacks callbacks) { + this.callbacks = callbacks; + } + + void initTabs(MonitorContext ctx, DataRefreshService dataService, Runnable resetIntegrationTabState) { + logTab = new LogTab(ctx); + diagramTab = new DiagramTab(ctx); + routesTab = new RoutesTab(ctx); + consumersTab = new ConsumersTab(ctx); + dataSourceTab = new DataSourceTab(ctx); + sqlQueryTab = new SqlQueryTab(ctx); + endpointsTab = new EndpointsTab(ctx, dataService.metrics()); + httpTab = new HttpTab(ctx); + healthTab = new HealthTab(ctx); + historyTab = new HistoryTab(ctx, dataService.traces(), dataService.traceFilePositions()); + circuitBreakerTab = new CircuitBreakerTab(ctx, dataService.metrics()); + errorsTab = new ErrorsTab(ctx); + metricsTab = new MetricsTab(ctx); + startupTab = new StartupTab(ctx); + configurationTab = new ConfigurationTab(ctx); + beansTab = new BeansTab(ctx); + browseTab = new BrowseTab(ctx); + classpathTab = new ClasspathTab(ctx); + inflightTab = new InflightTab(ctx); + memoryTab = new MemoryTab(ctx, dataService.metrics()); + threadsTab = new ThreadsTab(ctx); + spansTab = new SpansTab(ctx, dataService.otelSpans()); + processTab = new ProcessTab(ctx); + overviewTab = new OverviewTab( + ctx, dataService.metrics(), dataService.stoppingPids(), + resetIntegrationTabState); + } + + // ---- Tab access ---- + + MonitorTab activeTab() { + return switch (tabsState.selected()) { + case TAB_OVERVIEW -> overviewTab; + case TAB_LOG -> logTab; + case TAB_DIAGRAM -> diagramTab; + case TAB_ROUTES -> routesTab; + case TAB_ENDPOINTS -> endpointsTab; + case TAB_HEALTH -> healthTab; + case TAB_HISTORY -> historyTab; + case TAB_HTTP -> httpTab; + case TAB_ERRORS -> errorsTab; + case TAB_MORE -> activeMoreTab; + default -> null; + }; + } + + MonitorTab getActiveMoreTab() { + return activeMoreTab; + } + + int selectedTabIndex() { + return tabsState.selected(); + } + + // ---- Navigation ---- + + boolean handleTabKey(int tab, MonitorContext ctx, DataRefreshService dataService) { + if (tab != TAB_OVERVIEW) { + overviewTab.selectCurrentIntegration(); + routesTab.preloadDiagram(); + diagramTab.preloadDiagram(); + } + if (tab == TAB_LOG) { + callbacks.refreshLogData(); + logTab.onTabSelected(); + } + if (tab == TAB_ROUTES && routesTab != null && routesTab.isShowDiagram()) { + routesTab.closeDiagram(); + } + if (tab == TAB_DIAGRAM) { + diagramTab.onTabSelected(); + } + if (tab == TAB_HISTORY && ctx.selectedPid != null) { + try { + long pid = Long.parseLong(ctx.selectedPid); + historyTab.historyEntries = dataService.loadHistoryData(List.of(pid)); + dataService.refreshTraceData(List.of(pid)); + } catch (NumberFormatException e) { + // ignore + } + historyTab.onTabSelected(); + } + if (tab == TAB_ERRORS && ctx.selectedPid != null) { + try { + long pid = Long.parseLong(ctx.selectedPid); + dataService.refreshErrorData(List.of(pid)); + } catch (NumberFormatException e) { + // ignore + } + errorsTab.onTabSelected(); + } + if (tab == TAB_MORE) { + callbacks.openMorePopup(); + return true; + } + callbacks.closeMorePopup(); + tabsState.select(tab); + return true; + } + + void selectMoreTab(int index) { + callbacks.selectMorePopupEntry(index); + activeMoreTab = switch (index) { + case 0 -> beansTab; + case 1 -> browseTab; + case 2 -> circuitBreakerTab; + case 3 -> classpathTab; + case 4 -> configurationTab; + case 5 -> consumersTab; + case 6 -> dataSourceTab; + case 7 -> inflightTab; + case 8 -> memoryTab; + case 9 -> metricsTab; + case 10 -> sqlQueryTab; + case 11 -> spansTab; + case 12 -> processTab; + case 13 -> startupTab; + case 14 -> threadsTab; + default -> null; + }; + if (activeMoreTab != null) { + overviewTab.selectCurrentIntegration(); + tabsState.select(TAB_MORE); + activeMoreTab.onTabSelected(); + } + } + + void resetIntegrationTabState(DataRefreshService dataService, FilesBrowser filesBrowser) { + diagramTab.onIntegrationChanged(); + routesTab.onIntegrationChanged(); + httpTab.onIntegrationChanged(); + logTab.onIntegrationChanged(); + historyTab.onIntegrationChanged(); + beansTab.onIntegrationChanged(); + browseTab.onIntegrationChanged(); + threadsTab.onIntegrationChanged(); + startupTab.onIntegrationChanged(); + configurationTab.onIntegrationChanged(); + consumersTab.onIntegrationChanged(); + dataSourceTab.onIntegrationChanged(); + sqlQueryTab.onIntegrationChanged(); + circuitBreakerTab.onIntegrationChanged(); + inflightTab.onIntegrationChanged(); + spansTab.onIntegrationChanged(); + processTab.onIntegrationChanged(); + dataService.otelSpans().set(List.of()); + + filesBrowser.reset(); + + // Preload diagram data in background so it's ready when the user switches tabs + routesTab.preloadDiagram(); + diagramTab.preloadDiagram(); + } + + void navigateUp() { + activeTab().navigateUp(); + } + + void navigateDown() { + activeTab().navigateDown(); + } + + // ---- Typed tab accessors ---- + + LogTab logTab() { + return logTab; + } + + DiagramTab diagramTab() { + return diagramTab; + } + + RoutesTab routesTab() { + return routesTab; + } + + HttpTab httpTab() { + return httpTab; + } + + HealthTab healthTab() { + return healthTab; + } + + HistoryTab historyTab() { + return historyTab; + } + + ErrorsTab errorsTab() { + return errorsTab; + } + + SpansTab spansTab() { + return spansTab; + } + + OverviewTab overviewTab() { + return overviewTab; + } + + SqlQueryTab sqlQueryTab() { + return sqlQueryTab; + } +} From fd68d64c971f8669e92463f25628add21f5d27cb Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Mon, 29 Jun 2026 19:14:15 +0000 Subject: [PATCH 4/4] chore: use TabRegistry.TAB_OVERVIEW instead of duplicated constants Remove duplicated TAB_OVERVIEW constant from DataRefreshService and McpFacade, referencing TabRegistry.TAB_OVERVIEW directly since all classes are in the same package. Co-Authored-By: Claude Opus 4.6 --- .../dsl/jbang/core/commands/tui/DataRefreshService.java | 5 +---- .../apache/camel/dsl/jbang/core/commands/tui/McpFacade.java | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java index 2ab9ac79382ac..2556eae8eef32 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java @@ -99,9 +99,6 @@ interface RefreshContext { private final Function statusFileResolver; private final Function errorFileResolver; - // Tab index constants (mirrored from CamelMonitor to avoid coupling) - private static final int TAB_OVERVIEW = 0; - DataRefreshService( String name, RefreshContext refreshCtx, @@ -227,7 +224,7 @@ List selectedPidAsList() { private boolean scanIntegrations() { List infos = new ArrayList<>(); long now = System.currentTimeMillis(); - boolean wantFullScan = refreshCtx.selectedTab() == TAB_OVERVIEW || refreshCtx.isSwitchPopupVisible() + boolean wantFullScan = refreshCtx.selectedTab() == TabRegistry.TAB_OVERVIEW || refreshCtx.isSwitchPopupVisible() || cachedPids.isEmpty(); long scanInterval = isBurstMode() ? 1000 : 2000; boolean fullScan = wantFullScan && (now - lastFullScanTime >= scanInterval); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java index 84d617dc107e6..99efb93f2856d 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java @@ -93,8 +93,6 @@ interface MonitorBridge { "Threads" }; - private static final int TAB_OVERVIEW = 0; - private final MonitorContext ctx; private final AtomicReference> data; private final TabsState tabsState; @@ -546,7 +544,7 @@ JsonArray getFooterActionsAsJson() { } } else { MonitorTab tab = bridge.activeTab(); - if (tabsState.selected() == TAB_OVERVIEW) { + if (tabsState.selected() == TabRegistry.TAB_OVERVIEW) { bridge.renderOverviewFooter(spans); } else if (tab != null) { tab.renderFooter(spans);