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
|
- 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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user