Add dedicated page for running Ollama models
This commit is contained in:
@@ -119,6 +119,54 @@ async def get_models():
|
|||||||
detail="Errore nel recupero dei modelli"
|
detail="Errore nel recupero dei modelli"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models/running")
|
||||||
|
async def get_running_models() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Recupera i modelli attualmente residenti in memoria, equivalenti a `ollama ps`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Payload con modelli running e conteggio
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{settings.OLLAMA_HOST}/api/ps",
|
||||||
|
timeout=settings.OLLAMA_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail="Ollama non disponibile"
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
models_data = data.get("models", [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"models": models_data,
|
||||||
|
"total": len(models_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 running models: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Errore nel recupero dei modelli residenti"
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/models/{model_name}", response_model=ModelInfo)
|
@router.get("/models/{model_name}", response_model=ModelInfo)
|
||||||
async def get_model(model_name: str):
|
async def get_model(model_name: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
class RunningModelsPage {
|
||||||
|
constructor() {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
document.getElementById("refresh-btn")?.addEventListener("click", () => {
|
||||||
|
this.loadRunningModels();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loadRunningModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRunningModels() {
|
||||||
|
const container = document.getElementById("running-models");
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="inline-block w-8 h-8 border-4 border-gray-600 border-t-purple-500 rounded-full animate-spin"></div>
|
||||||
|
<p class="text-gray-400 mt-4">Aggiornamento in corso...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/v1/models/running");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Errore nel caricamento dei modelli residenti");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const models = data.models || [];
|
||||||
|
|
||||||
|
this.renderStats(models);
|
||||||
|
this.renderRunningModels(models);
|
||||||
|
} catch (error) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-center py-8 text-red-400">
|
||||||
|
<p>Errore nel caricamento di ollama ps</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.renderStats([]);
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStats(models) {
|
||||||
|
const runningCountEl = document.getElementById("running-count");
|
||||||
|
const vramTotalEl = document.getElementById("vram-total");
|
||||||
|
const lastRefreshEl = document.getElementById("last-refresh");
|
||||||
|
|
||||||
|
const totalVram = models.reduce((sum, model) => sum + (model.size_vram || 0), 0);
|
||||||
|
|
||||||
|
if (runningCountEl) {
|
||||||
|
runningCountEl.textContent = String(models.length);
|
||||||
|
}
|
||||||
|
if (vramTotalEl) {
|
||||||
|
vramTotalEl.textContent = this.formatBytes(totalVram);
|
||||||
|
}
|
||||||
|
if (lastRefreshEl) {
|
||||||
|
lastRefreshEl.textContent = new Date().toLocaleString("it-IT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRunningModels(models) {
|
||||||
|
const container = document.getElementById("running-models");
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-center py-8 text-gray-400">
|
||||||
|
<p>Nessun modello residente in memoria al momento.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = models
|
||||||
|
.map((model) => this.renderModelCard(model))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
renderModelCard(model) {
|
||||||
|
const name = this.escapeHtml(model.name || "unknown");
|
||||||
|
const modelId = this.escapeHtml(model.model || "-");
|
||||||
|
const size = this.formatBytes(model.size || 0);
|
||||||
|
const sizeVram = this.formatBytes(model.size_vram || 0);
|
||||||
|
const processor = this.escapeHtml(model.details?.processor || "-");
|
||||||
|
const expiresAt = model.expires_at ? this.formatDateTime(model.expires_at) : "-";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="bg-gray-700 rounded-lg p-4 border border-gray-600">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">${name}</h3>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">${modelId}</p>
|
||||||
|
</div>
|
||||||
|
<span class="bg-green-700 text-green-100 text-xs px-2 py-1 rounded">Pronto</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-4 text-sm">
|
||||||
|
<div class="bg-gray-800 rounded p-3">
|
||||||
|
<p class="text-gray-400 text-xs">Dimensione modello</p>
|
||||||
|
<p class="font-semibold mt-1">${size}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-800 rounded p-3">
|
||||||
|
<p class="text-gray-400 text-xs">VRAM usata</p>
|
||||||
|
<p class="font-semibold mt-1">${sizeVram}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-800 rounded p-3">
|
||||||
|
<p class="text-gray-400 text-xs">Processor</p>
|
||||||
|
<p class="font-semibold mt-1">${processor}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-800 rounded p-3">
|
||||||
|
<p class="text-gray-400 text-xs">Scarico previsto</p>
|
||||||
|
<p class="font-semibold mt-1">${expiresAt}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatBytes(bytes) {
|
||||||
|
if (!bytes || bytes <= 0) {
|
||||||
|
return "0 B";
|
||||||
|
}
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||||
|
const value = bytes / Math.pow(1024, index);
|
||||||
|
return `${value.toFixed(2)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDateTime(isoDate) {
|
||||||
|
const date = new Date(isoDate);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString("it-IT", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = String(text);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
window.runningModelsPage = new RunningModelsPage();
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
<h1 class="text-2xl font-bold">LLM Monitor</h1>
|
<h1 class="text-2xl font-bold">LLM Monitor</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="/models-running" class="text-sm bg-gray-700 hover:bg-gray-600 px-3 py-2 rounded-lg transition">Modelli in Memoria</a>
|
||||||
<div id="health-status" class="flex items-center gap-2">
|
<div id="health-status" class="flex items-center gap-2">
|
||||||
<div id="status-indicator" class="w-3 h-3 bg-gray-500 rounded-full"></div>
|
<div id="status-indicator" class="w-3 h-3 bg-gray-500 rounded-full"></div>
|
||||||
<span id="status-text" class="text-sm text-gray-400">Controllo...</span>
|
<span id="status-text" class="text-sm text-gray-400">Controllo...</span>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LLM Monitor - Modelli in Memoria</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
|
<link rel="stylesheet" href="/static/css/output.css">
|
||||||
|
<style>
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-900 text-white">
|
||||||
|
<div class="min-h-screen flex flex-col">
|
||||||
|
<header class="bg-gray-800 border-b border-gray-700 sticky top-0 z-50">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 py-6">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center font-bold text-lg">
|
||||||
|
🧠
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Modelli in Memoria</h1>
|
||||||
|
<p class="text-xs text-gray-400">Vista dedicata all'output di ollama ps</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/dashboard" class="text-sm bg-gray-700 hover:bg-gray-600 px-3 py-2 rounded-lg transition">Dashboard</a>
|
||||||
|
<button id="refresh-btn" class="text-sm bg-purple-600 hover:bg-purple-700 px-3 py-2 rounded-lg transition">Aggiorna</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<div class="text-gray-400 text-sm font-medium">Modelli Residenti</div>
|
||||||
|
<div id="running-count" class="text-4xl font-bold mt-2">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<div class="text-gray-400 text-sm font-medium">VRAM Totale Stimata</div>
|
||||||
|
<div id="vram-total" class="text-4xl font-bold mt-2">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<div class="text-gray-400 text-sm font-medium">Ultimo Refresh</div>
|
||||||
|
<div id="last-refresh" class="text-base font-semibold mt-3">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Output Ollama PS</h2>
|
||||||
|
<div id="running-models" class="space-y-3">
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="inline-block w-8 h-8 border-4 border-gray-600 border-t-purple-500 rounded-full animate-spin"></div>
|
||||||
|
<p class="text-gray-400 mt-4">Caricamento modelli residenti...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="bg-gray-800 border-t border-gray-700 mt-12">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 py-6 text-center text-gray-400 text-sm">
|
||||||
|
<p>LLM Monitor v1.0.0 • Modelli residenti in memoria (ollama ps)</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/models-running.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -62,6 +62,12 @@ async def dashboard():
|
|||||||
return FileResponse(templates_path / "index.html")
|
return FileResponse(templates_path / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/models-running")
|
||||||
|
async def models_running_page():
|
||||||
|
"""Pagina dedicata ai modelli residenti in memoria (ollama ps)."""
|
||||||
|
return FileResponse(templates_path / "models_running.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/favicon.ico", include_in_schema=False)
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
async def favicon():
|
async def favicon():
|
||||||
"""Favicon dell'applicazione."""
|
"""Favicon dell'applicazione."""
|
||||||
|
|||||||
@@ -46,6 +46,39 @@ def test_get_models(client, mock_models_response):
|
|||||||
assert len(data["models"]) == 2
|
assert len(data["models"]) == 2
|
||||||
assert data["models"][0]["name"] == "llama2"
|
assert data["models"][0]["name"] == "llama2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_running_models(client):
|
||||||
|
"""Test getting running models (ollama ps)."""
|
||||||
|
with patch("requests.get") as mock_get:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"name": "llama3.2:3b",
|
||||||
|
"size_vram": 2147483648,
|
||||||
|
"expires_at": "2026-04-24T10:30:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
response = client.get("/api/v1/models/running")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "models" in data
|
||||||
|
assert data["total"] == 1
|
||||||
|
assert data["models"][0]["name"] == "llama3.2:3b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_running_models_ollama_offline(client):
|
||||||
|
"""Test running models when Ollama is offline."""
|
||||||
|
with patch("requests.get") as mock_get:
|
||||||
|
mock_get.side_effect = Exception("Connection refused")
|
||||||
|
|
||||||
|
response = client.get("/api/v1/models/running")
|
||||||
|
assert response.status_code == 500
|
||||||
|
|
||||||
def test_get_models_ollama_offline(client):
|
def test_get_models_ollama_offline(client):
|
||||||
"""Test getting models when Ollama is offline"""
|
"""Test getting models when Ollama is offline"""
|
||||||
with patch("requests.get") as mock_get:
|
with patch("requests.get") as mock_get:
|
||||||
@@ -126,6 +159,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/running" in schema["paths"]
|
||||||
assert "/api/v1/models/{model_name}/show" 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