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
+53 -1
View File
@@ -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
+58 -2
View File
@@ -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);
+39
View File
@@ -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)
+9
View File
@@ -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>