feat: load and cache Ollama show data per model with clickable model details

- Add GET /api/v1/models/{model_name}/show endpoint (proxy to Ollama /api/show)
- Worker now fetches show data for each model during model list sync
- Persist show details in localStorage under llm_monitor_models.showByModel
- Make model cards clickable to display cached show details in a dedicated panel
- Keep UI updates incremental without full page reload
- Add tests for show endpoint and OpenAPI path
- Update README and PRD with show-flow and click-card behavior
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-24 19:41:46 +02:00
parent 32b1130632
commit 57663400ce
7 changed files with 217 additions and 4 deletions
+7 -1
View File
@@ -131,12 +131,15 @@ Attualmente, per verificare i modelli LLM in Ollama, è necessario:
- Data ultimo aggiornamento - Data ultimo aggiornamento
- Digest (hash univoco) - Digest (hash univoco)
- Pulsante refresh manuale - Pulsante refresh manuale
- Pannello dettagli modello su click card
**Behavior:** **Behavior:**
- Auto-refresh ogni 30 secondi - Auto-refresh ogni 30 secondi
- Aggiorna solo elementi cambiati (no full re-render) - Aggiorna solo elementi cambiati (no full re-render)
- Mostra loading state durante fetch - Mostra loading state durante fetch
- Error handling con messaggi chiari - Error handling con messaggi chiari
- Durante il refresh lista, chiama `show` per ogni modello e salva i dettagli in cache locale
- Click su card modello apre i dettagli `show` senza page reload
--- ---
@@ -187,6 +190,9 @@ Dettagli di un modello specifico
} }
``` ```
#### `GET /api/v1/models/{model_name}/show`
Proxy dell'endpoint Ollama `POST /api/show` per ottenere informazioni estese sul modello
#### `POST /api/v1/models/{model_name}/pull` #### `POST /api/v1/models/{model_name}/pull`
Scarica/carica un modello (**disabilitato di default**) Scarica/carica un modello (**disabilitato di default**)
@@ -221,7 +227,7 @@ Elimina un modello (**disabilitato di default**)
**Dati Salvati:** **Dati Salvati:**
- `llm_monitor_health` - Status health - `llm_monitor_health` - Status health
- `llm_monitor_models` - Elenco modelli - `llm_monitor_models` - Elenco modelli + mappa dettagli `showByModel`
**Benefit:** **Benefit:**
- Offline support - Offline support
+16
View File
@@ -6,6 +6,7 @@ Una dashboard web moderna e intuitiva per monitorare e gestire i modelli LLM car
-**Dashboard intuitiva** - Visualizza in tempo reale i modelli caricati in Ollama -**Dashboard intuitiva** - Visualizza in tempo reale i modelli caricati in Ollama
- 📊 **Monitoraggio modelli** - Dettagli completi di ogni modello (nome, dimensione, memoria, stato) - 📊 **Monitoraggio modelli** - Dettagli completi di ogni modello (nome, dimensione, memoria, stato)
- 🧩 **Dettagli avanzati on click** - Clic su una card modello per visualizzare i dati Ollama `show`
- 🔌 **API REST documentata** - Documentazione interattiva con Swagger/OpenAPI - 🔌 **API REST documentata** - Documentazione interattiva con Swagger/OpenAPI
- 🎨 **UI moderna** - Interfaccia elegante realizzata con TailwindCSS - 🎨 **UI moderna** - Interfaccia elegante realizzata con TailwindCSS
- 🐳 **Docker ready** - Container sempre acceso (until stopped) - 🐳 **Docker ready** - Container sempre acceso (until stopped)
@@ -147,6 +148,12 @@ GET /api/v1/models
GET /api/v1/models/{model_name} GET /api/v1/models/{model_name}
``` ```
#### Dettagli estesi da Ollama show
```bash
GET /api/v1/models/{model_name}/show
```
#### Health check API Ollama #### Health check API Ollama
```bash ```bash
@@ -187,10 +194,19 @@ curl http://localhost:8000/api/v1/models
# Ottenere info su un modello # Ottenere info su un modello
curl http://localhost:8000/api/v1/models/llama2 curl http://localhost:8000/api/v1/models/llama2
# Ottenere dettagli estesi show
curl http://localhost:8000/api/v1/models/llama2/show
# Health check # Health check
curl http://localhost:8000/api/v1/health curl http://localhost:8000/api/v1/health
``` ```
### Comportamento dashboard
- Al refresh della lista modelli, per ogni modello viene recuperato anche il dettaglio `show`.
- I dati vengono salvati in localStorage nella chiave `llm_monitor_models` (campo `showByModel`).
- Cliccando su una card modello, la dashboard mostra i dettagli `show` senza ricaricare la pagina.
## 🐳 Docker ## 🐳 Docker
### Build dell'immagine ### Build dell'immagine
+53 -1
View File
@@ -4,7 +4,7 @@ Models endpoints - Gestione dei modelli Ollama
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional from typing import Any, Dict, List, Optional
from datetime import datetime from datetime import datetime
import requests import requests
import logging import logging
@@ -174,6 +174,58 @@ async def get_model(model_name: str):
detail="Errore nel recupero del modello" detail="Errore nel recupero del modello"
) )
@router.get("/models/{model_name}/show")
async def get_model_show(model_name: str) -> Dict[str, Any]:
"""
Recupera le informazioni estese di un modello tramite endpoint Ollama /api/show.
Args:
model_name: Nome del modello da interrogare
Returns:
Dict[str, Any]: Dati estesi del modello
"""
try:
response = requests.post(
f"{settings.OLLAMA_HOST}/api/show",
json={"model": model_name},
timeout=settings.OLLAMA_TIMEOUT
)
if response.status_code == 404:
raise HTTPException(
status_code=404,
detail=f"Modello '{model_name}' non trovato"
)
if response.status_code != 200:
raise HTTPException(
status_code=502,
detail="Errore durante il recupero dettagli modello"
)
return response.json()
except requests.exceptions.Timeout:
raise HTTPException(
status_code=504,
detail="Timeout: Ollama non ha risposto in tempo"
)
except requests.exceptions.ConnectionError:
raise HTTPException(
status_code=502,
detail="Impossible connettersi a Ollama"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching model show data: {e}")
raise HTTPException(
status_code=500,
detail="Errore nel recupero dei dettagli modello"
)
@router.post( @router.post(
"/models/{model_name}/pull", "/models/{model_name}/pull",
include_in_schema=settings.ENABLE_MODEL_RW_API include_in_schema=settings.ENABLE_MODEL_RW_API
+58 -2
View File
@@ -6,6 +6,7 @@
class LLMMonitorApp { class LLMMonitorApp {
constructor() { constructor() {
this.worker = null; this.worker = null;
this.selectedModelName = null;
this.lastData = { this.lastData = {
health: null, health: null,
models: null models: null
@@ -35,6 +36,15 @@ class LLMMonitorApp {
document.getElementById("refresh-btn")?.addEventListener("click", () => { document.getElementById("refresh-btn")?.addEventListener("click", () => {
this.requestSync(); this.requestSync();
}); });
// Listener click card modello
document.getElementById("models-container")?.addEventListener("click", (event) => {
const card = event.target.closest("[data-model-name]");
if (!card) {
return;
}
this.showModelDetails(card.getAttribute("data-model-name"));
});
} }
// Caricare dati da localStorage // Caricare dati da localStorage
@@ -68,12 +78,17 @@ class LLMMonitorApp {
if (type === "DATA_UPDATED") { if (type === "DATA_UPDATED") {
if (health && JSON.stringify(this.lastData.health) !== JSON.stringify(health)) { if (health && JSON.stringify(this.lastData.health) !== JSON.stringify(health)) {
this.lastData.health = health; this.lastData.health = health;
localStorage.setItem("llm_monitor_health", JSON.stringify(health));
this.renderHealth(health); this.renderHealth(health);
} }
if (modelsData && JSON.stringify(this.lastData.models) !== JSON.stringify(modelsData)) { if (modelsData && JSON.stringify(this.lastData.models) !== JSON.stringify(modelsData)) {
this.lastData.models = modelsData; this.lastData.models = modelsData;
localStorage.setItem("llm_monitor_models", JSON.stringify(modelsData));
this.renderModels(modelsData); this.renderModels(modelsData);
if (this.selectedModelName) {
this.showModelDetails(this.selectedModelName);
}
} }
} }
} }
@@ -140,10 +155,11 @@ class LLMMonitorApp {
// Renderizzare singola card modello // Renderizzare singola card modello
renderModelCard(model) { renderModelCard(model) {
const formattedDate = this.formatDate(model.modified_at); const formattedDate = this.formatDate(model.modified_at);
const modelName = this.escapeHtml(model.name);
return ` return `
<div class="bg-gray-700 rounded-lg p-4 border border-gray-600 hover:border-purple-500 transition"> <div data-model-name="${modelName}" class="bg-gray-700 rounded-lg p-4 border border-gray-600 hover:border-purple-500 transition cursor-pointer">
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">
<h3 class="text-lg font-semibold">${this.escapeHtml(model.name)}</h3> <h3 class="text-lg font-semibold">${modelName}</h3>
<span class="bg-purple-600 px-3 py-1 rounded text-xs font-medium">Caricato</span> <span class="bg-purple-600 px-3 py-1 rounded text-xs font-medium">Caricato</span>
</div> </div>
<div class="grid grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-2 gap-4 text-sm">
@@ -160,10 +176,35 @@ class LLMMonitorApp {
<p class="text-gray-400 text-xs">Digest</p> <p class="text-gray-400 text-xs">Digest</p>
<p class="font-mono text-xs bg-gray-800 p-2 rounded mt-1 break-all">${this.escapeHtml(model.digest.substring(0, 64))}...</p> <p class="font-mono text-xs bg-gray-800 p-2 rounded mt-1 break-all">${this.escapeHtml(model.digest.substring(0, 64))}...</p>
</div> </div>
<p class="text-xs text-purple-300 mt-3">Clicca per vedere dettagli show</p>
</div> </div>
`; `;
} }
showModelDetails(modelName) {
const detailsSection = document.getElementById("model-details-section");
const detailsName = document.getElementById("model-details-name");
const detailsContent = document.getElementById("model-details-content");
if (!detailsSection || !detailsName || !detailsContent || !this.lastData.models) {
return;
}
const showByModel = this.lastData.models.showByModel || {};
const showData = showByModel[modelName];
this.selectedModelName = modelName;
detailsSection.classList.remove("hidden");
detailsName.textContent = modelName;
if (!showData) {
detailsContent.textContent = "Dettagli show non disponibili per questo modello.";
return;
}
detailsContent.textContent = JSON.stringify(showData, null, 2);
}
// Formattare bytes // Formattare bytes
formatBytes(bytes) { formatBytes(bytes) {
if (bytes === 0) return "0 B"; if (bytes === 0) return "0 B";
@@ -220,15 +261,30 @@ class LLMMonitorApp {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
const models = data.models || []; const models = data.models || [];
const showByModel = {};
await Promise.allSettled(
models.map(async (model) => {
const showResponse = await fetch(`/api/v1/models/${encodeURIComponent(model.name)}/show`);
if (showResponse.ok) {
showByModel[model.name] = await showResponse.json();
}
})
);
const modelsData = { const modelsData = {
models, models,
total: models.length, total: models.length,
totalSize: this.formatBytes(models.reduce((sum, m) => sum + m.size, 0)), totalSize: this.formatBytes(models.reduce((sum, m) => sum + m.size, 0)),
showByModel,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; };
this.lastData.models = modelsData; this.lastData.models = modelsData;
localStorage.setItem("llm_monitor_models", JSON.stringify(modelsData)); localStorage.setItem("llm_monitor_models", JSON.stringify(modelsData));
this.renderModels(modelsData); this.renderModels(modelsData);
if (this.selectedModelName) {
this.showModelDetails(this.selectedModelName);
}
} }
} catch (error) { } catch (error) {
console.error("Models loading error:", error); console.error("Models loading error:", error);
+39
View File
@@ -54,11 +54,50 @@ async function fetchModels() {
} }
} }
// Recuperare dettagli show per un modello
async function fetchModelShow(modelName) {
try {
const response = await fetch(`${API_BASE}/models/${encodeURIComponent(modelName)}/show`);
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error(`Error loading show data for model ${modelName}:`, error);
return null;
}
}
// Recuperare dettagli show per tutti i modelli
async function fetchAllModelsShow(models) {
const showByModel = {};
const results = await Promise.allSettled(
models.map(async (model) => {
const showData = await fetchModelShow(model.name);
return { name: model.name, showData };
})
);
results.forEach((result) => {
if (result.status === "fulfilled" && result.value.showData) {
showByModel[result.value.name] = result.value.showData;
}
});
return showByModel;
}
// Sincronizzare i dati // Sincronizzare i dati
async function syncData() { async function syncData() {
const health = await fetchHealth(); const health = await fetchHealth();
const modelsData = await fetchModels(); const modelsData = await fetchModels();
if (modelsData && modelsData.models.length > 0) {
modelsData.showByModel = await fetchAllModelsShow(modelsData.models);
} else if (modelsData) {
modelsData.showByModel = {};
}
// Notificare il main thread // Notificare il main thread
// (il main thread gestisce localStorage) // (il main thread gestisce localStorage)
self.postMessage({ self.postMessage({
+9
View File
@@ -77,6 +77,15 @@
</div> </div>
</div> </div>
<!-- Model Show Details -->
<div id="model-details-section" class="mt-8 bg-gray-800 rounded-lg border border-gray-700 p-6 hidden">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold">Dettagli Modello</h3>
<span id="model-details-name" class="text-sm text-purple-300 font-medium"></span>
</div>
<pre id="model-details-content" class="bg-gray-900 text-gray-200 text-xs p-4 rounded-lg overflow-auto max-h-96 whitespace-pre-wrap"></pre>
</div>
<!-- API Documentation Section --> <!-- API Documentation Section -->
<div class="mt-8 bg-blue-900 bg-opacity-20 border border-blue-700 rounded-lg p-6"> <div class="mt-8 bg-blue-900 bg-opacity-20 border border-blue-700 rounded-lg p-6">
<h3 class="text-lg font-bold mb-4">📚 Documentazione API</h3> <h3 class="text-lg font-bold mb-4">📚 Documentazione API</h3>
+35
View File
@@ -78,6 +78,40 @@ def test_get_nonexistent_model(client, mock_models_response):
response = client.get("/api/v1/models/nonexistent") response = client.get("/api/v1/models/nonexistent")
assert response.status_code == 404 assert response.status_code == 404
def test_get_model_show(client):
"""Test show endpoint for model details."""
with patch("requests.post") as mock_post:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"details": {
"family": "llama",
"parameter_size": "8B"
},
"model_info": {
"general.architecture": "llama"
}
}
mock_post.return_value = mock_response
response = client.get("/api/v1/models/llama2/show")
assert response.status_code == 200
data = response.json()
assert "details" in data
assert data["details"]["family"] == "llama"
def test_get_model_show_not_found(client):
"""Test show endpoint when model is not found."""
with patch("requests.post") as mock_post:
mock_response = MagicMock()
mock_response.status_code = 404
mock_post.return_value = mock_response
response = client.get("/api/v1/models/nonexistent/show")
assert response.status_code == 404
def test_root_endpoint(client): def test_root_endpoint(client):
"""Test root endpoint redirects to dashboard""" """Test root endpoint redirects to dashboard"""
response = client.get("/", follow_redirects=False) response = client.get("/", follow_redirects=False)
@@ -92,6 +126,7 @@ def test_openapi_schema(client):
assert "paths" in schema assert "paths" in schema
assert "/api/v1/health" in schema["paths"] assert "/api/v1/health" in schema["paths"]
assert "/api/v1/models" in schema["paths"] assert "/api/v1/models" in schema["paths"]
assert "/api/v1/models/{model_name}/show" in schema["paths"]
assert "/api/v1/models/{model_name}/pull" not in schema["paths"] assert "/api/v1/models/{model_name}/pull" not in schema["paths"]