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:
@@ -131,12 +131,15 @@ Attualmente, per verificare i modelli LLM in Ollama, è necessario:
|
||||
- Data ultimo aggiornamento
|
||||
- Digest (hash univoco)
|
||||
- Pulsante refresh manuale
|
||||
- Pannello dettagli modello su click card
|
||||
|
||||
**Behavior:**
|
||||
- Auto-refresh ogni 30 secondi
|
||||
- Aggiorna solo elementi cambiati (no full re-render)
|
||||
- Mostra loading state durante fetch
|
||||
- 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`
|
||||
Scarica/carica un modello (**disabilitato di default**)
|
||||
|
||||
@@ -221,7 +227,7 @@ Elimina un modello (**disabilitato di default**)
|
||||
|
||||
**Dati Salvati:**
|
||||
- `llm_monitor_health` - Status health
|
||||
- `llm_monitor_models` - Elenco modelli
|
||||
- `llm_monitor_models` - Elenco modelli + mappa dettagli `showByModel`
|
||||
|
||||
**Benefit:**
|
||||
- Offline support
|
||||
|
||||
@@ -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
|
||||
- 📊 **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
|
||||
- 🎨 **UI moderna** - Interfaccia elegante realizzata con TailwindCSS
|
||||
- 🐳 **Docker ready** - Container sempre acceso (until stopped)
|
||||
@@ -147,6 +148,12 @@ GET /api/v1/models
|
||||
GET /api/v1/models/{model_name}
|
||||
```
|
||||
|
||||
#### Dettagli estesi da Ollama show
|
||||
|
||||
```bash
|
||||
GET /api/v1/models/{model_name}/show
|
||||
```
|
||||
|
||||
#### Health check API Ollama
|
||||
|
||||
```bash
|
||||
@@ -187,10 +194,19 @@ curl http://localhost:8000/api/v1/models
|
||||
# Ottenere info su un modello
|
||||
curl http://localhost:8000/api/v1/models/llama2
|
||||
|
||||
# Ottenere dettagli estesi show
|
||||
curl http://localhost:8000/api/v1/models/llama2/show
|
||||
|
||||
# Health check
|
||||
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
|
||||
|
||||
### Build dell'immagine
|
||||
|
||||
+53
-1
@@ -4,7 +4,7 @@ Models endpoints - Gestione dei modelli Ollama
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
import requests
|
||||
import logging
|
||||
@@ -174,6 +174,58 @@ async def get_model(model_name: str):
|
||||
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(
|
||||
"/models/{model_name}/pull",
|
||||
include_in_schema=settings.ENABLE_MODEL_RW_API
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
class LLMMonitorApp {
|
||||
constructor() {
|
||||
this.worker = null;
|
||||
this.selectedModelName = null;
|
||||
this.lastData = {
|
||||
health: null,
|
||||
models: null
|
||||
@@ -35,6 +36,15 @@ class LLMMonitorApp {
|
||||
document.getElementById("refresh-btn")?.addEventListener("click", () => {
|
||||
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
|
||||
@@ -68,12 +78,17 @@ class LLMMonitorApp {
|
||||
if (type === "DATA_UPDATED") {
|
||||
if (health && JSON.stringify(this.lastData.health) !== JSON.stringify(health)) {
|
||||
this.lastData.health = health;
|
||||
localStorage.setItem("llm_monitor_health", JSON.stringify(health));
|
||||
this.renderHealth(health);
|
||||
}
|
||||
|
||||
if (modelsData && JSON.stringify(this.lastData.models) !== JSON.stringify(modelsData)) {
|
||||
this.lastData.models = modelsData;
|
||||
localStorage.setItem("llm_monitor_models", JSON.stringify(modelsData));
|
||||
this.renderModels(modelsData);
|
||||
if (this.selectedModelName) {
|
||||
this.showModelDetails(this.selectedModelName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,10 +155,11 @@ class LLMMonitorApp {
|
||||
// Renderizzare singola card modello
|
||||
renderModelCard(model) {
|
||||
const formattedDate = this.formatDate(model.modified_at);
|
||||
const modelName = this.escapeHtml(model.name);
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
<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="font-mono text-xs bg-gray-800 p-2 rounded mt-1 break-all">${this.escapeHtml(model.digest.substring(0, 64))}...</p>
|
||||
</div>
|
||||
<p class="text-xs text-purple-300 mt-3">Clicca per vedere dettagli show</p>
|
||||
</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
|
||||
formatBytes(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
@@ -220,15 +261,30 @@ class LLMMonitorApp {
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
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 = {
|
||||
models,
|
||||
total: models.length,
|
||||
totalSize: this.formatBytes(models.reduce((sum, m) => sum + m.size, 0)),
|
||||
showByModel,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
this.lastData.models = modelsData;
|
||||
localStorage.setItem("llm_monitor_models", JSON.stringify(modelsData));
|
||||
this.renderModels(modelsData);
|
||||
if (this.selectedModelName) {
|
||||
this.showModelDetails(this.selectedModelName);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Models loading error:", error);
|
||||
|
||||
@@ -54,10 +54,49 @@ 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
|
||||
async function syncData() {
|
||||
const health = await fetchHealth();
|
||||
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
|
||||
// (il main thread gestisce localStorage)
|
||||
|
||||
@@ -77,6 +77,15 @@
|
||||
</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 -->
|
||||
<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>
|
||||
|
||||
@@ -78,6 +78,40 @@ def test_get_nonexistent_model(client, mock_models_response):
|
||||
response = client.get("/api/v1/models/nonexistent")
|
||||
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):
|
||||
"""Test root endpoint redirects to dashboard"""
|
||||
response = client.get("/", follow_redirects=False)
|
||||
@@ -92,6 +126,7 @@ def test_openapi_schema(client):
|
||||
assert "paths" in schema
|
||||
assert "/api/v1/health" 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"]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user