This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch tui-ollama-tab in repository https://gitbox.apache.org/repos/asf/camel.git
commit 98b4b1adff645595239ca0b19af49f77d1543a65 Author: Claus Ibsen <[email protected]> AuthorDate: Thu Sep 17 12:45:14 2026 +0200 CAMEL-24794: camel-jbang - TUI lists the Ollama tab only while an Ollama server answers The More menu entry now depends on a background probe: endpoint detection and one version request every ten seconds, run from the regular refresh cycle whether or not the tab is showing. MoreTab gains an availableWhen supplier that, unlike activeWhen, also applies when no integration is selected, so the tab appears shortly after ollama serve starts and disappears when it stops. Full polling still happens only while the tab is active. Co-Authored-By: Claude Fable 5.1 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../modules/ROOT/pages/camel-jbang-tui.adoc | 5 +- .../dsl/jbang/core/commands/tui/CamelMonitor.java | 4 + .../dsl/jbang/core/commands/tui/OllamaMonitor.java | 89 ++++++++++++++-------- .../dsl/jbang/core/commands/tui/TabRegistry.java | 27 +++++-- .../src/main/resources/tui/help/ollama.md | 5 +- .../jbang/core/commands/tui/OllamaMonitorTest.java | 10 +++ .../jbang/core/commands/tui/TabRegistryTest.java | 41 ++++++++-- 7 files changed, 136 insertions(+), 45 deletions(-) diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc index 5851b3104896..369e9e2de5ad 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc @@ -150,7 +150,7 @@ The *More* menu (key *0*) opens a popup with tabs organized into groups: * *Routing* -- Browse Endpoints, Consumers, HTTP, Inflight, Producers, Route Controller * *Observability* -- Circuit Breaker, Health, JFR, Metrics, Network Services, Exchange Events, Recovery Tasks, OpenTelemetry Spans -* *AI* -- Ollama +* *AI* -- Ollama (listed when an Ollama server is detected) * *Data* -- JDBC DataSource, Kafka, SQL Query, SQL Trace * *JVM* -- Classpath, Heap Memory Histogram, Memory Usage, Memory Leak, Process, Startup, Threads * *Project* -- Beans, Catalog, Configuration, CVE Audit, Maven Dependencies, Type Converters, Data Type Transformers @@ -417,7 +417,8 @@ correlating Camel processing with external service calls (HTTP, database, messag The Ollama tab (under More, in the *AI* group) shows how the model served by a local https://ollama.com[Ollama] is performing, in the spirit of an LLM dashboard. It works with or without a running integration: it finds Ollama at `localhost:11434`, at the address of `camel infra run ollama`, -or at the endpoint the AI panel (*F8*) is using. +or at the endpoint the AI panel (*F8*) is using. The tab is listed only while an Ollama server answers; +the TUI checks every ten seconds, so it appears shortly after `ollama serve` starts. * *Model* -- the loaded model with its family, parameters, quantization, layers, experts (and how many are active per token for a mixture-of-experts model), how much of it sits in GPU memory, the 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 55f3feef7a82..45ab04da7d0b 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 @@ -2941,6 +2941,10 @@ public class CamelMonitor extends CamelCommand { aiPanel.spanRefreshRequested = false; dataService.refreshSpanData(); } + if (ollamaMonitor != null) { + // cheap and throttled: keeps the More menu's Ollama entry in step with whether a server answers + ollamaMonitor.probe(); + } if (tabRegistry.selectedTabIndex() == TAB_MORE && tabRegistry.getActiveMoreTab() == tabRegistry.ollamaTab() && ollamaMonitor != null) { ollamaMonitor.poll(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java index b7d0f485d183..00cf91d53c40 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java @@ -261,7 +261,7 @@ final class OllamaMonitor { private String lastError; private Instant lastPoll; - private long lastDetect; + private long lastProbe; private long lastVersion; private long lastPs; private long lastTags; @@ -274,6 +274,36 @@ final class OllamaMonitor { // ---- input from the rest of the TUI ---- + /** Whether an Ollama server currently answers; the More menu lists the tab only then. */ + boolean isAvailable() { + synchronized (lock) { + return server != null; + } + } + + /** + * Cheap background check while the tab is not showing, so the More menu can list it as soon as Ollama comes up and + * drop it when Ollama goes away: endpoint detection and one version request every ten seconds. Full polling happens + * in {@link #poll()} while the tab is active. + */ + void probe() { + if (!polling.compareAndSet(false, true)) { + return; + } + try { + long now = System.currentTimeMillis(); + if (now - lastProbe >= DETECT_INTERVAL_MS) { + lastProbe = now; + lastVersion = now; + checkServer(); + } + } catch (Exception e) { + LOG.debug("Ollama probe failed", e); + } finally { + polling.set(false); + } + } + /** Uses the Ollama endpoint another part of the TUI already resolved (the AI panel's client, an explicit URL). */ void adoptEndpoint(String url) { if (url == null || url.isBlank()) { @@ -530,22 +560,6 @@ final class OllamaMonitor { } private void doPoll(long now) { - String base = baseUrl; - if (base == null) { - if (now - lastDetect >= DETECT_INTERVAL_MS) { - lastDetect = now; - base = detectEndpoint(); - if (base != null) { - adoptEndpoint(base); - base = baseUrl; - } - } - if (base == null) { - tick(now); - return; - } - } - boolean connected; synchronized (lock) { connected = server != null; @@ -553,24 +567,14 @@ final class OllamaMonitor { long versionInterval = connected ? VERSION_INTERVAL_MS : RECONNECT_INTERVAL_MS; if (now - lastVersion >= versionInterval) { lastVersion = now; - JsonObject version = getJsonObject(base + "/api/version"); - if (version == null) { - synchronized (lock) { - server = null; - models = List.of(); - slot = null; - lastError = "Ollama not reachable at " + OllamaParsers.displayHost(base); - } - tick(now); - return; - } - updateServer(new ServerInfo(base, OllamaParsers.str(version, "version"), OllamaParsers.isLoopbackUrl(base))); - connected = true; + lastProbe = now; + connected = checkServer(); } if (!connected) { tick(now); return; } + String base = baseUrl; if (now - lastPs >= PS_INTERVAL_MS) { lastPs = now; @@ -671,6 +675,31 @@ final class OllamaMonitor { return shape; } + /** Detects the endpoint when none is known yet and confirms the server answers; true when connected. */ + private boolean checkServer() { + String base = baseUrl; + if (base == null) { + base = detectEndpoint(); + if (base == null) { + return false; + } + adoptEndpoint(base); + base = baseUrl; + } + JsonObject version = getJsonObject(base + "/api/version"); + if (version == null) { + synchronized (lock) { + server = null; + models = List.of(); + slot = null; + lastError = "Ollama not reachable at " + OllamaParsers.displayHost(base); + } + return false; + } + updateServer(new ServerInfo(base, OllamaParsers.str(version, "version"), OllamaParsers.isLoopbackUrl(base))); + return true; + } + private String detectEndpoint() { try { LlmClient client = LlmClient.create().withApiType(LlmClient.ApiType.ollama); 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 index b0f6f9555c97..3cfc2b5221f5 100644 --- 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 @@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.util.ArrayList; import java.util.List; +import java.util.function.BooleanSupplier; import java.util.function.Predicate; import dev.tamboui.widgets.tabs.TabsState; @@ -164,7 +165,8 @@ class TabRegistry { threadsTab = new ThreadsTab(ctx); spansTab = new SpansTab(ctx, dataService.otelSpans()); processTab = new ProcessTab(ctx); - ollamaTab = new OllamaTab(ctx, ctx.ollamaMonitor); + OllamaMonitor ollamaMonitor = ctx.ollamaMonitor; + ollamaTab = new OllamaTab(ctx, ollamaMonitor); overviewTab = new OverviewTab( ctx, dataService.metrics(), dataService.stoppingPids(), resetIntegrationTabState); @@ -218,7 +220,10 @@ class TabRegistry { TuiIcons.TAB_JFR, "JFR", "J&FR", jfrTab, "Observability", List.of("jfr")), // Data - new MoreTab(TuiIcons.TAB_OLLAMA, "Ollama", "&Ollama", ollamaTab, "AI"), + new MoreTab( + TuiIcons.TAB_OLLAMA, "Ollama", "&Ollama", ollamaTab, "AI", List.of(), null, + // listed only while an Ollama server answers; the monitor probes in the background + () -> ollamaMonitor != null && ollamaMonitor.isAvailable()), new MoreTab( TuiIcons.TAB_DATASOURCE, "JDBC DataSource", "&JDBC DataSource", dataSourceTab, "Data", List.of(), info -> !info.dataSources.isEmpty()), @@ -481,21 +486,28 @@ class TabRegistry { * @param requiredConsoles dev console IDs that must be present (any-of) for this tab to be active; empty = always * active * @param activeWhen runtime predicate on IntegrationInfo; null = always active (after console check) + * @param availableWhen runtime check independent of any integration (e.g. a local service answers); null = + * always available. Unlike {@code activeWhen} it also applies when nothing is selected. */ record MoreTab(String icon, String name, String label, MonitorTab tab, String group, - List<String> requiredConsoles, Predicate<IntegrationInfo> activeWhen) { + List<String> requiredConsoles, Predicate<IntegrationInfo> activeWhen, BooleanSupplier availableWhen) { + + MoreTab(String icon, String name, String label, MonitorTab tab, String group, + List<String> requiredConsoles, Predicate<IntegrationInfo> activeWhen) { + this(icon, name, label, tab, group, requiredConsoles, activeWhen, null); + } MoreTab(String icon, String name, String label, MonitorTab tab, String group, List<String> requiredConsoles) { - this(icon, name, label, tab, group, requiredConsoles, null); + this(icon, name, label, tab, group, requiredConsoles, null, null); } MoreTab(String icon, String name, String label, MonitorTab tab, String group) { - this(icon, name, label, tab, group, List.of(), null); + this(icon, name, label, tab, group, List.of(), null, null); } MoreTab(String icon, String name, String label, MonitorTab tab) { - this(icon, name, label, tab, null, List.of(), null); + this(icon, name, label, tab, null, List.of(), null, null); } MoreTab { @@ -524,6 +536,9 @@ class TabRegistry { } static boolean isMoreTabActive(MoreTab mt, IntegrationInfo info) { + if (mt.availableWhen() != null && !mt.availableWhen().getAsBoolean()) { + return false; + } if (mt.requiredConsoles().isEmpty() && mt.activeWhen() == null) { return true; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md index da15e63a38dd..e63b78c069fb 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md @@ -3,7 +3,10 @@ The Ollama tab shows how the model served by Ollama is performing, in the spirit of an LLM dashboard. It finds Ollama at `localhost:11434`, at the address of `camel infra run ollama`, or at the endpoint the AI -panel (F8) uses, and works with or without a running integration. +panel (F8) uses, and works with or without a running integration. The +tab is listed in the More menu only while an Ollama server answers; the +TUI checks every ten seconds, so it appears shortly after `ollama serve` +starts and disappears when it stops. Two kinds of requests feed it. Questions asked in the AI panel with Ollama as the provider arrive with the timings Ollama reports for every diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java index 467422474974..a4783ed5fb64 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java @@ -203,6 +203,16 @@ class OllamaMonitorTest { assertEquals(0, history[0]); } + @Test + void availabilityFollowsTheServer() { + OllamaMonitor monitor = new OllamaMonitor(); + assertFalse(monitor.isAvailable()); + monitor.updateServer(new ServerInfo("http://localhost:11434", "0.33.3", true)); + assertTrue(monitor.isAvailable()); + monitor.updateServer(null); + assertFalse(monitor.isAvailable()); + } + @Test void resetClearsRequestsTotalsAndHistoryButKeepsServer() { OllamaMonitor monitor = new OllamaMonitor(); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistryTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistryTest.java index 4594d1495664..081443e3dcc5 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistryTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistryTest.java @@ -80,6 +80,30 @@ class TabRegistryTest { }); } + @Test + void availableWhenHidesATabEvenWithoutASelectedIntegration() { + MonitorTab tab = registry.moreTabs().get(0).tab(); + TabRegistry.MoreTab hidden = new TabRegistry.MoreTab( + "🦙", "Probe", "&Probe", tab, "AI", List.of(), null, () -> false); + TabRegistry.MoreTab shown = new TabRegistry.MoreTab( + "🦙", "Probe", "&Probe", tab, "AI", List.of(), null, () -> true); + IntegrationInfo info = new IntegrationInfo(); + + assertFalse(TabRegistry.isMoreTabActive(hidden, null)); + assertFalse(TabRegistry.isMoreTabActive(hidden, info)); + assertTrue(TabRegistry.isMoreTabActive(shown, null)); + assertTrue(TabRegistry.isMoreTabActive(shown, info)); + } + + @Test + void ollamaTabIsListedOnlyWhileTheMonitorSeesAServer() { + TabRegistry.MoreTab ollama = registry.moreTabs().stream() + .filter(mt -> "Ollama".equals(mt.name())).findFirst().orElseThrow(); + // the registry in this test has no monitor at all: never listed + assertFalse(TabRegistry.isMoreTabActive(ollama, null)); + assertEquals("AI", ollama.group()); + } + @Test void everyMoreTabLabelHasMnemonicMarker() { for (TabRegistry.MoreTab mt : registry.moreTabs()) { @@ -118,7 +142,9 @@ class TabRegistryTest { @Test void allTabEntriesExposeDigitsIconsAndMoreShortcuts() { List<TabRegistry.TabEntry> entries = registry.allTabEntries(); - assertEquals(9 + registry.moreTabs().size(), entries.size()); + // the Ollama tab is listed only while a server answers, and this registry has no monitor + assertEquals(registry.moreTabs().size() - 1, registry.activeMoreTabs(null).size()); + assertEquals(9 + registry.activeMoreTabs(null).size(), entries.size()); // Primary tabs: digit shortcuts 1-9, moreIndex -1, icon indexed by tabIndex. for (int i = 0; i < 9; i++) { @@ -128,12 +154,15 @@ class TabRegistryTest { assertEquals(TuiIcons.PRIMARY_TAB_ICONS.get(e.tabIndex()), e.icon()); } - // More tabs: tabIndex TAB_MORE, ascending moreIndex, shortcut/name/icon carried from the owning MoreTab. - for (int i = 0; i < registry.moreTabs().size(); i++) { - TabRegistry.TabEntry e = entries.get(9 + i); - TabRegistry.MoreTab mt = registry.moreTabs().get(i); + // More tabs: tabIndex TAB_MORE, ascending moreIndex into the full list, shortcut/name/icon carried from + // the owning MoreTab; a tab that is not listed leaves a gap in the indexes. + int previousIndex = -1; + for (int i = 9; i < entries.size(); i++) { + TabRegistry.TabEntry e = entries.get(i); + TabRegistry.MoreTab mt = registry.moreTabs().get(e.moreIndex()); assertEquals(TabRegistry.TAB_MORE, e.tabIndex()); - assertEquals(i, e.moreIndex()); + assertTrue(e.moreIndex() > previousIndex, "More indexes ascend"); + previousIndex = e.moreIndex(); assertEquals(String.valueOf(mt.shortcut()), e.shortcut()); assertEquals(mt.name(), e.name()); assertEquals(mt.icon(), e.icon());
