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());

Reply via email to