From 57663400ce66b9e5ad30e5d231da40d07dc508d5 Mon Sep 17 00:00:00 2001 From: Luca Sacchi Ricciardi Date: Fri, 24 Apr 2026 19:41:46 +0200 Subject: [PATCH] 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 --- PRD.md | 8 +++- README.md | 16 +++++++ app/api/models.py | 54 +++++++++++++++++++++++- app/web/static/js/app.js | 60 ++++++++++++++++++++++++++- app/web/static/js/data-sync.worker.js | 39 +++++++++++++++++ app/web/templates/index.html | 9 ++++ tests/test_api.py | 35 ++++++++++++++++ 7 files changed, 217 insertions(+), 4 deletions(-) diff --git a/PRD.md b/PRD.md index 81daa41..8616ece 100644 --- a/PRD.md +++ b/PRD.md @@ -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 diff --git a/README.md b/README.md index 0177569..d7500aa 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/api/models.py b/app/api/models.py index 9bf8250..5991017 100644 --- a/app/api/models.py +++ b/app/api/models.py @@ -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 diff --git a/app/web/static/js/app.js b/app/web/static/js/app.js index 304a68b..3c19fd9 100644 --- a/app/web/static/js/app.js +++ b/app/web/static/js/app.js @@ -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 ` -
+
-

${this.escapeHtml(model.name)}

+

${modelName}

Caricato
@@ -160,10 +176,35 @@ class LLMMonitorApp {

Digest

${this.escapeHtml(model.digest.substring(0, 64))}...

+

Clicca per vedere dettagli show

`; } + 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); diff --git a/app/web/static/js/data-sync.worker.js b/app/web/static/js/data-sync.worker.js index 4c1f680..7aaa4bb 100644 --- a/app/web/static/js/data-sync.worker.js +++ b/app/web/static/js/data-sync.worker.js @@ -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) diff --git a/app/web/templates/index.html b/app/web/templates/index.html index ffd636e..7eb07bc 100644 --- a/app/web/templates/index.html +++ b/app/web/templates/index.html @@ -77,6 +77,15 @@
+ + +

๐Ÿ“š Documentazione API

diff --git a/tests/test_api.py b/tests/test_api.py index 47e6f9f..8e1e413 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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"]