docs: add comprehensive README and project scaffolding

- README completo con istruzioni di installazione, configurazione e utilizzo
- API Swagger/OpenAPI documentata
- File env.example con variabili di configurazione
- Dockerfile multi-stage ottimizzato
- Docker Compose con Ollama e LLM Monitor
- Struttura completa dell'app FastAPI (main.py, config, api routes)
- Servizio client Ollama reusabile
- Dashboard web HTML con TailwindCSS
- Test suite con pytest
- Makefile per comandi comuni
- CONTRIBUTING.md per i contributori
- LICENSE MIT
- .editorconfig e .dockerignore
- requirements.txt e requirements-dev.txt
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-24 19:11:58 +02:00
commit 4b782ffdc8
28 changed files with 2087 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
*.egg
.pytest_cache/
.mypy_cache/
.coverage
.venv/
venv/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
.gitignore
Thumbs.db
# Git
.git/
.gitignore
# Documentation
docs/
*.md
LICENSE
CONTRIBUTING.md
# Development
node_modules/
package-lock.json
Makefile
.env*
# Test
tests/
pytest.ini
+35
View File
@@ -0,0 +1,35 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
# Python files
[*.py]
indent_style = space
indent_size = 4
max_line_length = 100
# JSON files
[*.json]
indent_style = space
indent_size = 2
# YAML files
[*.{yml,yaml}]
indent_style = space
indent_size = 2
# Markdown files
[*.md]
trim_trailing_whitespace = false
# Dockerfile
[Dockerfile*]
indent_style = space
indent_size = 4
+15
View File
@@ -0,0 +1,15 @@
# LLM Monitor - Local Development Environment
# Copia questo file da env.example e personalizza per il tuo ambiente
OLLAMA_HOST=http://localhost:11434
OLLAMA_TIMEOUT=30
API_HOST=0.0.0.0
API_PORT=8000
API_WORKERS=1
CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8000
LOG_LEVEL=DEBUG
ENVIRONMENT=development
+145
View File
@@ -0,0 +1,145 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
Pipfile.lock
# PEP 582
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Node modules (per TailwindCSS)
node_modules/
package-lock.json
# Build outputs
app/web/static/css/output.css
# Database
*.db
*.sqlite
*.sqlite3
# Uploads
uploads/
+123
View File
@@ -0,0 +1,123 @@
# Contribuire a LLM Monitor
Grazie per l'interesse nel contribuire a LLM Monitor! Questo documento fornisce linee guida per contribuire al progetto.
## Codice di Condotta
Questo progetto aderisce a un Codice di Condotta per garantire un ambiente inclusivo e rispettoso.
## Come Contribuire
### Segnalare Bug
- **Verificare prima** se il bug non è già stato segnalato
- **Includere dettagli**: sistema operativo, versione Python, stack trace
- **Fornire un esempio ripetibile** se possibile
### Suggerire Miglioramenti
- **Verificare prima** se il suggerimento non è già stato fatto
- **Spiegare chiaramente** il caso d'uso e i benefici
- **Fornire esempi** di come dovrebbe funzionare
### Pull Requests
1. **Fork il repository**
2. **Crea un branch**: `git checkout -b feature/my-feature`
3. **Installa le dipendenze di sviluppo**:
```bash
pip install -r requirements-dev.txt
```
4. **Effettua i tuoi cambiamenti** seguendo lo [Style Guide](#style-guide)
5. **Scrivi i test**: I test sono obbligatori per nuove funzionalità
6. **Esegui i test**: `make test`
7. **Formatta il codice**: `make format`
8. **Esegui il linting**: `make lint`
9. **Fai il commit**: `git commit -m "feat: descrizione della feature"`
10. **Push**: `git push origin feature/my-feature`
11. **Apri una PR** descrivendo i cambiamenti
## Style Guide
### Python
- Usa **Black** per la formattazione: `make format`
- Usa **isort** per l'organizzazione degli import
- Segui **PEP 8**
- Usa type hints per le funzioni nuove
- Documenta con docstring (formato Google):
```python
def my_function(param1: str, param2: int) -> bool:
"""
Descrizione breve della funzione.
Args:
param1: Descrizione del primo parametro
param2: Descrizione del secondo parametro
Returns:
Descrizione del valore ritornato
Raises:
ValueError: Quando succede
"""
pass
```
### Commit Messages
Usa il formato Conventional Commits:
- `feat: aggiungi nuova feature`
- `fix: correggi un bug`
- `docs: aggiorna documentazione`
- `style: formattazione, without semantic change`
- `refactor: ristruttura codice`
- `perf: migliora le performance`
- `test: aggiungi o modifica test`
- `chore: aggiorna dipendenze, etc`
Esempio:
```
feat: aggiungi endpoint per ottenere statistiche modelli
- Nuovo endpoint GET /api/v1/models/stats
- Ritorna conteggio, spazio totale e ultimi aggiornamenti
- Include test di integrazione
```
### Codice
- Mantieni le funzioni piccole e ben definite
- Usa nomi descrittivi
- Aggiungi commenti per la logica complessa
- Evita magic numbers, usa costanti
## Testing
- Tutti i PR devono includere test per nuove funzionalità
- La copertura del codice deve rimanere ≥ 80%
- Esegui i test prima di submitare:
```bash
make test
```
## Documentazione
- Aggiorna il README se cambi il comportamento
- Aggiungi docstring a nuove funzioni
- Aggiorna il CHANGELOG.md
## Processo di Review
- I PR saranno reviewati il prima possibile
- I feedback saranno forniti in buona fede
- Le discussioni devono essere costruttive
## Licenza
Contribuendo, accetti che i tuoi contributi siano licensiati sotto la MIT License.
---
Domande? Apri un issue o contatta il maintainer!
+52
View File
@@ -0,0 +1,52 @@
# Multi-stage build per LLM Monitor
# Stage 1: Builder
FROM python:3.11-slim as builder
WORKDIR /app
# Installare dipendenze di build
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copiare requirements
COPY requirements.txt .
# Installare Python packages in un virtualenv
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
pip install --no-cache-dir -r requirements.txt
# Stage 2: Runtime
FROM python:3.11-slim
WORKDIR /app
# Installare dipendenze di runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copiare il virtualenv dal builder
COPY --from=builder /opt/venv /opt/venv
# Copiare codice dell'app
COPY app/ /app/app/
COPY main.py /app/
COPY .env* /app/
# Impostare PATH
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
# Esporre la porta
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/api/v1/health || exit 1
# Comando di avvio
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2026 Luca Sacchi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+54
View File
@@ -0,0 +1,54 @@
.PHONY: help install dev prod test lint format clean docker-build docker-up docker-down
help:
@echo "LLM Monitor - Makefile Commands"
@echo "================================"
@echo "make install - Installa le dipendenze"
@echo "make dev - Avvia in modalità sviluppo"
@echo "make prod - Avvia in modalità produzione"
@echo "make test - Esegui i test"
@echo "make lint - Linting e type checking"
@echo "make format - Formatta il codice"
@echo "make clean - Pulisce cache e file temporanei"
@echo "make docker-build - Build dell'immagine Docker"
@echo "make docker-up - Avvia i container con Docker Compose"
@echo "make docker-down - Ferma i container con Docker Compose"
install:
python3 -m venv venv
. venv/bin/activate && pip install -r requirements.txt -r requirements-dev.txt
dev:
. venv/bin/activate && uvicorn main:app --reload --host 0.0.0.0 --port 8000
prod:
. venv/bin/activate && uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
test:
. venv/bin/activate && pytest tests/ -v --cov=app
lint:
. venv/bin/activate && flake8 app/ tests/ main.py && mypy app/
format:
. venv/bin/activate && black app/ tests/ main.py && isort app/ tests/ main.py
clean:
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete
rm -rf .pytest_cache .mypy_cache .coverage htmlcov dist build *.egg-info
docker-build:
docker build -t llm-monitor:latest .
docker-up:
docker compose up -d
docker-down:
docker compose down
docker-logs:
docker compose logs -f llm-monitor
docker-shell:
docker compose exec llm-monitor /bin/bash
+381
View File
@@ -0,0 +1,381 @@
# LLM Monitor - Dashboard Ollama
Una dashboard web moderna e intuitiva per monitorare e gestire i modelli LLM caricati in **Ollama**. Visualizza i modelli disponibili, i dettagli dei caricamenti e accedi ai dati via API Ollama direttamente da una web app elegante.
## 🎯 Caratteristiche
-**Dashboard intuitiva** - Visualizza in tempo reale i modelli caricati in Ollama
- 📊 **Monitoraggio modelli** - Dettagli completi di ogni modello (nome, dimensione, memoria, stato)
- 🔌 **API REST documentata** - Documentazione interattiva con Swagger/OpenAPI
- 🎨 **UI moderna** - Interfaccia elegante realizzata con TailwindCSS
- 🐳 **Docker ready** - Container sempre acceso (until stopped)
-**Performance** - Costruito su FastAPI e uVicorn
- 🔐 **Configurazione flessibile** - File `.env` per personalizzazione
## 📋 Requisiti
- **Python** 3.10+
- **Ollama** installato e in esecuzione
- **Docker** (opzionale, per containerizzazione)
- **Docker Compose** (opzionale)
## 🚀 Installazione Rapida
### 1. Clonare il repository
```bash
git clone https://github.com/LucaSacchiNet/llm-monitor.git
cd llm-monitor
```
### 2. Configurare l'ambiente
Copia il file di esempio:
```bash
cp env.example .env
```
Modifica `.env` con i tuoi parametri (vedi [Configurazione](#configurazione)):
```bash
nano .env
```
### 3. Installare le dipendenze
#### Opzione A: Ambiente virtuale (Sviluppo)
```bash
python3 -m venv venv
source venv/bin/activate # Su Windows: venv\Scripts\activate
pip install -r requirements.txt
```
#### Opzione B: Docker (Produzione)
```bash
docker compose up -d
```
### 4. Avviare l'applicazione
#### Modalità sviluppo
```bash
python3 -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
#### Modalità produzione
```bash
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
```
## ⚙️ Configurazione
Crea un file `.env` nella root del progetto (copia da `env.example`):
```env
# Ollama Configuration
OLLAMA_HOST=http://localhost:11434
OLLAMA_TIMEOUT=30
# API Configuration
API_HOST=0.0.0.0
API_PORT=8000
API_WORKERS=4
# CORS Configuration
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
# Logging
LOG_LEVEL=INFO
# Environment
ENVIRONMENT=development
```
### Variabili disponibili
| Variabile | Default | Descrizione |
|-----------|---------|-------------|
| `OLLAMA_HOST` | `http://localhost:11434` | URL della API Ollama |
| `OLLAMA_TIMEOUT` | `30` | Timeout (secondi) per le richieste |
| `API_HOST` | `0.0.0.0` | Host su cui esporre l'API |
| `API_PORT` | `8000` | Porta dell'API |
| `API_WORKERS` | `4` | Worker processes |
| `CORS_ORIGINS` | `http://localhost:3000` | Origini CORS consentite |
| `LOG_LEVEL` | `INFO` | Livello di logging |
| `ENVIRONMENT` | `development` | Ambiente (development/production) |
## 📚 API Swagger
La documentazione interattiva dell'API è disponibile automaticamente:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
### Endpoint principali
#### Recuperare modelli caricati
```bash
GET /api/v1/models
```
**Risposta:**
```json
{
"models": [
{
"name": "llama2",
"digest": "abc123...",
"size": 3825922048,
"modified_at": "2024-01-15T10:30:00Z"
}
]
}
```
#### Dettagli di un modello specifico
```bash
GET /api/v1/models/{model_name}
```
#### Health check API Ollama
```bash
GET /api/v1/health
```
**Risposta:**
```json
{
"status": "healthy",
"ollama_version": "0.1.0",
"uptime_seconds": 3600
}
```
### Test API con cURL
```bash
# Ottenere i modelli
curl http://localhost:8000/api/v1/models
# Ottenere info su un modello
curl http://localhost:8000/api/v1/models/llama2
# Health check
curl http://localhost:8000/api/v1/health
```
## 🐳 Docker
### Build dell'immagine
```bash
docker build -t llm-monitor:latest .
```
### Eseguire il container
```bash
docker run -d \
--name llm-monitor \
-p 8000:8000 \
--env-file .env \
--network host \
llm-monitor:latest
```
> ⚠️ **Nota**: `--network host` consente al container di accedere a Ollama su localhost
### Docker Compose
Usa il file `docker-compose.yml` fornito:
```bash
# Avviare i servizi
docker compose up -d
# Visualizzare i log
docker compose logs -f llm-monitor
# Fermare i servizi
docker compose down
# Riavviare
docker compose restart llm-monitor
```
### Container sempre acceso
Il container Ollama rimarrà in esecuzione fino al suo arresto manuale:
```bash
# Fermare
docker compose stop ollama
# oppure
docker stop llm-monitor
# Riavviare
docker compose start ollama
# oppure
docker start llm-monitor
```
## 📁 Struttura del Progetto
```
llm-monitor/
├── main.py # Entry point dell'applicazione
├── requirements.txt # Dipendenze Python
├── env.example # Esempio di configurazione
├── Dockerfile # Configurazione Docker
├── docker-compose.yml # Composizione servizi
├── README.md # Questo file
├── .gitignore
├── app/
│ ├── __init__.py
│ ├── config.py # Configurazione (variabili ambiente)
│ ├── main.py # Inizializzazione FastAPI
│ │
│ ├── api/
│ │ ├── __init__.py
│ │ ├── models.py # Endpoint modelli
│ │ ├── health.py # Endpoint health
│ │ └── v1/
│ │ └── __init__.py
│ │
│ ├── services/
│ │ ├── __init__.py
│ │ ├── ollama.py # Client Ollama
│ │ └── cache.py # Cache in-memory (opzionale)
│ │
│ └── web/
│ ├── __init__.py
│ ├── static/ # Assets statici (CSS compilato TailwindCSS)
│ └── templates/ # Template HTML
└── tests/
├── __init__.py
├── test_api.py
└── test_ollama.py
```
## 🛠️ Sviluppo
### Setup locale
```bash
# Clonare il repo
git clone https://github.com/LucaSacchiNet/llm-monitor.git
cd llm-monitor
# Ambiente virtuale
python3 -m venv venv
source venv/bin/activate
# Installare dipendenze + dev
pip install -r requirements.txt
pip install -r requirements-dev.txt # black, pytest, flake8, etc.
```
### Comandi utili
```bash
# Formattare codice
black app/ tests/ main.py
# Linting
flake8 app/ tests/ main.py
# Test
pytest tests/ -v
# Test con coverage
pytest tests/ --cov=app
# Hot reload durante sviluppo
uvicorn main:app --reload
```
### Compilare TailwindCSS
```bash
# Installare dipendenze Node (opzionale)
npm install
# Generare CSS in modalità développement
npm run tailwind:dev
# Build per produzione
npm run tailwind:build
```
## 🐛 Troubleshooting
### Errore: "Cannot connect to Ollama"
- Verificare che Ollama sia in esecuzione: `curl http://localhost:11434/api/tags`
- Controllare che l'indirizzo in `.env` sia corretto (`OLLAMA_HOST`)
- Se usi Docker, assicurati che il container abbia accesso a Ollama (vedi [Docker](#docker))
### Errore: "Port 8000 already in use"
```bash
# Cambiare la porta in .env
API_PORT=8001
# Oppure liberare la porta
lsof -ti :8000 | xargs kill -9
```
### Dashboard lenta
- Verificare lo stato di Ollama
- Aumentare `OLLAMA_TIMEOUT` in `.env`
- Controllare i log: `docker compose logs -f llm-monitor`
## 📄 Dipendenze Principali
- **FastAPI** - Framework web moderno
- **uVicorn** - Server ASGI ad alte prestazioni
- **Pydantic** - Validazione dati
- **Requests** - Client HTTP
- **Jinja2** - Template HTML
- **TailwindCSS** - Utility-first CSS
## 📜 Licenza
Questo progetto è distribuito sotto licenza **MIT**. Vedi il file `LICENSE` per dettagli.
## 🤝 Contribuire
Le pull request sono benvenute! Per cambiamenti importanti, apri prima un issue per discutere i cambiamenti proposti.
### Processo di contribuzione
1. Fork il repository
2. Crea un branch (`git checkout -b feature/amazing-feature`)
3. Commit i cambiamenti (`git commit -m 'Add amazing feature'`)
4. Push al branch (`git push origin feature/amazing-feature`)
5. Apri una Pull Request
## 📞 Supporto
Per domande o segnalazioni di bug, apri un **Issue** nel repository.
---
**Fatto con ❤️ da [LucaSacchi.Net](https://lucasacchi.net)**
**Versione**: 1.0.0
**Ultima modifica**: Aprile 2026
**Status**: 🟢 In Development
+5
View File
@@ -0,0 +1,5 @@
"""
LLM Monitor - Package principale
"""
__version__ = "1.0.0"
+3
View File
@@ -0,0 +1,3 @@
"""
API routes
"""
+70
View File
@@ -0,0 +1,70 @@
"""
Health check endpoints
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from datetime import datetime
import requests
import logging
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
class HealthResponse(BaseModel):
status: str
ollama_status: str
timestamp: datetime
class Config:
json_schema_extra = {
"example": {
"status": "healthy",
"ollama_status": "online",
"timestamp": "2024-01-15T10:30:00Z"
}
}
@router.get("/health", response_model=HealthResponse)
async def health_check():
"""
Health check dell'API e dello stato di Ollama
Returns:
HealthResponse: Status dell'API e di Ollama
"""
try:
# Check Ollama
response = requests.get(
f"{settings.OLLAMA_HOST}/api/tags",
timeout=settings.OLLAMA_TIMEOUT
)
ollama_status = "online" if response.status_code == 200 else "offline"
except Exception as e:
logger.warning(f"Ollama health check failed: {e}")
ollama_status = "offline"
return HealthResponse(
status="healthy",
ollama_status=ollama_status,
timestamp=datetime.utcnow()
)
@router.get("/ready")
async def ready():
"""
Readiness probe per Kubernetes/Docker
"""
try:
response = requests.get(
f"{settings.OLLAMA_HOST}/api/tags",
timeout=5
)
if response.status_code == 200:
return {"status": "ready"}
else:
raise HTTPException(status_code=503, detail="Service unavailable")
except Exception as e:
logger.error(f"Readiness check failed: {e}")
raise HTTPException(status_code=503, detail="Service unavailable")
+232
View File
@@ -0,0 +1,232 @@
"""
Models endpoints - Gestione dei modelli Ollama
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
import requests
import logging
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
class ModelInfo(BaseModel):
"""Informazioni su un modello"""
name: str
digest: str
size: int
modified_at: datetime
class Config:
json_schema_extra = {
"example": {
"name": "llama2",
"digest": "abc123def456...",
"size": 3825922048,
"modified_at": "2024-01-15T10:30:00Z"
}
}
class ModelsResponse(BaseModel):
"""Risposta con lista di modelli"""
models: List[ModelInfo]
total: int
class Config:
json_schema_extra = {
"example": {
"models": [
{
"name": "llama2",
"digest": "abc123def456...",
"size": 3825922048,
"modified_at": "2024-01-15T10:30:00Z"
}
],
"total": 1
}
}
@router.get("/models", response_model=ModelsResponse)
async def get_models():
"""
Recupera l'elenco di tutti i modelli caricati in Ollama
Returns:
ModelsResponse: Lista dei modelli disponibili
Raises:
HTTPException: Se Ollama non è disponibile
"""
try:
response = requests.get(
f"{settings.OLLAMA_HOST}/api/tags",
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", [])
models = [
ModelInfo(
name=model.get("name", "unknown"),
digest=model.get("digest", ""),
size=model.get("size", 0),
modified_at=datetime.fromisoformat(
model.get("modified_at", "").replace("Z", "+00:00")
) if model.get("modified_at") else datetime.utcnow()
)
for model in models_data
]
return ModelsResponse(
models=models,
total=len(models)
)
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 Exception as e:
logger.error(f"Error fetching models: {e}")
raise HTTPException(
status_code=500,
detail="Errore nel recupero dei modelli"
)
@router.get("/models/{model_name}", response_model=ModelInfo)
async def get_model(model_name: str):
"""
Recupera le informazioni di un modello specifico
Args:
model_name: Nome del modello da cercare
Returns:
ModelInfo: Informazioni del modello
Raises:
HTTPException: Se il modello non esiste o Ollama non è disponibile
"""
try:
response = requests.get(
f"{settings.OLLAMA_HOST}/api/tags",
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", [])
# Cercare il modello
for model in models_data:
if model.get("name") == model_name:
return ModelInfo(
name=model.get("name", "unknown"),
digest=model.get("digest", ""),
size=model.get("size", 0),
modified_at=datetime.fromisoformat(
model.get("modified_at", "").replace("Z", "+00:00")
) if model.get("modified_at") else datetime.utcnow()
)
raise HTTPException(
status_code=404,
detail=f"Modello '{model_name}' non trovato"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching model: {e}")
raise HTTPException(
status_code=500,
detail="Errore nel recupero del modello"
)
@router.post("/models/{model_name}/pull")
async def pull_model(model_name: str):
"""
Scarica/carica un modello in Ollama
Args:
model_name: Nome del modello da scaricare
Returns:
dict: Status del download
"""
try:
response = requests.post(
f"{settings.OLLAMA_HOST}/api/pull",
json={"name": model_name},
timeout=None # Pull può essere lungo
)
if response.status_code not in [200, 201]:
raise HTTPException(
status_code=502,
detail="Errore nel pull del modello"
)
return {"status": "pulling", "model": model_name}
except Exception as e:
logger.error(f"Error pulling model: {e}")
raise HTTPException(
status_code=500,
detail="Errore nel pull del modello"
)
@router.delete("/models/{model_name}")
async def delete_model(model_name: str):
"""
Elimina un modello da Ollama
Args:
model_name: Nome del modello da eliminare
Returns:
dict: Confirmazione eliminazione
"""
try:
response = requests.delete(
f"{settings.OLLAMA_HOST}/api/delete",
json={"name": model_name},
timeout=settings.OLLAMA_TIMEOUT
)
if response.status_code not in [200, 204]:
raise HTTPException(
status_code=502,
detail="Errore nell'eliminazione del modello"
)
return {"status": "deleted", "model": model_name}
except Exception as e:
logger.error(f"Error deleting model: {e}")
raise HTTPException(
status_code=500,
detail="Errore nell'eliminazione del modello"
)
+34
View File
@@ -0,0 +1,34 @@
"""
Configurazione dell'applicazione tramite variabili di ambiente
"""
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
"""Configurazione dell'applicazione"""
# Ollama
OLLAMA_HOST: str = "http://localhost:11434"
OLLAMA_TIMEOUT: int = 30
# API
API_HOST: str = "0.0.0.0"
API_PORT: int = 8000
API_WORKERS: int = 4
# CORS
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
# Logging
LOG_LEVEL: str = "INFO"
# Environment
ENVIRONMENT: str = "development"
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
# Istanza globale della configurazione
settings = Settings()
+3
View File
@@ -0,0 +1,3 @@
"""
Services - Business logic
"""
+116
View File
@@ -0,0 +1,116 @@
"""
Ollama client service
"""
import requests
import logging
from typing import List, Dict, Optional
from app.config import settings
logger = logging.getLogger(__name__)
class OllamaClient:
"""Client per interagire con l'API Ollama"""
def __init__(self, host: str = None, timeout: int = None):
self.host = host or settings.OLLAMA_HOST
self.timeout = timeout or settings.OLLAMA_TIMEOUT
def get_models(self) -> List[Dict]:
"""
Recupera l'elenco dei modelli da Ollama
Returns:
List[Dict]: Lista dei modelli
"""
try:
response = requests.get(
f"{self.host}/api/tags",
timeout=self.timeout
)
response.raise_for_status()
return response.json().get("models", [])
except Exception as e:
logger.error(f"Error getting models from Ollama: {e}")
return []
def get_model(self, model_name: str) -> Optional[Dict]:
"""
Recupera informazioni su un modello specifico
Args:
model_name: Nome del modello
Returns:
Dict: Informazioni del modello, o None se non trovato
"""
try:
models = self.get_models()
for model in models:
if model.get("name") == model_name:
return model
return None
except Exception as e:
logger.error(f"Error getting model {model_name}: {e}")
return None
def is_available(self) -> bool:
"""
Verifica se Ollama è disponibile
Returns:
bool: True se disponibile, False altrimenti
"""
try:
response = requests.get(
f"{self.host}/api/tags",
timeout=5
)
return response.status_code == 200
except Exception:
return False
def pull_model(self, model_name: str) -> bool:
"""
Scarica/carica un modello
Args:
model_name: Nome del modello
Returns:
bool: True se ha successo
"""
try:
response = requests.post(
f"{self.host}/api/pull",
json={"name": model_name},
timeout=None
)
return response.status_code in [200, 201]
except Exception as e:
logger.error(f"Error pulling model {model_name}: {e}")
return False
def delete_model(self, model_name: str) -> bool:
"""
Elimina un modello
Args:
model_name: Nome del modello
Returns:
bool: True se ha successo
"""
try:
response = requests.delete(
f"{self.host}/api/delete",
json={"name": model_name},
timeout=self.timeout
)
return response.status_code in [200, 204]
except Exception as e:
logger.error(f"Error deleting model {model_name}: {e}")
return False
# Istanza globale del client Ollama
ollama_client = OllamaClient()
+3
View File
@@ -0,0 +1,3 @@
"""
Web templates and static files
"""
+224
View File
@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LLM Monitor - Dashboard Ollama</title>
<script src="https://cdn.tailwindcss.com"></script>
<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 -->
<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">
<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>
<h1 class="text-2xl font-bold">LLM Monitor</h1>
</div>
<div class="flex items-center gap-4">
<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>
<span id="status-text" class="text-sm text-gray-400">Controllo...</span>
</div>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="flex-1">
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Stats Cards -->
<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 Caricati</div>
<div id="models-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">Spazio Totale</div>
<div id="total-size" 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">Status Ollama</div>
<div id="ollama-status" class="text-4xl font-bold mt-2">-</div>
</div>
</div>
<!-- Models Section -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">Modelli Disponibili</h2>
<button onclick="loadModels()" class="bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded-lg text-sm font-medium transition">
🔄 Aggiorna
</button>
</div>
<!-- Models List -->
<div id="models-container" class="space-y-4">
<div class="text-center py-8">
<div class="animate-spin inline-block w-8 h-8 border-4 border-gray-600 border-t-purple-500 rounded-full"></div>
<p class="text-gray-400 mt-4">Caricamento modelli...</p>
</div>
</div>
</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>
<p class="text-gray-300 mb-4">La API è documentata e testabile direttamente da:</p>
<div class="flex gap-3 flex-wrap">
<a href="/docs" target="_blank" class="inline-block bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg text-sm font-medium transition">
Swagger UI
</a>
<a href="/redoc" target="_blank" class="inline-block bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg text-sm font-medium transition">
ReDoc
</a>
</div>
</div>
</div>
</main>
<!-- Footer -->
<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 • Fatto con ❤️ da <a href="https://lucasacchi.net" target="_blank" class="text-purple-400 hover:text-purple-300">LucaSacchi.Net</a></p>
</div>
</footer>
</div>
<script>
const API_BASE = "/api/v1";
// Formattare bytes in formato leggibile
function formatBytes(bytes) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i];
}
// Formattare data
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString("it-IT", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
});
}
// Verificare health
async function checkHealth() {
try {
const response = await fetch(`${API_BASE}/health`);
if (response.ok) {
const data = await response.json();
const statusEl = document.getElementById("status-indicator");
const statusText = document.getElementById("status-text");
const ollamaStatus = data.ollama_status;
if (ollamaStatus === "online") {
statusEl.className = "w-3 h-3 bg-green-500 rounded-full";
statusText.className = "text-sm text-green-400";
statusText.textContent = "Ollama Online";
document.getElementById("ollama-status").innerHTML = "🟢 Online";
} else {
statusEl.className = "w-3 h-3 bg-red-500 rounded-full";
statusText.className = "text-sm text-red-400";
statusText.textContent = "Ollama Offline";
document.getElementById("ollama-status").innerHTML = "🔴 Offline";
}
}
} catch (error) {
console.error("Health check error:", error);
document.getElementById("status-indicator").className = "w-3 h-3 bg-red-500 rounded-full";
document.getElementById("status-text").textContent = "Errore connessione";
}
}
// Caricare modelli
async function loadModels() {
try {
const response = await fetch(`${API_BASE}/models`);
if (!response.ok) throw new Error("Errore nel caricamento");
const data = await response.json();
const models = data.models || [];
// Aggiornare conteggio
document.getElementById("models-count").textContent = models.length;
// Calcolare spazio totale
const totalSize = models.reduce((sum, m) => sum + m.size, 0);
document.getElementById("total-size").textContent = formatBytes(totalSize);
// Renderizzare modelli
if (models.length === 0) {
document.getElementById("models-container").innerHTML = `
<div class="text-center py-8 text-gray-400">
<p>Nessun modello caricato</p>
</div>
`;
} else {
document.getElementById("models-container").innerHTML = models.map(model => `
<div class="bg-gray-700 rounded-lg p-4 border border-gray-600 hover:border-purple-500 transition">
<div class="flex items-start justify-between mb-3">
<h3 class="text-lg font-semibold">${model.name}</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">
<div>
<p class="text-gray-400">Dimensione</p>
<p class="font-semibold">${formatBytes(model.size)}</p>
</div>
<div>
<p class="text-gray-400">Ultimo aggiornamento</p>
<p class="font-semibold">${formatDate(model.modified_at)}</p>
</div>
</div>
<div class="mt-3">
<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">${model.digest.substring(0, 64)}...</p>
</div>
</div>
`).join("");
}
} catch (error) {
console.error("Error loading models:", error);
document.getElementById("models-container").innerHTML = `
<div class="text-center py-8 text-red-400">
<p>❌ Errore nel caricamento dei modelli</p>
<p class="text-sm mt-2">${error.message}</p>
</div>
`;
}
}
// Inizializzazione
document.addEventListener("DOMContentLoaded", () => {
checkHealth();
loadModels();
// Refresh ogni 30 secondi
setInterval(() => {
checkHealth();
loadModels();
}, 30000);
});
</script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
version: '3.8'
services:
# Ollama Service
ollama:
image: ollama/ollama:latest
container_name: ollama-server
ports:
- "11434:11434"
environment:
OLLAMA_HOST: 0.0.0.0:11434
volumes:
- ollama_data:/root/.ollama
restart: unless-stopped
# Keep container running until stopped
stdin_open: true
tty: true
networks:
- llm-monitor-network
# LLM Monitor Dashboard
llm-monitor:
build:
context: .
dockerfile: Dockerfile
container_name: llm-monitor-app
ports:
- "8000:8000"
environment:
# Carica variabili da .env
OLLAMA_HOST: http://ollama:11434
OLLAMA_TIMEOUT: 30
API_HOST: 0.0.0.0
API_PORT: 8000
API_WORKERS: 4
CORS_ORIGINS: http://localhost:3000,http://localhost:5173,http://localhost:8000
LOG_LEVEL: INFO
ENVIRONMENT: production
env_file:
- .env
depends_on:
- ollama
restart: unless-stopped
stdin_open: true
tty: true
networks:
- llm-monitor-network
# Health check
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
ollama_data:
driver: local
networks:
llm-monitor-network:
driver: bridge
# Istruzioni di avvio:
# docker compose up -d # Avvia i servizi
# docker compose logs -f # Visualizza i log
# docker compose down # Ferma i servizi
# docker compose stop ollama # Ferma solo Ollama
# docker compose start ollama # Riavvia Ollama
+52
View File
@@ -0,0 +1,52 @@
# LLM Monitor - Environment Configuration Example
# Copy this file to .env and adjust values for your environment
# ===========================================
# Ollama Configuration
# ===========================================
# URL base dell'API Ollama
OLLAMA_HOST=http://localhost:11434
# Timeout per le richieste a Ollama (secondi)
OLLAMA_TIMEOUT=30
# ===========================================
# API Configuration
# ===========================================
# Host su cui esporre l'API
API_HOST=0.0.0.0
# Porta su cui esporre l'API
API_PORT=8000
# Numero di worker processes per uVicorn
API_WORKERS=4
# ===========================================
# CORS Configuration
# ===========================================
# Origini CORS consentite (separare con virgola)
CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8000
# ===========================================
# Logging
# ===========================================
# Livello di logging (DEBUG, INFO, WARNING, ERROR, CRITICAL)
LOG_LEVEL=INFO
# ===========================================
# Environment
# ===========================================
# Ambiente di esecuzione (development, production)
ENVIRONMENT=development
# ===========================================
# Security (opzionale)
# ===========================================
# Secret key per sessioni (genera con: python -c "import secrets; print(secrets.token_hex(32))")
# SECRET_KEY=your-secret-key-here
# ===========================================
# Database (se necessario in futuro)
# ===========================================
# DATABASE_URL=sqlite:///./llm-monitor.db
+82
View File
@@ -0,0 +1,82 @@
"""
LLM Monitor - Dashboard per controllare i modelli caricati in Ollama
Entry point dell'applicazione FastAPI
"""
import logging
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path
import os
# Configurazione logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Importare le rotte
from app.api.health import router as health_router
from app.api.models import router as models_router
from app.config import settings
# Creare l'app FastAPI
app = FastAPI(
title="LLM Monitor API",
description="Dashboard per il monitoraggio dei modelli LLM in Ollama",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json"
)
# Configurare CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS.split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Registrare le rotte API
app.include_router(health_router, prefix="/api/v1", tags=["health"])
app.include_router(models_router, prefix="/api/v1", tags=["models"])
# Servire i file statici
static_path = Path(__file__).parent / "app" / "web" / "static"
if static_path.exists():
app.mount("/static", StaticFiles(directory=static_path), name="static")
# Servire la dashboard web
templates_path = Path(__file__).parent / "app" / "web" / "templates"
@app.get("/")
async def root():
"""Redirect alla dashboard"""
return FileResponse(templates_path / "index.html")
@app.get("/dashboard")
async def dashboard():
"""Dashboard principale"""
return FileResponse(templates_path / "index.html")
# Event hooks
@app.on_event("startup")
async def startup_event():
logger.info("🚀 LLM Monitor avviato")
logger.info(f"📊 Ollama host: {settings.OLLAMA_HOST}")
@app.on_event("shutdown")
async def shutdown_event():
logger.info("🛑 LLM Monitor arrestato")
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host=settings.API_HOST,
port=settings.API_PORT,
reload=settings.ENVIRONMENT == "development",
log_level=settings.LOG_LEVEL.lower()
)
+13
View File
@@ -0,0 +1,13 @@
{
"name": "llm-monitor",
"version": "1.0.0",
"description": "Dashboard per controllare i modelli caricati in Ollama",
"private": true,
"scripts": {
"tailwind:dev": "tailwindcss -i app/web/static/css/input.css -o app/web/static/css/output.css --watch",
"tailwind:build": "tailwindcss -i app/web/static/css/input.css -o app/web/static/css/output.css --minify"
},
"devDependencies": {
"tailwindcss": "^3.4.0"
}
}
+26
View File
@@ -0,0 +1,26 @@
# Development Dependencies
# Testing
pytest==7.4.3
pytest-cov==4.1.0
pytest-asyncio==0.21.1
# Code Quality
black==23.12.0
flake8==6.1.0
isort==5.13.2
mypy==1.7.1
# Linting
pylint==3.0.3
# Documentation
mkdocs==1.5.3
mkdocs-material==9.5.0
# Debug
ipython==8.18.1
ipdb==0.13.13
# Pre-commit hooks
pre-commit==3.5.0
+34
View File
@@ -0,0 +1,34 @@
# LLM Monitor Requirements
# Core Web Framework
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
pydantic-settings==2.1.0
# HTTP Client
requests==2.31.0
httpx==0.25.1
# Template Engine
jinja2==3.1.2
# Database & ORM (opzionale)
# sqlalchemy==2.0.23
# alembic==1.12.1
# Utilities
python-dotenv==1.0.0
python-multipart==0.0.6
# Async
aiohttp==3.9.1
# Logging & Monitoring
python-json-logger==2.0.7
# CORS
fastapi-cors==0.0.6
# API Documentation
# (Swagger/ReDoc sono inclusi di default in FastAPI)
+3
View File
@@ -0,0 +1,3 @@
"""
Test suite for LLM Monitor
"""
+32
View File
@@ -0,0 +1,32 @@
"""
Pytest configuration and fixtures
"""
import pytest
from fastapi.testclient import TestClient
from main import app
@pytest.fixture
def client():
"""FastAPI test client"""
return TestClient(app)
@pytest.fixture
def mock_models_response():
"""Mock response from Ollama API"""
return {
"models": [
{
"name": "llama2",
"digest": "91ab89b1b9117e34fb2ff4a5bff07b2e1fa1f1d2d3e4f5a6b7c8d9e0f1a2b3c",
"size": 3825922048,
"modified_at": "2024-01-15T10:30:00.000Z"
},
{
"name": "mistral",
"digest": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1",
"size": 4096000000,
"modified_at": "2024-01-14T15:45:00.000Z"
}
]
}
+94
View File
@@ -0,0 +1,94 @@
"""
Test API endpoints
"""
import pytest
from unittest.mock import patch, MagicMock
def test_health_check(client):
"""Test health endpoint"""
with patch("requests.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_get.return_value = mock_response
response = client.get("/api/v1/health")
assert response.status_code == 200
data = response.json()
assert "status" in data
assert data["status"] == "healthy"
def test_ready_endpoint(client):
"""Test readiness probe"""
with patch("requests.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_get.return_value = mock_response
response = client.get("/api/v1/ready")
assert response.status_code == 200
assert response.json() == {"status": "ready"}
def test_get_models(client, mock_models_response):
"""Test getting models list"""
with patch("requests.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_models_response
mock_get.return_value = mock_response
response = client.get("/api/v1/models")
assert response.status_code == 200
data = response.json()
assert "models" in data
assert "total" in data
assert data["total"] == 2
assert len(data["models"]) == 2
assert data["models"][0]["name"] == "llama2"
def test_get_models_ollama_offline(client):
"""Test getting 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")
assert response.status_code == 500
def test_get_specific_model(client, mock_models_response):
"""Test getting specific model"""
with patch("requests.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_models_response
mock_get.return_value = mock_response
response = client.get("/api/v1/models/llama2")
assert response.status_code == 200
data = response.json()
assert data["name"] == "llama2"
def test_get_nonexistent_model(client, mock_models_response):
"""Test getting nonexistent model"""
with patch("requests.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_models_response
mock_get.return_value = mock_response
response = client.get("/api/v1/models/nonexistent")
assert response.status_code == 404
def test_root_endpoint(client):
"""Test root endpoint redirects to dashboard"""
response = client.get("/", follow_redirects=False)
assert response.status_code in [200, 307]
def test_openapi_schema(client):
"""Test OpenAPI schema is available"""
response = client.get("/openapi.json")
assert response.status_code == 200
schema = response.json()
assert "info" in schema
assert "paths" in schema
assert "/api/v1/health" in schema["paths"]
assert "/api/v1/models" in schema["paths"]
+122
View File
@@ -0,0 +1,122 @@
"""
Test Ollama client service
"""
import pytest
from unittest.mock import patch, MagicMock
from app.services.ollama import OllamaClient
@pytest.fixture
def ollama_client():
"""Create OllamaClient instance"""
return OllamaClient(host="http://localhost:11434", timeout=30)
def test_get_models(ollama_client):
"""Test getting models from Ollama"""
mock_data = {
"models": [
{"name": "llama2", "digest": "abc123", "size": 3825922048},
{"name": "mistral", "digest": "def456", "size": 4096000000}
]
}
with patch("requests.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_data
mock_get.return_value = mock_response
models = ollama_client.get_models()
assert len(models) == 2
assert models[0]["name"] == "llama2"
def test_get_models_error(ollama_client):
"""Test get models when error occurs"""
with patch("requests.get") as mock_get:
mock_get.side_effect = Exception("Connection error")
models = ollama_client.get_models()
assert models == []
def test_get_model(ollama_client):
"""Test getting specific model"""
mock_data = {
"models": [
{"name": "llama2", "digest": "abc123", "size": 3825922048}
]
}
with patch("requests.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_data
mock_get.return_value = mock_response
model = ollama_client.get_model("llama2")
assert model is not None
assert model["name"] == "llama2"
def test_get_nonexistent_model(ollama_client):
"""Test getting nonexistent model"""
mock_data = {"models": []}
with patch("requests.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = mock_data
mock_get.return_value = mock_response
model = ollama_client.get_model("nonexistent")
assert model is None
def test_is_available(ollama_client):
"""Test checking if Ollama is available"""
with patch("requests.get") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_get.return_value = mock_response
assert ollama_client.is_available() is True
def test_is_available_offline(ollama_client):
"""Test checking if Ollama is available when offline"""
with patch("requests.get") as mock_get:
mock_get.side_effect = Exception("Connection refused")
assert ollama_client.is_available() is False
def test_pull_model(ollama_client):
"""Test pulling a model"""
with patch("requests.post") as mock_post:
mock_response = MagicMock()
mock_response.status_code = 200
mock_post.return_value = mock_response
result = ollama_client.pull_model("llama2")
assert result is True
def test_pull_model_error(ollama_client):
"""Test pull model when error occurs"""
with patch("requests.post") as mock_post:
mock_post.side_effect = Exception("Error")
result = ollama_client.pull_model("llama2")
assert result is False
def test_delete_model(ollama_client):
"""Test deleting a model"""
with patch("requests.delete") as mock_delete:
mock_response = MagicMock()
mock_response.status_code = 204
mock_delete.return_value = mock_response
result = ollama_client.delete_model("llama2")
assert result is True
def test_delete_model_error(ollama_client):
"""Test delete model when error occurs"""
with patch("requests.delete") as mock_delete:
mock_delete.side_effect = Exception("Error")
result = ollama_client.delete_model("llama2")
assert result is False