Compare commits
33 Commits
b3beb525ad
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 923621fd55 | |||
| 5440887ef4 | |||
| f6ad08c4a0 | |||
| 3a8adafb6a | |||
| 831b41f487 | |||
| bfed2f60aa | |||
| c217860ebc | |||
| b6fba35004 | |||
| 24b27d26ce | |||
| 74f9501a9c | |||
| a83c1d1261 | |||
| 6739b84b9a | |||
| 1c76515d8c | |||
| 165ad9c02b | |||
| ac2089f921 | |||
| 760c9cc923 | |||
| 9649f2ccfb | |||
| f60781bd7f | |||
| 3ba6a9a41c | |||
| 2f28b6a52a | |||
| bfe301a52c | |||
| 229115ae87 | |||
| 32302e2b06 | |||
| eea6e2a80e | |||
| 87ebd35ad5 | |||
| 1aee51b0d6 | |||
| 2f591e55ce | |||
| e05df7ce2b | |||
| f19c03b7bd | |||
| 0789e5b8e9 | |||
| 57663400ce | |||
| 32b1130632 | |||
| 893376dc14 |
@@ -35,7 +35,6 @@ CONTRIBUTING.md
|
|||||||
|
|
||||||
# Development
|
# Development
|
||||||
node_modules/
|
node_modules/
|
||||||
package-lock.json
|
|
||||||
Makefile
|
Makefile
|
||||||
.env*
|
.env*
|
||||||
|
|
||||||
|
|||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
# 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
|
|
||||||
+4
-1
@@ -93,6 +93,8 @@ celerybeat.pid
|
|||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
@@ -134,7 +136,8 @@ node_modules/
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
app/web/static/css/output.css
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
+11
-2
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
Grazie per l'interesse nel contribuire a LLM Monitor! Questo documento fornisce linee guida per contribuire al progetto.
|
Grazie per l'interesse nel contribuire a LLM Monitor! Questo documento fornisce linee guida per contribuire al progetto.
|
||||||
|
|
||||||
|
## Autore e Diritti
|
||||||
|
|
||||||
|
- **Autore del progetto**: Luca Sacchi Ricciardi
|
||||||
|
- **Detentore di tutti i diritti**: Luca Sacchi Ricciardi
|
||||||
|
|
||||||
## Codice di Condotta
|
## Codice di Condotta
|
||||||
|
|
||||||
Questo progetto aderisce a un Codice di Condotta per garantire un ambiente inclusivo e rispettoso.
|
Questo progetto aderisce a un Codice di Condotta per garantire un ambiente inclusivo e rispettoso.
|
||||||
@@ -116,8 +121,12 @@ feat: aggiungi endpoint per ottenere statistiche modelli
|
|||||||
|
|
||||||
## Licenza
|
## Licenza
|
||||||
|
|
||||||
Contribuendo, accetti che i tuoi contributi siano licensiati sotto la MIT License.
|
Contribuendo, accetti che i tuoi contributi siano soggetti alla licenza
|
||||||
|
proprietaria del progetto (tutti i diritti riservati).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Domande? Apri un issue o contatta il maintainer!
|
Domande? Apri un issue o contatta il maintainer:
|
||||||
|
|
||||||
|
- luca.sacchi@gmail.com
|
||||||
|
- luca@lucasacchi.net
|
||||||
|
|||||||
+31
-4
@@ -1,7 +1,34 @@
|
|||||||
# Multi-stage build per LLM Monitor
|
# Multi-stage build per LLM Monitor
|
||||||
|
|
||||||
# Stage 1: Builder
|
# Stage 1: Build CSS with Tailwind
|
||||||
FROM python:3.11-slim as builder
|
FROM node:18-alpine AS css-builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copiare file di configurazione npm.
|
||||||
|
# Nota: package-lock.json puo non essere presente in alcuni deploy.
|
||||||
|
COPY package*.json tailwind.config.js ./
|
||||||
|
|
||||||
|
# Installare dipendenze Node
|
||||||
|
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
|
|
||||||
|
# Copiare input CSS e file usati dal content scan di Tailwind.
|
||||||
|
# Questo passaggio deve avvenire prima della build per invalidare cache quando cambiano template/js.
|
||||||
|
COPY app/web/static/css/input.css ./app/web/static/css/
|
||||||
|
COPY app/web/templates/ ./app/web/templates/
|
||||||
|
COPY app/web/static/js/ ./app/web/static/js/
|
||||||
|
|
||||||
|
# Compilare CSS Tailwind
|
||||||
|
RUN npm run tailwind:build
|
||||||
|
|
||||||
|
# Verifica bloccante: output.css deve essere compilato e non vuoto.
|
||||||
|
RUN test -s ./app/web/static/css/output.css && \
|
||||||
|
CSS_LINES=$(wc -l < ./app/web/static/css/output.css) && \
|
||||||
|
echo "[css-builder] output.css lines: ${CSS_LINES}" && \
|
||||||
|
test "${CSS_LINES}" -ge 100
|
||||||
|
|
||||||
|
# Stage 2: Build Python packages
|
||||||
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -19,7 +46,7 @@ ENV PATH="/opt/venv/bin:$PATH"
|
|||||||
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
|
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
|
||||||
pip install --no-cache-dir -r requirements.txt
|
pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Stage 2: Runtime
|
# Stage 3: Runtime
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -33,9 +60,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
COPY --from=builder /opt/venv /opt/venv
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
|
||||||
# Copiare codice dell'app
|
# Copiare codice dell'app
|
||||||
|
COPY --from=css-builder /app/app/web/static/css/output.css ./app/web/static/css/output.css
|
||||||
COPY app/ /app/app/
|
COPY app/ /app/app/
|
||||||
COPY main.py /app/
|
COPY main.py /app/
|
||||||
COPY .env* /app/
|
|
||||||
|
|
||||||
# Impostare PATH
|
# Impostare PATH
|
||||||
ENV PATH="/opt/venv/bin:$PATH"
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
MIT License
|
LLM Monitor - Proprietary License Notice
|
||||||
|
|
||||||
Copyright (c) 2024-2026 Luca Sacchi
|
Copyright (c) 2024-2026 Luca Sacchi Ricciardi.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
This software is proprietary. Permission is granted to use the software free
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of charge, strictly "AS IS", without warranties, maintenance, or support,
|
||||||
in the Software without restriction, including without limitation the rights
|
subject to the terms in the following files:
|
||||||
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
|
- LICENSE.en.txt (English, authoritative fallback)
|
||||||
copies or substantial portions of the Software.
|
- LICENSE.it.txt (Italian)
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
If there is a conflict between language versions, the English version in
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
LICENSE.en.txt prevails.
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
Jurisdiction and venue for any dispute: Milan, Italy.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
LLM Monitor - Proprietary License (English)
|
||||||
|
|
||||||
|
Version: 1.0
|
||||||
|
Last updated: 2026-04-25
|
||||||
|
|
||||||
|
Copyright (c) 2024-2026 Luca Sacchi Ricciardi.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
1. Ownership
|
||||||
|
The software, source code, binaries, assets, and documentation (collectively,
|
||||||
|
"Software") are owned by the copyright holder. No ownership rights are
|
||||||
|
transferred under this license.
|
||||||
|
|
||||||
|
2. Grant of Use (Free of Charge)
|
||||||
|
Subject to full compliance with this license, any person obtaining a copy of
|
||||||
|
this Software is granted a personal, non-exclusive, non-transferable,
|
||||||
|
non-sublicensable, revocable right to use the Software free of charge.
|
||||||
|
|
||||||
|
3. Restrictions
|
||||||
|
Unless expressly required by applicable law, you may not:
|
||||||
|
- remove or alter copyright, trademark, or license notices;
|
||||||
|
- represent the Software as your own work;
|
||||||
|
- use the Software in a way that violates applicable law;
|
||||||
|
- provide paid support, warranty, or indemnity on behalf of the copyright
|
||||||
|
holder;
|
||||||
|
- use the copyright holder's name, marks, or branding without prior written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
4. Distribution
|
||||||
|
Redistribution of original or modified copies is allowed only if all of the
|
||||||
|
following are met:
|
||||||
|
- this license text is included in full;
|
||||||
|
- all copyright and attribution notices are preserved;
|
||||||
|
- recipients are clearly informed that the Software is provided "AS IS" with
|
||||||
|
no warranty and no support from the copyright holder.
|
||||||
|
|
||||||
|
5. No Warranty (AS IS)
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", "AS AVAILABLE", AND WITH ALL FAULTS, WITHOUT
|
||||||
|
WARRANTIES OR CONDITIONS OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||||
|
LIMITED TO MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE,
|
||||||
|
NON-INFRINGEMENT, ACCURACY, OR UNINTERRUPTED OPERATION.
|
||||||
|
|
||||||
|
6. No Support or Maintenance
|
||||||
|
THE COPYRIGHT HOLDER HAS NO OBLIGATION TO PROVIDE SUPPORT, MAINTENANCE,
|
||||||
|
UPDATES, ENHANCEMENTS, PATCHES, OR FIXES.
|
||||||
|
|
||||||
|
7. Limitation of Liability
|
||||||
|
TO THE MAXIMUM EXTENT PERMITTED BY LAW, IN NO EVENT SHALL THE COPYRIGHT HOLDER
|
||||||
|
BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL,
|
||||||
|
EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR LOSS OF DATA, PROFITS, REVENUE,
|
||||||
|
GOODWILL, OR BUSINESS INTERRUPTION, ARISING OUT OF OR RELATED TO THE SOFTWARE,
|
||||||
|
WHETHER IN CONTRACT, TORT, STRICT LIABILITY, OR ANY OTHER THEORY, EVEN IF
|
||||||
|
ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
8. Indemnity
|
||||||
|
You agree to defend, indemnify, and hold harmless the copyright holder from and
|
||||||
|
against claims, liabilities, damages, and expenses arising from your use of the
|
||||||
|
Software or your breach of this license, to the extent permitted by law.
|
||||||
|
|
||||||
|
9. Termination
|
||||||
|
This license terminates automatically if you fail to comply with its terms.
|
||||||
|
Upon termination, you must cease use and distribution of the Software.
|
||||||
|
Sections intended by nature to survive termination shall survive.
|
||||||
|
|
||||||
|
10. Governing Law and Jurisdiction
|
||||||
|
This license is governed by the laws of Italy.
|
||||||
|
For any dispute arising out of or related to this Software or this license,
|
||||||
|
the exclusive place of jurisdiction and venue is Milan, Italy.
|
||||||
|
|
||||||
|
11. Severability and Entire Agreement
|
||||||
|
If any provision is held unenforceable, the remaining provisions remain in
|
||||||
|
full force. This license constitutes the entire agreement regarding use of the
|
||||||
|
Software and supersedes prior statements on this subject.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
LLM Monitor - Licenza Proprietaria (Italiano)
|
||||||
|
|
||||||
|
Versione: 1.0
|
||||||
|
Ultimo aggiornamento: 2026-04-25
|
||||||
|
|
||||||
|
Copyright (c) 2024-2026 Luca Sacchi Ricciardi.
|
||||||
|
Tutti i diritti riservati.
|
||||||
|
|
||||||
|
1. Titolarita
|
||||||
|
Il software, il codice sorgente, i binari, gli asset e la documentazione
|
||||||
|
(collettivamente, "Software") sono di proprieta del titolare dei diritti.
|
||||||
|
Questa licenza non trasferisce alcun diritto di proprieta.
|
||||||
|
|
||||||
|
2. Concessione d'uso (gratuita)
|
||||||
|
Nel rispetto integrale di questa licenza, a chi ottiene una copia del Software
|
||||||
|
viene concesso un diritto personale, non esclusivo, non trasferibile,
|
||||||
|
non sublicenziabile e revocabile di usare il Software gratuitamente.
|
||||||
|
|
||||||
|
3. Limitazioni
|
||||||
|
Salvo quanto imposto dalla legge applicabile, non e consentito:
|
||||||
|
- rimuovere o modificare avvisi di copyright, marchi o licenza;
|
||||||
|
- presentare il Software come opera propria;
|
||||||
|
- usare il Software in violazione di legge;
|
||||||
|
- offrire supporto, garanzie o manleve a pagamento in nome del titolare;
|
||||||
|
- usare nome, marchi o branding del titolare senza autorizzazione scritta.
|
||||||
|
|
||||||
|
4. Ridistribuzione
|
||||||
|
La ridistribuzione di copie originali o modificate e consentita solo se sono
|
||||||
|
rispettate tutte le seguenti condizioni:
|
||||||
|
- il testo della presente licenza e incluso integralmente;
|
||||||
|
- avvisi di copyright e attribuzione sono mantenuti;
|
||||||
|
- i destinatari sono informati in modo chiaro che il Software e fornito
|
||||||
|
"COSI COM'E" senza garanzia e senza supporto del titolare.
|
||||||
|
|
||||||
|
5. Esclusione di garanzia (AS IS)
|
||||||
|
IL SOFTWARE E FORNITO "COSI COM'E", "COME DISPONIBILE" E CON OGNI EVENTUALE
|
||||||
|
DIFETTO, SENZA GARANZIE O CONDIZIONI DI ALCUN TIPO, ESPRESSE O IMPLICITE,
|
||||||
|
INCLUSE, A TITOLO ESEMPLIFICATIVO, GARANZIE DI COMMERCIABILITA,
|
||||||
|
IDONEITA A UNO SCOPO SPECIFICO, TITOLARITA, NON VIOLAZIONE, ACCURATEZZA
|
||||||
|
O FUNZIONAMENTO ININTERROTTO.
|
||||||
|
|
||||||
|
6. Nessun supporto o manutenzione
|
||||||
|
IL TITOLARE NON HA ALCUN OBBLIGO DI FORNIRE SUPPORTO, MANUTENZIONE,
|
||||||
|
AGGIORNAMENTI, MIGLIORIE, PATCH O CORREZIONI.
|
||||||
|
|
||||||
|
7. Limitazione di responsabilita
|
||||||
|
NELLA MASSIMA MISURA CONSENTITA DALLA LEGGE, IL TITOLARE NON RISPONDE IN
|
||||||
|
ALCUN CASO DI DANNI DIRETTI, INDIRETTI, INCIDENTALI, SPECIALI,
|
||||||
|
CONSEQUENZIALI, ESEMPLARI O PUNITIVI, NE DI PERDITA DI DATI, PROFITTI,
|
||||||
|
RICAVI, AVVIAMENTO O INTERRUZIONE DI ATTIVITA, DERIVANTI DA O CONNESSI AL
|
||||||
|
SOFTWARE, A QUALSIASI TITOLO (CONTRATTUALE, EXTRACONTRATTUALE, RESPONSABILITA
|
||||||
|
OGGETTIVA O ALTRA TEORIA), ANCHE SE AVVISATO DELLA POSSIBILITA DI TALI DANNI.
|
||||||
|
|
||||||
|
8. Manleva
|
||||||
|
Nei limiti consentiti dalla legge, l'utente accetta di tenere indenne e
|
||||||
|
manlevare il titolare da pretese, responsabilita, danni e costi derivanti
|
||||||
|
dall'uso del Software o dalla violazione della presente licenza.
|
||||||
|
|
||||||
|
9. Risoluzione
|
||||||
|
La licenza si risolve automaticamente in caso di mancato rispetto dei suoi
|
||||||
|
termini. In caso di risoluzione, l'utente deve cessare uso e ridistribuzione
|
||||||
|
del Software. Le clausole che per natura devono sopravvivere restano efficaci.
|
||||||
|
|
||||||
|
10. Legge applicabile e foro competente
|
||||||
|
La presente licenza e regolata dalla legge italiana.
|
||||||
|
Per ogni controversia derivante da o connessa al Software o alla presente
|
||||||
|
licenza, il foro esclusivamente competente e Milano, Italia.
|
||||||
|
|
||||||
|
11. Clausola di salvaguardia e intero accordo
|
||||||
|
Se una clausola e ritenuta invalida o inapplicabile, le altre restano valide
|
||||||
|
ed efficaci. La presente licenza costituisce l'intero accordo relativo all'uso
|
||||||
|
del Software e sostituisce ogni precedente dichiarazione sul tema.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help install dev prod test lint format clean docker-build docker-up docker-down
|
.PHONY: help install dev prod test lint format clean docker-build docker-up docker-down docker-build-no-cache verify-css deploy-no-cache
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "LLM Monitor - Makefile Commands"
|
@echo "LLM Monitor - Makefile Commands"
|
||||||
@@ -11,8 +11,11 @@ help:
|
|||||||
@echo "make format - Formatta il codice"
|
@echo "make format - Formatta il codice"
|
||||||
@echo "make clean - Pulisce cache e file temporanei"
|
@echo "make clean - Pulisce cache e file temporanei"
|
||||||
@echo "make docker-build - Build dell'immagine Docker"
|
@echo "make docker-build - Build dell'immagine Docker"
|
||||||
|
@echo "make docker-build-no-cache - Build Docker senza cache (fix Tailwind)"
|
||||||
@echo "make docker-up - Avvia i container con Docker Compose"
|
@echo "make docker-up - Avvia i container con Docker Compose"
|
||||||
@echo "make docker-down - Ferma i container con Docker Compose"
|
@echo "make docker-down - Ferma i container con Docker Compose"
|
||||||
|
@echo "make verify-css - Verifica output.css compilato nel container"
|
||||||
|
@echo "make deploy-no-cache - Deploy completo con build no-cache + verifica CSS"
|
||||||
|
|
||||||
install:
|
install:
|
||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
@@ -41,12 +44,21 @@ clean:
|
|||||||
docker-build:
|
docker-build:
|
||||||
docker build -t llm-monitor:latest .
|
docker build -t llm-monitor:latest .
|
||||||
|
|
||||||
|
docker-build-no-cache:
|
||||||
|
docker compose build --no-cache
|
||||||
|
|
||||||
docker-up:
|
docker-up:
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
docker-down:
|
docker-down:
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
|
verify-css:
|
||||||
|
./scripts/verify-tailwind-css.sh
|
||||||
|
|
||||||
|
deploy-no-cache:
|
||||||
|
./scripts/deploy-no-cache.sh
|
||||||
|
|
||||||
docker-logs:
|
docker-logs:
|
||||||
docker compose logs -f llm-monitor
|
docker compose logs -f llm-monitor
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ Una dashboard web moderna e intuitiva per monitorare e gestire i modelli LLM car
|
|||||||
|
|
||||||
## 🎯 Caratteristiche
|
## 🎯 Caratteristiche
|
||||||
|
|
||||||
- ✨ **Dashboard intuitiva** - Visualizza in tempo reale i modelli caricati in Ollama
|
- ✨ **Dashboard intuitiva** - Visualizza in tempo reale i modelli disponibili e in esecuzione su Ollama
|
||||||
- 📊 **Monitoraggio modelli** - Dettagli completi di ogni modello (nome, dimensione, memoria, stato)
|
- 📊 **Monitoraggio modelli** - Dettagli completi di ogni modello (nome, dimensione, memoria, stato)
|
||||||
|
- 🧩 **Dettagli accordion on click** - Clic su una card per esplorare i dati `ollama show` in pannelli collassabili (dettagli, parametri, template, modelfile, licenza)
|
||||||
|
- 🖥️ **Multi-server** - Gestione di più istanze Ollama con switch istantaneo (pagina `/servers`)
|
||||||
|
- 🏃 **Modelli in esecuzione** - Pagina dedicata `/models-running` con VRAM, tempo rimanente e backend GPU/CPU
|
||||||
|
- 📱 **PWA** - Installabile come app desktop/mobile con Service Worker e cache offline
|
||||||
- 🔌 **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 dark-mode realizzata con TailwindCSS
|
||||||
- 🐳 **Docker ready** - Container sempre acceso (until stopped)
|
- 🐳 **Docker ready** - Container sempre acceso (restart: unless-stopped)
|
||||||
- ⚡ **Performance** - Costruito su FastAPI e uVicorn
|
- ⚡ **Performance** - FastAPI + uVicorn, aggiornamenti ogni 30s via Web Worker senza bloccare l'UI
|
||||||
- 🔐 **Configurazione flessibile** - File `.env` per personalizzazione
|
- 🔐 **Configurazione flessibile** - File `.env` per personalizzazione
|
||||||
|
|
||||||
## 📋 Requisiti
|
## 📋 Requisiti
|
||||||
@@ -85,6 +89,7 @@ OLLAMA_TIMEOUT=30
|
|||||||
API_HOST=0.0.0.0
|
API_HOST=0.0.0.0
|
||||||
API_PORT=8000
|
API_PORT=8000
|
||||||
API_WORKERS=4
|
API_WORKERS=4
|
||||||
|
ENABLE_MODEL_RW_API=false
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
|
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
|
||||||
@@ -105,6 +110,7 @@ ENVIRONMENT=development
|
|||||||
| `API_HOST` | `0.0.0.0` | Host su cui esporre l'API |
|
| `API_HOST` | `0.0.0.0` | Host su cui esporre l'API |
|
||||||
| `API_PORT` | `8000` | Porta dell'API |
|
| `API_PORT` | `8000` | Porta dell'API |
|
||||||
| `API_WORKERS` | `4` | Worker processes |
|
| `API_WORKERS` | `4` | Worker processes |
|
||||||
|
| `ENABLE_MODEL_RW_API` | `false` | Abilita endpoint `POST/DELETE` sui modelli |
|
||||||
| `CORS_ORIGINS` | `http://localhost:3000` | Origini CORS consentite |
|
| `CORS_ORIGINS` | `http://localhost:3000` | Origini CORS consentite |
|
||||||
| `LOG_LEVEL` | `INFO` | Livello di logging |
|
| `LOG_LEVEL` | `INFO` | Livello di logging |
|
||||||
| `ENVIRONMENT` | `development` | Ambiente (development/production) |
|
| `ENVIRONMENT` | `development` | Ambiente (development/production) |
|
||||||
@@ -145,12 +151,33 @@ 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
|
||||||
GET /api/v1/health
|
GET /api/v1/health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Endpoint R/W modelli (opzionali)
|
||||||
|
|
||||||
|
Per impostazione predefinita gli endpoint di scrittura sono **disabilitati** e non disponibili.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/v1/models/{model_name}/pull
|
||||||
|
DELETE /api/v1/models/{model_name}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per abilitarli, imposta nel file `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ENABLE_MODEL_RW_API=true
|
||||||
|
```
|
||||||
|
|
||||||
**Risposta:**
|
**Risposta:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -170,10 +197,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
|
||||||
@@ -203,6 +239,12 @@ Usa il file `docker-compose.yml` fornito:
|
|||||||
# Avviare i servizi
|
# Avviare i servizi
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
|
# Rebuild completo senza cache (consigliato dopo modifiche UI/Tailwind)
|
||||||
|
docker compose build --no-cache
|
||||||
|
|
||||||
|
# Verificare che il CSS compilato non sia vuoto
|
||||||
|
docker exec llm-monitor-app wc -l /app/app/web/static/css/output.css
|
||||||
|
|
||||||
# Visualizzare i log
|
# Visualizzare i log
|
||||||
docker compose logs -f llm-monitor
|
docker compose logs -f llm-monitor
|
||||||
|
|
||||||
@@ -213,18 +255,53 @@ docker compose down
|
|||||||
docker compose restart llm-monitor
|
docker compose restart llm-monitor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Deploy consigliato (Tailwind-safe)
|
||||||
|
|
||||||
|
Se l'interfaccia appare senza stili o una modale non si posiziona correttamente, usa il deploy con rebuild no-cache e verifica CSS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/llm-monitor
|
||||||
|
docker compose down
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
sleep 5
|
||||||
|
docker exec llm-monitor-app wc -l /app/app/web/static/css/output.css
|
||||||
|
```
|
||||||
|
|
||||||
|
In alternativa dal repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deploy-no-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tailwind Build Process
|
||||||
|
|
||||||
|
- Lo stage `css-builder` del Dockerfile installa dipendenze Node con `npm ci`.
|
||||||
|
- Prima della build Tailwind vengono copiati template HTML e JS usati dal content scan.
|
||||||
|
- Dopo `npm run tailwind:build` una verifica bloccante controlla che `output.css` esista e abbia almeno 100 linee.
|
||||||
|
- Lo stage runtime copia `output.css` compilato da `css-builder` con `COPY --from=css-builder`.
|
||||||
|
|
||||||
|
### Troubleshooting UI
|
||||||
|
|
||||||
|
Se la modale non appare o i componenti sembrano "unstyled":
|
||||||
|
|
||||||
|
1. Esegui `docker compose build --no-cache`.
|
||||||
|
2. Riavvia con `docker compose up -d`.
|
||||||
|
3. Verifica CSS compilato: `docker exec llm-monitor-app wc -l /app/app/web/static/css/output.css`.
|
||||||
|
4. Se il numero linee e `< 100`, la build Tailwind non e riuscita correttamente.
|
||||||
|
|
||||||
### Container sempre acceso
|
### Container sempre acceso
|
||||||
|
|
||||||
Il container Ollama rimarrà in esecuzione fino al suo arresto manuale:
|
Il container `llm-monitor` rimarrà in esecuzione fino al suo arresto manuale:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Fermare
|
# Fermare
|
||||||
docker compose stop ollama
|
docker compose stop llm-monitor
|
||||||
# oppure
|
# oppure
|
||||||
docker stop llm-monitor
|
docker stop llm-monitor
|
||||||
|
|
||||||
# Riavviare
|
# Riavviare
|
||||||
docker compose start ollama
|
docker compose start llm-monitor
|
||||||
# oppure
|
# oppure
|
||||||
docker start llm-monitor
|
docker start llm-monitor
|
||||||
```
|
```
|
||||||
@@ -235,38 +312,59 @@ docker start llm-monitor
|
|||||||
llm-monitor/
|
llm-monitor/
|
||||||
├── main.py # Entry point dell'applicazione
|
├── main.py # Entry point dell'applicazione
|
||||||
├── requirements.txt # Dipendenze Python
|
├── requirements.txt # Dipendenze Python
|
||||||
|
├── requirements-dev.txt # Dipendenze sviluppo (pytest, black, flake8…)
|
||||||
├── env.example # Esempio di configurazione
|
├── env.example # Esempio di configurazione
|
||||||
├── Dockerfile # Configurazione Docker
|
├── Dockerfile # Build multi-stage (Node CSS + Python runtime)
|
||||||
├── docker-compose.yml # Composizione servizi
|
├── docker-compose.yml # Composizione servizi
|
||||||
|
├── package.json # Script Node (Tailwind, Playwright)
|
||||||
|
├── tailwind.config.js # Configurazione TailwindCSS
|
||||||
|
├── playwright.config.js # Configurazione test E2E
|
||||||
|
├── Makefile # Comandi rapidi (dev, test, deploy…)
|
||||||
├── README.md # Questo file
|
├── README.md # Questo file
|
||||||
├── .gitignore
|
├── CONTRIBUTING.md # Guida ai contributi
|
||||||
│
|
│
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── __init__.py
|
│ ├── config.py # Configurazione via variabili d'ambiente
|
||||||
│ ├── config.py # Configurazione (variabili ambiente)
|
|
||||||
│ ├── main.py # Inizializzazione FastAPI
|
|
||||||
│ │
|
│ │
|
||||||
│ ├── api/
|
│ ├── api/
|
||||||
│ │ ├── __init__.py
|
│ │ ├── models.py # Endpoint modelli (/api/v1/models)
|
||||||
│ │ ├── models.py # Endpoint modelli
|
│ │ └── health.py # Endpoint health (/api/v1/health)
|
||||||
│ │ ├── health.py # Endpoint health
|
|
||||||
│ │ └── v1/
|
|
||||||
│ │ └── __init__.py
|
|
||||||
│ │
|
│ │
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ ├── __init__.py
|
│ │ └── ollama.py # Client HTTP verso Ollama
|
||||||
│ │ ├── ollama.py # Client Ollama
|
|
||||||
│ │ └── cache.py # Cache in-memory (opzionale)
|
|
||||||
│ │
|
│ │
|
||||||
│ └── web/
|
│ └── web/
|
||||||
│ ├── __init__.py
|
│ ├── static/
|
||||||
│ ├── static/ # Assets statici (CSS compilato TailwindCSS)
|
│ │ ├── css/
|
||||||
│ └── templates/ # Template HTML
|
│ │ │ ├── input.css # Sorgente Tailwind
|
||||||
|
│ │ │ └── output.css # CSS compilato (generato)
|
||||||
|
│ │ └── js/
|
||||||
|
│ │ ├── app.js # App principale (dashboard modelli)
|
||||||
|
│ │ ├── servers.js # Pagina gestione server
|
||||||
|
│ │ ├── models-running.js # Pagina modelli in esecuzione
|
||||||
|
│ │ ├── data-sync.worker.js # Web Worker sincronizzazione dati
|
||||||
|
│ │ ├── server-config.js # Utilità multi-server e localStorage
|
||||||
|
│ │ ├── pwa-register.js # Registrazione Service Worker
|
||||||
|
│ │ └── service-worker.js # PWA Service Worker (cache-first)
|
||||||
|
│ └── templates/
|
||||||
|
│ ├── index.html # Dashboard modelli disponibili
|
||||||
|
│ ├── servers.html # Gestione istanze Ollama
|
||||||
|
│ └── models_running.html # Modelli attualmente in esecuzione
|
||||||
|
│
|
||||||
|
├── docs/
|
||||||
|
│ ├── PRD.md # Product Requirements Document
|
||||||
|
│ ├── DEVELOPMENT.md # Guida al setup e sviluppo locale
|
||||||
|
│ └── WEB_WORKERS.md # Architettura Web Worker e PWA
|
||||||
|
│
|
||||||
|
├── scripts/
|
||||||
|
│ ├── deploy-no-cache.sh # Deploy Docker con rebuild forzato
|
||||||
|
│ └── verify-tailwind-css.sh # Verifica CSS compilato in container
|
||||||
│
|
│
|
||||||
└── tests/
|
└── tests/
|
||||||
├── __init__.py
|
├── test_api.py # Unit test endpoint FastAPI
|
||||||
├── test_api.py
|
├── test_ollama.py # Unit test client Ollama
|
||||||
└── test_ollama.py
|
└── e2e/
|
||||||
|
└── cache-navigation.spec.js # Test E2E Playwright (cache/PWA)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🛠️ Sviluppo
|
## 🛠️ Sviluppo
|
||||||
@@ -302,6 +400,9 @@ pytest tests/ -v
|
|||||||
# Test con coverage
|
# Test con coverage
|
||||||
pytest tests/ --cov=app
|
pytest tests/ --cov=app
|
||||||
|
|
||||||
|
# Browser E2E test (cache-first navigation)
|
||||||
|
OLLAMA_HOST=http://192.168.254.115:11434 npm run test:e2e
|
||||||
|
|
||||||
# Hot reload durante sviluppo
|
# Hot reload durante sviluppo
|
||||||
uvicorn main:app --reload
|
uvicorn main:app --reload
|
||||||
```
|
```
|
||||||
@@ -354,7 +455,21 @@ lsof -ti :8000 | xargs kill -9
|
|||||||
|
|
||||||
## 📜 Licenza
|
## 📜 Licenza
|
||||||
|
|
||||||
Questo progetto è distribuito sotto licenza **MIT**. Vedi il file `LICENSE` per dettagli.
|
Questo progetto e distribuito con **licenza proprietaria** (tutti i diritti riservati).
|
||||||
|
|
||||||
|
Autore e detentore esclusivo di tutti i diritti: **Luca Sacchi Ricciardi**.
|
||||||
|
|
||||||
|
- Uso consentito gratuitamente
|
||||||
|
- Software fornito "AS IS"
|
||||||
|
- Nessuna garanzia
|
||||||
|
- Nessun supporto o manutenzione obbligatori
|
||||||
|
- Foro competente esclusivo: Milano, Italia
|
||||||
|
|
||||||
|
Dettagli completi:
|
||||||
|
|
||||||
|
- `LICENSE` (notice principale)
|
||||||
|
- `LICENSE.en.txt` (testo completo in inglese)
|
||||||
|
- `LICENSE.it.txt` (testo completo in italiano)
|
||||||
|
|
||||||
## 🤝 Contribuire
|
## 🤝 Contribuire
|
||||||
|
|
||||||
@@ -370,12 +485,15 @@ Le pull request sono benvenute! Per cambiamenti importanti, apri prima un issue
|
|||||||
|
|
||||||
## 📞 Supporto
|
## 📞 Supporto
|
||||||
|
|
||||||
Per domande o segnalazioni di bug, apri un **Issue** nel repository.
|
Per domande o segnalazioni di bug, apri un **Issue** nel repository oppure contatta l'autore:
|
||||||
|
|
||||||
|
- luca.sacchi@gmail.com
|
||||||
|
- luca@lucasacchi.net
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Fatto con ❤️ da [LucaSacchi.Net](https://lucasacchi.net)**
|
**Autore: Luca Sacchi Ricciardi ([LucaSacchi.Net](https://lucasacchi.net), luca.sacchi@gmail.com, luca@lucasacchi.net)**
|
||||||
|
|
||||||
**Versione**: 1.0.0
|
**Versione**: 1.1.0
|
||||||
**Ultima modifica**: Aprile 2026
|
**Ultima modifica**: Aprile 2026
|
||||||
**Status**: 🟢 In Development
|
**Status**: 🟢 Active
|
||||||
|
|||||||
+21
-5
@@ -2,11 +2,13 @@
|
|||||||
Health check endpoints
|
Health check endpoints
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -26,18 +28,31 @@ class HealthResponse(BaseModel):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ollama_host(host: Optional[str]) -> str:
|
||||||
|
"""Resolve target Ollama host, optionally overridden by query parameter."""
|
||||||
|
if not host:
|
||||||
|
return settings.OLLAMA_HOST
|
||||||
|
|
||||||
|
parsed = urlparse(host.strip())
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise HTTPException(status_code=422, detail="Invalid Ollama host URL")
|
||||||
|
|
||||||
|
return host.rstrip("/")
|
||||||
|
|
||||||
@router.get("/health", response_model=HealthResponse)
|
@router.get("/health", response_model=HealthResponse)
|
||||||
async def health_check():
|
async def health_check(host: Optional[str] = Query(default=None)):
|
||||||
"""
|
"""
|
||||||
Health check dell'API e dello stato di Ollama
|
Health check dell'API e dello stato di Ollama
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
HealthResponse: Status dell'API e di Ollama
|
HealthResponse: Status dell'API e di Ollama
|
||||||
"""
|
"""
|
||||||
|
target_host = resolve_ollama_host(host)
|
||||||
try:
|
try:
|
||||||
# Check Ollama
|
# Check Ollama
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
f"{settings.OLLAMA_HOST}/api/tags",
|
f"{target_host}/api/tags",
|
||||||
timeout=settings.OLLAMA_TIMEOUT
|
timeout=settings.OLLAMA_TIMEOUT
|
||||||
)
|
)
|
||||||
ollama_status = "online" if response.status_code == 200 else "offline"
|
ollama_status = "online" if response.status_code == 200 else "offline"
|
||||||
@@ -52,13 +67,14 @@ async def health_check():
|
|||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/ready")
|
@router.get("/ready")
|
||||||
async def ready():
|
async def ready(host: Optional[str] = Query(default=None)):
|
||||||
"""
|
"""
|
||||||
Readiness probe per Kubernetes/Docker
|
Readiness probe per Kubernetes/Docker
|
||||||
"""
|
"""
|
||||||
|
target_host = resolve_ollama_host(host)
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
f"{settings.OLLAMA_HOST}/api/tags",
|
f"{target_host}/api/tags",
|
||||||
timeout=5
|
timeout=5
|
||||||
)
|
)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
|||||||
+144
-8
@@ -2,17 +2,39 @@
|
|||||||
Models endpoints - Gestione dei modelli Ollama
|
Models endpoints - Gestione dei modelli Ollama
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
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
|
||||||
|
from urllib.parse import urlparse
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_rw_api_enabled() -> None:
|
||||||
|
"""Blocca le API di scrittura se non abilitate esplicitamente."""
|
||||||
|
if not settings.ENABLE_MODEL_RW_API:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Endpoint non disponibile"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ollama_host(host: Optional[str]) -> str:
|
||||||
|
"""Resolve target Ollama host, optionally overridden by query parameter."""
|
||||||
|
if not host:
|
||||||
|
return settings.OLLAMA_HOST
|
||||||
|
|
||||||
|
parsed = urlparse(host.strip())
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise HTTPException(status_code=422, detail="Invalid Ollama host URL")
|
||||||
|
|
||||||
|
return host.rstrip("/")
|
||||||
|
|
||||||
class ModelInfo(BaseModel):
|
class ModelInfo(BaseModel):
|
||||||
"""Informazioni su un modello"""
|
"""Informazioni su un modello"""
|
||||||
name: str
|
name: str
|
||||||
@@ -51,7 +73,7 @@ class ModelsResponse(BaseModel):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/models", response_model=ModelsResponse)
|
@router.get("/models", response_model=ModelsResponse)
|
||||||
async def get_models():
|
async def get_models(host: Optional[str] = Query(default=None)):
|
||||||
"""
|
"""
|
||||||
Recupera l'elenco di tutti i modelli caricati in Ollama
|
Recupera l'elenco di tutti i modelli caricati in Ollama
|
||||||
|
|
||||||
@@ -61,9 +83,10 @@ async def get_models():
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: Se Ollama non è disponibile
|
HTTPException: Se Ollama non è disponibile
|
||||||
"""
|
"""
|
||||||
|
target_host = resolve_ollama_host(host)
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
f"{settings.OLLAMA_HOST}/api/tags",
|
f"{target_host}/api/tags",
|
||||||
timeout=settings.OLLAMA_TIMEOUT
|
timeout=settings.OLLAMA_TIMEOUT
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,6 +126,8 @@ async def get_models():
|
|||||||
status_code=502,
|
status_code=502,
|
||||||
detail="Impossible connettersi a Ollama"
|
detail="Impossible connettersi a Ollama"
|
||||||
)
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching models: {e}")
|
logger.error(f"Error fetching models: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -110,8 +135,57 @@ 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(host: Optional[str] = Query(default=None)) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Recupera i modelli attualmente residenti in memoria, equivalenti a `ollama ps`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: Payload con modelli running e conteggio
|
||||||
|
"""
|
||||||
|
target_host = resolve_ollama_host(host)
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"{target_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, host: Optional[str] = Query(default=None)):
|
||||||
"""
|
"""
|
||||||
Recupera le informazioni di un modello specifico
|
Recupera le informazioni di un modello specifico
|
||||||
|
|
||||||
@@ -124,9 +198,10 @@ async def get_model(model_name: str):
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: Se il modello non esiste o Ollama non è disponibile
|
HTTPException: Se il modello non esiste o Ollama non è disponibile
|
||||||
"""
|
"""
|
||||||
|
target_host = resolve_ollama_host(host)
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
f"{settings.OLLAMA_HOST}/api/tags",
|
f"{target_host}/api/tags",
|
||||||
timeout=settings.OLLAMA_TIMEOUT
|
timeout=settings.OLLAMA_TIMEOUT
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -165,7 +240,63 @@ async def get_model(model_name: str):
|
|||||||
detail="Errore nel recupero del modello"
|
detail="Errore nel recupero del modello"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/models/{model_name}/pull")
|
|
||||||
|
@router.get("/models/{model_name}/show")
|
||||||
|
async def get_model_show(model_name: str, host: Optional[str] = Query(default=None)) -> 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
|
||||||
|
"""
|
||||||
|
target_host = resolve_ollama_host(host)
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{target_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
|
||||||
|
)
|
||||||
async def pull_model(model_name: str):
|
async def pull_model(model_name: str):
|
||||||
"""
|
"""
|
||||||
Scarica/carica un modello in Ollama
|
Scarica/carica un modello in Ollama
|
||||||
@@ -176,6 +307,7 @@ async def pull_model(model_name: str):
|
|||||||
Returns:
|
Returns:
|
||||||
dict: Status del download
|
dict: Status del download
|
||||||
"""
|
"""
|
||||||
|
ensure_rw_api_enabled()
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{settings.OLLAMA_HOST}/api/pull",
|
f"{settings.OLLAMA_HOST}/api/pull",
|
||||||
@@ -198,7 +330,10 @@ async def pull_model(model_name: str):
|
|||||||
detail="Errore nel pull del modello"
|
detail="Errore nel pull del modello"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.delete("/models/{model_name}")
|
@router.delete(
|
||||||
|
"/models/{model_name}",
|
||||||
|
include_in_schema=settings.ENABLE_MODEL_RW_API
|
||||||
|
)
|
||||||
async def delete_model(model_name: str):
|
async def delete_model(model_name: str):
|
||||||
"""
|
"""
|
||||||
Elimina un modello da Ollama
|
Elimina un modello da Ollama
|
||||||
@@ -209,6 +344,7 @@ async def delete_model(model_name: str):
|
|||||||
Returns:
|
Returns:
|
||||||
dict: Confirmazione eliminazione
|
dict: Confirmazione eliminazione
|
||||||
"""
|
"""
|
||||||
|
ensure_rw_api_enabled()
|
||||||
try:
|
try:
|
||||||
response = requests.delete(
|
response = requests.delete(
|
||||||
f"{settings.OLLAMA_HOST}/api/delete",
|
f"{settings.OLLAMA_HOST}/api/delete",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Settings(BaseSettings):
|
|||||||
API_HOST: str = "0.0.0.0"
|
API_HOST: str = "0.0.0.0"
|
||||||
API_PORT: int = 8000
|
API_PORT: int = 8000
|
||||||
API_WORKERS: int = 4
|
API_WORKERS: int = 4
|
||||||
|
ENABLE_MODEL_RW_API: bool = False
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
+523
-32
@@ -6,6 +6,11 @@
|
|||||||
class LLMMonitorApp {
|
class LLMMonitorApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.worker = null;
|
this.worker = null;
|
||||||
|
this.activeServer = getActiveServer();
|
||||||
|
this.selectedModelName = null;
|
||||||
|
this.isModalOpen = false;
|
||||||
|
this.hoverOpenDelayMs = 180;
|
||||||
|
this.hoverOpenTimer = null;
|
||||||
this.lastData = {
|
this.lastData = {
|
||||||
health: null,
|
health: null,
|
||||||
models: null
|
models: null
|
||||||
@@ -14,6 +19,16 @@ class LLMMonitorApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
if (!this.activeServer) {
|
||||||
|
this.renderNoServerState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateServerContextUI();
|
||||||
|
|
||||||
|
// Caricare dati da localStorage prima di qualsiasi sync di rete.
|
||||||
|
this.loadFromLocalStorage();
|
||||||
|
|
||||||
// Inizializzare il Web Worker
|
// Inizializzare il Web Worker
|
||||||
if (typeof Worker !== 'undefined') {
|
if (typeof Worker !== 'undefined') {
|
||||||
this.worker = new Worker('/static/js/data-sync.worker.js');
|
this.worker = new Worker('/static/js/data-sync.worker.js');
|
||||||
@@ -23,57 +38,105 @@ class LLMMonitorApp {
|
|||||||
// Fallback: sincronizzazione nel main thread
|
// Fallback: sincronizzazione nel main thread
|
||||||
this.syncDataInMainThread();
|
this.syncDataInMainThread();
|
||||||
};
|
};
|
||||||
} else {
|
const shouldSyncImmediately = this.shouldSyncImmediately();
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "SET_SERVER",
|
||||||
|
serverId: this.activeServer.id,
|
||||||
|
host: this.activeServer.host,
|
||||||
|
syncImmediately: shouldSyncImmediately,
|
||||||
|
lastSyncTimestamp: this.getLatestCacheTimestamp()
|
||||||
|
});
|
||||||
|
if (shouldSyncImmediately) {
|
||||||
|
this.renderLoadingState();
|
||||||
|
}
|
||||||
|
} else if (this.shouldSyncImmediately()) {
|
||||||
console.warn("Web Workers not supported, using main thread");
|
console.warn("Web Workers not supported, using main thread");
|
||||||
this.syncDataInMainThread();
|
this.syncDataInMainThread();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caricare dati da localStorage
|
|
||||||
this.loadFromLocalStorage();
|
|
||||||
|
|
||||||
// Listener al pulsante manuale
|
// Listener al pulsante manuale
|
||||||
document.getElementById("refresh-btn")?.addEventListener("click", () => {
|
document.getElementById("refresh-btn")?.addEventListener("click", () => {
|
||||||
this.requestSync();
|
this.requestSync();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Chiusura modal con pulsante X
|
||||||
|
document.getElementById("model-details-close")?.addEventListener("click", () => {
|
||||||
|
this.hideModelDetails();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chiusura modal con click su overlay
|
||||||
|
document.getElementById("model-details-backdrop")?.addEventListener("click", () => {
|
||||||
|
this.hideModelDetails();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chiusura modal con tasto Esc
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
this.hideModelDetails();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caricare dati da localStorage
|
// Caricare dati da localStorage
|
||||||
loadFromLocalStorage() {
|
loadFromLocalStorage() {
|
||||||
const healthStr = localStorage.getItem("llm_monitor_health");
|
const health = readServerCache(this.activeServer.id, "health");
|
||||||
const modelsStr = localStorage.getItem("llm_monitor_models");
|
const models = readServerCache(this.activeServer.id, "models");
|
||||||
|
|
||||||
if (healthStr) {
|
if (health) {
|
||||||
try {
|
this.lastData.health = health;
|
||||||
this.lastData.health = JSON.parse(healthStr);
|
|
||||||
this.renderHealth(this.lastData.health);
|
this.renderHealth(this.lastData.health);
|
||||||
} catch (e) {
|
|
||||||
console.error("Error parsing health data:", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelsStr) {
|
if (models) {
|
||||||
try {
|
this.lastData.models = models;
|
||||||
this.lastData.models = JSON.parse(modelsStr);
|
|
||||||
this.renderModels(this.lastData.models);
|
this.renderModels(this.lastData.models);
|
||||||
} catch (e) {
|
|
||||||
console.error("Error parsing models data:", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateCacheModeIndicator(models);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestire messaggi dal Worker
|
// Gestire messaggi dal Worker
|
||||||
handleWorkerMessage(event) {
|
handleWorkerMessage(event) {
|
||||||
const { type, health, modelsData } = event.data;
|
const { type, health, modelsData, runningData, serverId } = event.data;
|
||||||
|
|
||||||
|
if (serverId && this.activeServer && serverId !== this.activeServer.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
try {
|
||||||
|
writeServerCache(this.activeServer.id, "health", health);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Cannot persist health in localStorage:", error);
|
||||||
|
}
|
||||||
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;
|
||||||
this.renderModels(modelsData);
|
try {
|
||||||
|
const persistedModels = writeServerCache(this.activeServer.id, "models", modelsData);
|
||||||
|
if (persistedModels) {
|
||||||
|
this.lastData.models = persistedModels;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Cannot persist models in localStorage:", error);
|
||||||
|
}
|
||||||
|
this.updateCacheModeIndicator(this.lastData.models);
|
||||||
|
this.renderModels(this.lastData.models);
|
||||||
|
if (this.selectedModelName) {
|
||||||
|
this.showModelDetails(this.selectedModelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runningData) {
|
||||||
|
try {
|
||||||
|
writeServerCache(this.activeServer.id, "running", runningData);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Cannot persist running models in localStorage:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +185,7 @@ class LLMMonitorApp {
|
|||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="text-center py-8 text-gray-400">
|
<div class="text-center py-8 text-gray-400">
|
||||||
<p>Nessun modello caricato</p>
|
<p>No models loaded</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
@@ -134,25 +197,76 @@ class LLMMonitorApp {
|
|||||||
// Aggiornare solo se veramente diverso
|
// Aggiornare solo se veramente diverso
|
||||||
if (container.innerHTML !== newHTML) {
|
if (container.innerHTML !== newHTML) {
|
||||||
container.innerHTML = newHTML;
|
container.innerHTML = newHTML;
|
||||||
|
this.bindModelCardInteractions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Associare eventi card dopo ogni render (piu affidabile della delega su hover)
|
||||||
|
bindModelCardInteractions() {
|
||||||
|
const cards = document.querySelectorAll("#models-container [data-model-key]");
|
||||||
|
cards.forEach((card) => {
|
||||||
|
if (card.dataset.modalBound === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelKey = card.getAttribute("data-model-key");
|
||||||
|
if (!modelKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelName = decodeURIComponent(modelKey);
|
||||||
|
card.dataset.modalBound = "true";
|
||||||
|
|
||||||
|
card.addEventListener("click", () => {
|
||||||
|
this.toggleModelDetails(modelName);
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener("mouseenter", () => {
|
||||||
|
if (this.hoverOpenTimer) {
|
||||||
|
clearTimeout(this.hoverOpenTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hoverOpenTimer = setTimeout(() => {
|
||||||
|
this.showModelDetails(modelName);
|
||||||
|
}, this.hoverOpenDelayMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener("mouseleave", () => {
|
||||||
|
if (this.hoverOpenTimer) {
|
||||||
|
clearTimeout(this.hoverOpenTimer);
|
||||||
|
this.hoverOpenTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleModelDetails(modelName) {
|
||||||
|
if (this.isModalOpen && this.selectedModelName === modelName) {
|
||||||
|
this.hideModelDetails();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showModelDetails(modelName);
|
||||||
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
|
const modelKey = encodeURIComponent(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-key="${modelKey}" class="bg-gray-700 rounded-lg p-4 border border-gray-600 hover:border-purple-500 hover:-translate-y-0.5 transition cursor-pointer h-full">
|
||||||
<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">Loaded</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400">Dimensione</p>
|
<p class="text-gray-400">Size</p>
|
||||||
<p class="font-semibold">${this.formatBytes(model.size)}</p>
|
<p class="font-semibold">${this.formatBytes(model.size)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400">Ultimo aggiornamento</p>
|
<p class="text-gray-400">Last Updated</p>
|
||||||
<p class="font-semibold">${formattedDate}</p>
|
<p class="font-semibold">${formattedDate}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,10 +274,252 @@ 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">Hover or click to view show details</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showModelDetails(modelName) {
|
||||||
|
const detailsModal = document.getElementById("model-details-modal");
|
||||||
|
const detailsDialog = document.getElementById("model-details-dialog");
|
||||||
|
const detailsName = document.getElementById("model-details-name");
|
||||||
|
const detailsContent = document.getElementById("model-details-content");
|
||||||
|
|
||||||
|
if (!detailsModal || !detailsDialog || !detailsName || !detailsContent || !this.lastData.models) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showByModel = this.lastData.models.showByModel || {};
|
||||||
|
const showData = showByModel[modelName];
|
||||||
|
this.selectedModelName = modelName;
|
||||||
|
this.isModalOpen = true;
|
||||||
|
|
||||||
|
detailsModal.classList.remove("hidden");
|
||||||
|
detailsModal.classList.add("flex");
|
||||||
|
detailsDialog.classList.add("flex");
|
||||||
|
document.body.classList.add("overflow-hidden");
|
||||||
|
detailsName.textContent = modelName;
|
||||||
|
detailsModal.setAttribute("aria-hidden", "false");
|
||||||
|
|
||||||
|
if (!showData) {
|
||||||
|
detailsContent.innerHTML = `
|
||||||
|
<div class="flex items-center gap-2 text-gray-400 text-sm py-4">
|
||||||
|
<svg class="animate-spin w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||||
|
</svg>
|
||||||
|
Loading details…
|
||||||
|
</div>`;
|
||||||
|
this.loadModelShowDetails(modelName, detailsContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailsContent.innerHTML = this.buildAccordionHTML(showData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadModelShowDetails(modelName, detailsContent) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.buildApiUrl(`/api/v1/models/${encodeURIComponent(modelName)}/show`));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load show details for ${modelName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showData = await response.json();
|
||||||
|
if (!this.lastData.models) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.lastData.models.showByModel) {
|
||||||
|
this.lastData.models.showByModel = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastData.models.showByModel[modelName] = showData;
|
||||||
|
|
||||||
|
if (this.selectedModelName === modelName) {
|
||||||
|
detailsContent.innerHTML = this.buildAccordionHTML(showData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
if (this.selectedModelName === modelName) {
|
||||||
|
detailsContent.innerHTML = '<p class="text-gray-400 text-sm py-2">Show details are not available for this model.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accordion helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
buildAccordionHTML(showData) {
|
||||||
|
if (!showData || typeof showData !== "object") {
|
||||||
|
return '<p class="text-gray-400 text-sm py-2">No details available.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionOrder = ["details", "model_info", "parameters", "template", "modelfile", "license"];
|
||||||
|
const allKeys = Object.keys(showData);
|
||||||
|
const orderedKeys = [
|
||||||
|
...sectionOrder.filter(k => k in showData),
|
||||||
|
...allKeys.filter(k => !sectionOrder.includes(k))
|
||||||
|
];
|
||||||
|
|
||||||
|
let html = '<div class="space-y-2">';
|
||||||
|
|
||||||
|
orderedKeys.forEach((key, index) => {
|
||||||
|
const value = showData[key];
|
||||||
|
const isFirst = index === 0;
|
||||||
|
const contentId = `acc-${key.replace(/[^a-z0-9]/gi, "-")}`;
|
||||||
|
const label = this.formatAccordionLabel(key);
|
||||||
|
const body = this.renderAccordionBody(key, value);
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="border border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
<button type="button"
|
||||||
|
class="accordion-header w-full flex items-center justify-between px-4 py-2.5 bg-gray-800 hover:bg-gray-700 text-left transition-colors duration-150"
|
||||||
|
onclick="app.toggleAccordion('${contentId}', this)"
|
||||||
|
aria-expanded="${isFirst}">
|
||||||
|
<span class="font-semibold text-sm text-gray-200">${label}</span>
|
||||||
|
<svg class="accordion-chevron text-gray-400"
|
||||||
|
width="16" height="16" style="flex-shrink:0;transition:transform 0.2s;transform:${isFirst ? "rotate(180deg)" : "rotate(0deg)"}"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="${contentId}" class="accordion-content bg-gray-900 border-t border-gray-700 ${isFirst ? "" : "hidden"}">
|
||||||
|
<div class="px-4 py-3">${body}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += "</div>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatAccordionLabel(key) {
|
||||||
|
const labels = {
|
||||||
|
details: "Details",
|
||||||
|
model_info: "Model Info",
|
||||||
|
parameters: "Parameters",
|
||||||
|
template: "Template",
|
||||||
|
modelfile: "Modelfile",
|
||||||
|
license: "License"
|
||||||
|
};
|
||||||
|
const icons = {
|
||||||
|
details: "▦",
|
||||||
|
model_info: "🧠",
|
||||||
|
parameters: "⚙",
|
||||||
|
template: "📄",
|
||||||
|
modelfile: "📦",
|
||||||
|
license: "📜"
|
||||||
|
};
|
||||||
|
const icon = icons[key] || "▸";
|
||||||
|
const text = labels[key] || key.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
return `<span class="mr-2 text-base">${icon}</span>${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAccordionBody(key, value) {
|
||||||
|
if (key === "details" && value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return this.renderDetailsGrid(value);
|
||||||
|
}
|
||||||
|
if (key === "model_info" && value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return this.renderModelInfoTable(value);
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return `<pre class="text-xs text-gray-300 whitespace-pre-wrap font-mono leading-relaxed max-h-60 overflow-y-auto">${this.escapeHtml(value)}</pre>`;
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return this.renderKeyValueList(value);
|
||||||
|
}
|
||||||
|
return `<span class="text-sm text-gray-300">${this.escapeHtml(String(value))}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDetailsGrid(details) {
|
||||||
|
const labelMap = {
|
||||||
|
family: "Family",
|
||||||
|
families: "Families",
|
||||||
|
parameter_size: "Parameters",
|
||||||
|
quantization_level: "Quantization",
|
||||||
|
format: "Format",
|
||||||
|
parent_model: "Parent Model"
|
||||||
|
};
|
||||||
|
let html = '<div class="grid grid-cols-2 gap-x-6 gap-y-3">';
|
||||||
|
for (const [k, v] of Object.entries(details)) {
|
||||||
|
const label = labelMap[k] || k.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
const display = Array.isArray(v) ? v.join(", ") : String(v);
|
||||||
|
html += `
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs text-gray-500 uppercase tracking-wide">${this.escapeHtml(label)}</span>
|
||||||
|
<span class="text-sm text-gray-200 font-medium mt-0.5">${this.escapeHtml(display)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderModelInfoTable(modelInfo) {
|
||||||
|
let html = '<dl class="space-y-1.5">';
|
||||||
|
for (const [k, v] of Object.entries(modelInfo)) {
|
||||||
|
const display = typeof v === "object" ? JSON.stringify(v) : String(v);
|
||||||
|
html += `
|
||||||
|
<div class="flex gap-3 text-xs">
|
||||||
|
<dt class="text-gray-500 font-mono shrink-0 w-5/12 truncate" title="${this.escapeHtml(k)}">${this.escapeHtml(k)}</dt>
|
||||||
|
<dd class="text-gray-300 break-all">${this.escapeHtml(display)}</dd>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += "</dl>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderKeyValueList(obj) {
|
||||||
|
let html = '<dl class="space-y-1.5">';
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
const display = typeof v === "object" ? JSON.stringify(v) : String(v);
|
||||||
|
html += `
|
||||||
|
<div class="flex gap-3 text-xs">
|
||||||
|
<dt class="text-gray-500 shrink-0 w-1/3">${this.escapeHtml(k)}</dt>
|
||||||
|
<dd class="text-gray-300 break-all">${this.escapeHtml(display)}</dd>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += "</dl>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAccordion(contentId, btn) {
|
||||||
|
const content = document.getElementById(contentId);
|
||||||
|
if (!content) return;
|
||||||
|
const isHidden = content.classList.contains("hidden");
|
||||||
|
content.classList.toggle("hidden", !isHidden);
|
||||||
|
const chevron = btn.querySelector(".accordion-chevron");
|
||||||
|
if (chevron) {
|
||||||
|
chevron.style.transform = isHidden ? "rotate(180deg)" : "";
|
||||||
|
}
|
||||||
|
btn.setAttribute("aria-expanded", String(isHidden));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fine accordion helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
hideModelDetails() {
|
||||||
|
const detailsModal = document.getElementById("model-details-modal");
|
||||||
|
const detailsDialog = document.getElementById("model-details-dialog");
|
||||||
|
if (!detailsModal || detailsModal.classList.contains("hidden")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailsModal.classList.add("hidden");
|
||||||
|
detailsModal.classList.remove("flex");
|
||||||
|
detailsDialog?.classList.remove("flex");
|
||||||
|
document.body.classList.remove("overflow-hidden");
|
||||||
|
detailsModal.setAttribute("aria-hidden", "true");
|
||||||
|
this.isModalOpen = false;
|
||||||
|
this.selectedModelName = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Formattare bytes
|
// Formattare bytes
|
||||||
formatBytes(bytes) {
|
formatBytes(bytes) {
|
||||||
if (bytes === 0) return "0 B";
|
if (bytes === 0) return "0 B";
|
||||||
@@ -176,7 +532,7 @@ class LLMMonitorApp {
|
|||||||
// Formattare data
|
// Formattare data
|
||||||
formatDate(dateString) {
|
formatDate(dateString) {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString("it-IT", {
|
return date.toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -194,6 +550,10 @@ class LLMMonitorApp {
|
|||||||
|
|
||||||
// Chiedere sincronizzazione manuale al Worker
|
// Chiedere sincronizzazione manuale al Worker
|
||||||
requestSync() {
|
requestSync() {
|
||||||
|
if (!this.activeServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.worker) {
|
if (this.worker) {
|
||||||
this.worker.postMessage({ type: "SYNC_NOW" });
|
this.worker.postMessage({ type: "SYNC_NOW" });
|
||||||
} else {
|
} else {
|
||||||
@@ -203,12 +563,16 @@ class LLMMonitorApp {
|
|||||||
|
|
||||||
// Fallback: sincronizzazione nel main thread
|
// Fallback: sincronizzazione nel main thread
|
||||||
async syncDataInMainThread() {
|
async syncDataInMainThread() {
|
||||||
|
if (!this.activeServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/v1/health");
|
const response = await fetch(this.buildApiUrl("/api/v1/health"));
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const health = await response.json();
|
const health = await response.json();
|
||||||
this.lastData.health = health;
|
this.lastData.health = health;
|
||||||
localStorage.setItem("llm_monitor_health", JSON.stringify(health));
|
writeServerCache(this.activeServer.id, "health", health);
|
||||||
this.renderHealth(health);
|
this.renderHealth(health);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -216,24 +580,151 @@ class LLMMonitorApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/v1/models");
|
const response = await fetch(this.buildApiUrl("/api/v1/models"));
|
||||||
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(this.buildApiUrl(`/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));
|
const persistedModels = writeServerCache(this.activeServer.id, "models", modelsData);
|
||||||
this.renderModels(modelsData);
|
if (persistedModels) {
|
||||||
|
this.lastData.models = persistedModels;
|
||||||
|
}
|
||||||
|
this.updateCacheModeIndicator(this.lastData.models);
|
||||||
|
this.renderModels(this.lastData.models);
|
||||||
|
if (this.selectedModelName) {
|
||||||
|
this.showModelDetails(this.selectedModelName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Models loading error:", error);
|
console.error("Models loading error:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getStorageKey(suffix) {
|
||||||
|
return getServerStorageKey(this.activeServer.id, suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldSyncImmediately() {
|
||||||
|
const health = readServerCache(this.activeServer.id, "health");
|
||||||
|
const models = readServerCache(this.activeServer.id, "models");
|
||||||
|
|
||||||
|
if (!health || !models) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isCacheStale(this.getLatestCacheTimestamp());
|
||||||
|
}
|
||||||
|
|
||||||
|
getLatestCacheTimestamp() {
|
||||||
|
return getLatestServerCacheTimestamp(this.activeServer.id, ["health", "models", "running"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildApiUrl(path) {
|
||||||
|
const url = new URL(path, window.location.origin);
|
||||||
|
url.searchParams.set("host", this.activeServer.host);
|
||||||
|
return `${url.pathname}${url.search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateServerContextUI() {
|
||||||
|
const serverLabel = document.getElementById("active-server-label");
|
||||||
|
if (serverLabel) {
|
||||||
|
serverLabel.textContent = `Server: ${this.activeServer.name}`;
|
||||||
|
serverLabel.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningLink = document.getElementById("running-link");
|
||||||
|
if (runningLink) {
|
||||||
|
runningLink.href = buildServerUrl("/models-running", this.activeServer.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serversLink = document.getElementById("servers-link");
|
||||||
|
if (serversLink) {
|
||||||
|
serversLink.href = "/servers";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNoServerState() {
|
||||||
|
const container = document.getElementById("models-container");
|
||||||
|
const count = document.getElementById("models-count");
|
||||||
|
const totalSize = document.getElementById("total-size");
|
||||||
|
const statusIndicator = document.getElementById("status-indicator");
|
||||||
|
const statusText = document.getElementById("status-text");
|
||||||
|
const ollamaStatus = document.getElementById("ollama-status");
|
||||||
|
const cacheModeIndicator = document.getElementById("cache-mode-indicator");
|
||||||
|
|
||||||
|
if (count) count.textContent = "0";
|
||||||
|
if (totalSize) totalSize.textContent = "0 B";
|
||||||
|
if (statusIndicator) statusIndicator.className = "w-3 h-3 bg-yellow-500 rounded-full";
|
||||||
|
if (statusText) {
|
||||||
|
statusText.className = "text-sm text-yellow-300";
|
||||||
|
statusText.textContent = "No server selected";
|
||||||
|
}
|
||||||
|
if (ollamaStatus) {
|
||||||
|
ollamaStatus.innerHTML = "🟡 Not configured";
|
||||||
|
}
|
||||||
|
if (cacheModeIndicator) {
|
||||||
|
cacheModeIndicator.classList.add("hidden");
|
||||||
|
}
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-center py-10 text-gray-300">
|
||||||
|
<p class="text-lg font-semibold">No server selected</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-2">Configure or select a server from the control panel.</p>
|
||||||
|
<a href="/servers" class="inline-block mt-4 bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded">Open Servers Control Panel</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCacheModeIndicator(modelsData) {
|
||||||
|
const cacheModeIndicator = document.getElementById("cache-mode-indicator");
|
||||||
|
if (!cacheModeIndicator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDeferredShowDetails(modelsData)) {
|
||||||
|
cacheModeIndicator.classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheModeIndicator.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLoadingState() {
|
||||||
|
if (this.lastData.models) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById("models-container");
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<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">Loading models...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inizializzare l'app quando il DOM è pronto
|
// Inizializzare l'app quando il DOM è pronto
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
const API_BASE = "/api/v1";
|
const API_BASE = "/api/v1";
|
||||||
const REFRESH_INTERVAL = 30000; // 30 secondi
|
const REFRESH_INTERVAL = 30000; // 30 secondi
|
||||||
|
let activeServerId = null;
|
||||||
|
let activeHost = null;
|
||||||
|
let nextSyncTimeout = null;
|
||||||
|
|
||||||
// Formattare bytes
|
// Formattare bytes
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
@@ -17,14 +20,19 @@ function formatBytes(bytes) {
|
|||||||
|
|
||||||
// Recuperare health
|
// Recuperare health
|
||||||
async function fetchHealth() {
|
async function fetchHealth() {
|
||||||
|
if (!activeHost) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/health`);
|
const response = await fetch(buildApiUrl(`${API_BASE}/health`));
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
status: data.status,
|
status: data.status,
|
||||||
ollama_status: data.ollama_status,
|
ollama_status: data.ollama_status,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString(),
|
||||||
|
serverId: activeServerId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -35,9 +43,15 @@ async function fetchHealth() {
|
|||||||
|
|
||||||
// Recuperare modelli
|
// Recuperare modelli
|
||||||
async function fetchModels() {
|
async function fetchModels() {
|
||||||
|
if (!activeHost) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/models`);
|
const response = await fetch(buildApiUrl(`${API_BASE}/models`));
|
||||||
if (!response.ok) throw new Error("Errore nel caricamento");
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const models = data.models || [];
|
const models = data.models || [];
|
||||||
@@ -46,7 +60,8 @@ async function fetchModels() {
|
|||||||
models,
|
models,
|
||||||
total: models.length,
|
total: models.length,
|
||||||
totalSize: formatBytes(models.reduce((sum, m) => sum + m.size, 0)),
|
totalSize: formatBytes(models.reduce((sum, m) => sum + m.size, 0)),
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString(),
|
||||||
|
serverId: activeServerId
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading models:", error);
|
console.error("Error loading models:", error);
|
||||||
@@ -54,37 +69,138 @@ async function fetchModels() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sincronizzare i dati
|
// Recuperare dettagli show per un modello
|
||||||
async function syncData() {
|
async function fetchModelShow(modelName) {
|
||||||
const health = await fetchHealth();
|
try {
|
||||||
const modelsData = await fetchModels();
|
const response = await fetch(buildApiUrl(`${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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Salvare in localStorage
|
// Recuperare dettagli show per tutti i modelli
|
||||||
if (health) {
|
async function fetchAllModelsShow(models) {
|
||||||
localStorage.setItem("llm_monitor_health", JSON.stringify(health));
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRunningModels() {
|
||||||
|
if (!activeHost) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelsData) {
|
try {
|
||||||
localStorage.setItem("llm_monitor_models", JSON.stringify(modelsData));
|
const response = await fetch(buildApiUrl(`${API_BASE}/models/running`));
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
models: data.models || [],
|
||||||
|
total: data.total || (data.models || []).length,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
serverId: activeServerId
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading running models:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sincronizzare i dati
|
||||||
|
async function syncData() {
|
||||||
|
if (!activeHost) {
|
||||||
|
self.postMessage({
|
||||||
|
type: "DATA_UPDATED",
|
||||||
|
health: null,
|
||||||
|
modelsData: null,
|
||||||
|
serverId: activeServerId
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = await fetchHealth();
|
||||||
|
const isOnline = health?.ollama_status === "online";
|
||||||
|
const modelsData = isOnline ? await fetchModels() : null;
|
||||||
|
const runningData = isOnline ? await fetchRunningModels() : null;
|
||||||
|
|
||||||
|
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)
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: "DATA_UPDATED",
|
type: "DATA_UPDATED",
|
||||||
health,
|
health,
|
||||||
modelsData
|
modelsData,
|
||||||
|
runningData,
|
||||||
|
serverId: activeServerId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eseguire la sincronizzazione iniziale
|
function buildApiUrl(path) {
|
||||||
syncData();
|
const url = new URL(path, self.location.origin);
|
||||||
|
url.searchParams.set("host", activeHost);
|
||||||
|
return `${url.pathname}${url.search}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Pianificare aggiornamenti periodici
|
function clearNextSync() {
|
||||||
setInterval(syncData, REFRESH_INTERVAL);
|
if (nextSyncTimeout) {
|
||||||
|
clearTimeout(nextSyncTimeout);
|
||||||
|
nextSyncTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNextSync(lastSyncTimestamp = 0) {
|
||||||
|
clearNextSync();
|
||||||
|
|
||||||
|
const ageMs = lastSyncTimestamp ? Math.max(0, Date.now() - lastSyncTimestamp) : REFRESH_INTERVAL;
|
||||||
|
const delayMs = Math.max(0, REFRESH_INTERVAL - ageMs);
|
||||||
|
|
||||||
|
nextSyncTimeout = setTimeout(async () => {
|
||||||
|
await syncData();
|
||||||
|
scheduleNextSync(Date.now());
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
// Gestire messaggi dal main thread
|
// Gestire messaggi dal main thread
|
||||||
self.onmessage = (event) => {
|
self.onmessage = (event) => {
|
||||||
|
if (event.data.type === "SET_SERVER") {
|
||||||
|
activeServerId = event.data.serverId || null;
|
||||||
|
activeHost = event.data.host || null;
|
||||||
|
const lastSyncTimestamp = Number(event.data.lastSyncTimestamp || 0);
|
||||||
|
|
||||||
|
if (event.data.syncImmediately) {
|
||||||
|
syncData().finally(() => scheduleNextSync(Date.now()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNextSync(lastSyncTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
if (event.data.type === "SYNC_NOW") {
|
if (event.data.type === "SYNC_NOW") {
|
||||||
syncData();
|
syncData().finally(() => scheduleNextSync(Date.now()));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
class RunningModelsPage {
|
||||||
|
constructor() {
|
||||||
|
this.activeServer = getActiveServer();
|
||||||
|
this.worker = null;
|
||||||
|
this.lastRunningData = null;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.updateServerContextUI();
|
||||||
|
|
||||||
|
if (!this.activeServer) {
|
||||||
|
this.renderNoServerState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadFromLocalStorage();
|
||||||
|
|
||||||
|
if (typeof Worker !== "undefined") {
|
||||||
|
this.worker = new Worker("/static/js/data-sync.worker.js");
|
||||||
|
this.worker.onmessage = (event) => this.handleWorkerMessage(event);
|
||||||
|
this.worker.onerror = (error) => {
|
||||||
|
console.error("Worker error:", error);
|
||||||
|
this.loadRunningModels(true);
|
||||||
|
};
|
||||||
|
const shouldSyncImmediately = this.shouldSyncImmediately();
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "SET_SERVER",
|
||||||
|
serverId: this.activeServer.id,
|
||||||
|
host: this.activeServer.host,
|
||||||
|
syncImmediately: shouldSyncImmediately,
|
||||||
|
lastSyncTimestamp: this.getLatestCacheTimestamp()
|
||||||
|
});
|
||||||
|
if (shouldSyncImmediately && !this.lastRunningData) {
|
||||||
|
this.renderLoadingState();
|
||||||
|
}
|
||||||
|
} else if (this.shouldSyncImmediately()) {
|
||||||
|
this.loadRunningModels(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("refresh-btn")?.addEventListener("click", () => {
|
||||||
|
if (this.worker) {
|
||||||
|
this.worker.postMessage({ type: "SYNC_NOW" });
|
||||||
|
} else {
|
||||||
|
this.loadRunningModels(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFromLocalStorage() {
|
||||||
|
const runningData = readServerCache(this.activeServer.id, "running");
|
||||||
|
if (!runningData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastRunningData = runningData;
|
||||||
|
this.renderStats(runningData.models || [], runningData.timestamp);
|
||||||
|
this.renderRunningModels(runningData.models || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWorkerMessage(event) {
|
||||||
|
const { type, health, modelsData, runningData, serverId } = event.data;
|
||||||
|
|
||||||
|
if (type !== "DATA_UPDATED") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverId && serverId !== this.activeServer.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (health) {
|
||||||
|
try {
|
||||||
|
writeServerCache(this.activeServer.id, "health", health);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Cannot persist health in localStorage:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelsData) {
|
||||||
|
try {
|
||||||
|
writeServerCache(this.activeServer.id, "models", modelsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Cannot persist models in localStorage:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!runningData) {
|
||||||
|
if (!this.lastRunningData) {
|
||||||
|
this.renderStats([], health?.timestamp || null);
|
||||||
|
this.renderRunningUnavailable(health);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastRunningData = runningData;
|
||||||
|
try {
|
||||||
|
writeServerCache(this.activeServer.id, "running", runningData);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Cannot persist running models in localStorage:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderStats(runningData.models || [], runningData.timestamp);
|
||||||
|
this.renderRunningModels(runningData.models || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRunningModels(forceNetwork = false) {
|
||||||
|
const container = document.getElementById("running-models");
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceNetwork && this.lastRunningData) {
|
||||||
|
this.renderStats(this.lastRunningData.models || [], this.lastRunningData.timestamp);
|
||||||
|
this.renderRunningModels(this.lastRunningData.models || []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderLoadingState();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.buildApiUrl("/api/v1/models/running"));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to load running models");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const models = data.models || [];
|
||||||
|
const runningData = {
|
||||||
|
models,
|
||||||
|
total: data.total || models.length,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
serverId: this.activeServer.id
|
||||||
|
};
|
||||||
|
|
||||||
|
this.lastRunningData = runningData;
|
||||||
|
writeServerCache(this.activeServer.id, "running", runningData);
|
||||||
|
this.renderStats(models, runningData.timestamp);
|
||||||
|
this.renderRunningModels(models);
|
||||||
|
} catch (error) {
|
||||||
|
this.renderRunningUnavailable(null);
|
||||||
|
this.renderStats([], null);
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldSyncImmediately() {
|
||||||
|
const running = readServerCache(this.activeServer.id, "running");
|
||||||
|
if (!running) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isCacheStale(this.getLatestCacheTimestamp());
|
||||||
|
}
|
||||||
|
|
||||||
|
getLatestCacheTimestamp() {
|
||||||
|
return getLatestServerCacheTimestamp(this.activeServer.id, ["health", "models", "running"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildApiUrl(path) {
|
||||||
|
const url = new URL(path, window.location.origin);
|
||||||
|
url.searchParams.set("host", this.activeServer.host);
|
||||||
|
return `${url.pathname}${url.search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateServerContextUI() {
|
||||||
|
if (!this.activeServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverLabel = document.getElementById("active-server-label");
|
||||||
|
if (serverLabel) {
|
||||||
|
serverLabel.textContent = `Server: ${this.activeServer.name}`;
|
||||||
|
serverLabel.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableLink = document.getElementById("available-link");
|
||||||
|
if (availableLink) {
|
||||||
|
availableLink.href = buildServerUrl("/models-available", this.activeServer.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serversLink = document.getElementById("servers-link");
|
||||||
|
if (serversLink) {
|
||||||
|
serversLink.href = "/servers";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNoServerState() {
|
||||||
|
const container = document.getElementById("running-models");
|
||||||
|
const runningCountEl = document.getElementById("running-count");
|
||||||
|
const vramTotalEl = document.getElementById("vram-total");
|
||||||
|
const lastRefreshEl = document.getElementById("last-refresh");
|
||||||
|
|
||||||
|
if (runningCountEl) runningCountEl.textContent = "0";
|
||||||
|
if (vramTotalEl) vramTotalEl.textContent = "0 B";
|
||||||
|
if (lastRefreshEl) lastRefreshEl.textContent = "-";
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-center py-10 text-gray-300">
|
||||||
|
<p class="text-lg font-semibold">No server selected</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-2">Select a server in the control panel to load ollama ps data.</p>
|
||||||
|
<a href="/servers" class="inline-block mt-4 bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded">Open Servers Control Panel</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStats(models, timestamp = null) {
|
||||||
|
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 = timestamp ? this.formatDateTime(timestamp) : "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>No models are currently loaded in memory.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = models
|
||||||
|
.map((model) => this.renderModelCard(model))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRunningUnavailable(health = null) {
|
||||||
|
const container = document.getElementById("running-models");
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOffline = health?.ollama_status === "offline";
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-center py-8 ${isOffline ? "text-yellow-300" : "text-red-400"}">
|
||||||
|
<p>${isOffline ? "Selected server is offline." : "Failed to load ollama ps output."}</p>
|
||||||
|
<p class="text-sm text-gray-400 mt-2">Data will refresh automatically when the server becomes reachable.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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">Ready</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">Model Size</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 Used</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">Unload Time</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("en-US", {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLoadingState() {
|
||||||
|
const container = document.getElementById("running-models");
|
||||||
|
if (!container || this.lastRunningData) {
|
||||||
|
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">Refreshing data...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
window.runningModelsPage = new RunningModelsPage();
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
(() => {
|
||||||
|
if (!("serviceWorker" in navigator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("load", async () => {
|
||||||
|
try {
|
||||||
|
await navigator.serviceWorker.register("/service-worker.js", { scope: "/" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Service worker registration failed:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
const SERVER_STORAGE_KEY = "llm_monitor_servers";
|
||||||
|
const ACTIVE_SERVER_KEY = "llm_monitor_active_server";
|
||||||
|
const DATA_REFRESH_INTERVAL_MS = 30000;
|
||||||
|
const SERVER_CACHE_SUFFIXES = ["health", "models", "running"];
|
||||||
|
|
||||||
|
function normalizeHost(host) {
|
||||||
|
if (!host) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = host.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadServers() {
|
||||||
|
const raw = localStorage.getItem(SERVER_STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
.map((item) => ({
|
||||||
|
id: String(item.id || ""),
|
||||||
|
name: String(item.name || "").trim(),
|
||||||
|
host: normalizeHost(item.host || "")
|
||||||
|
}))
|
||||||
|
.filter((item) => item.id && item.name && item.host);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveServers(servers) {
|
||||||
|
localStorage.setItem(SERVER_STORAGE_KEY, JSON.stringify(servers));
|
||||||
|
cleanupOrphanedServerCaches(servers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateServerId() {
|
||||||
|
return `srv_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveServerId() {
|
||||||
|
return localStorage.getItem(ACTIVE_SERVER_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveServerId(serverId) {
|
||||||
|
localStorage.setItem(ACTIVE_SERVER_KEY, serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerById(serverId) {
|
||||||
|
return loadServers().find((server) => server.id === serverId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerIdFromQuery() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return params.get("server") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveServer() {
|
||||||
|
const queryServerId = getServerIdFromQuery();
|
||||||
|
if (queryServerId) {
|
||||||
|
const fromQuery = getServerById(queryServerId);
|
||||||
|
if (fromQuery) {
|
||||||
|
setActiveServerId(fromQuery.id);
|
||||||
|
return fromQuery;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeServerId = getActiveServerId();
|
||||||
|
if (activeServerId) {
|
||||||
|
const activeServer = getServerById(activeServerId);
|
||||||
|
if (activeServer) {
|
||||||
|
return activeServer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const servers = loadServers();
|
||||||
|
if (servers.length > 0) {
|
||||||
|
setActiveServerId(servers[0].id);
|
||||||
|
return servers[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildServerUrl(path, serverId) {
|
||||||
|
const url = new URL(path, window.location.origin);
|
||||||
|
if (serverId) {
|
||||||
|
url.searchParams.set("server", serverId);
|
||||||
|
}
|
||||||
|
return `${url.pathname}${url.search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerStorageKey(serverId, suffix) {
|
||||||
|
return `llm_monitor_${suffix}_${serverId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readServerCache(serverId, suffix) {
|
||||||
|
if (!serverId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = localStorage.getItem(getServerStorageKey(serverId, suffix));
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeServerCache(serverId, suffix, value) {
|
||||||
|
if (!serverId) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = getServerStorageKey(serverId, suffix);
|
||||||
|
const candidates = [value];
|
||||||
|
|
||||||
|
cleanupOrphanedServerCaches();
|
||||||
|
|
||||||
|
if (suffix === "models") {
|
||||||
|
candidates.push(createSlimModelsCache(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(candidate));
|
||||||
|
return candidate;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isQuotaExceededError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: free stale server caches and retry with the smallest payload.
|
||||||
|
cleanupOrphanedServerCaches(loadServers());
|
||||||
|
|
||||||
|
if (suffix === "models") {
|
||||||
|
const slimValue = createSlimModelsCache(value);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(slimValue));
|
||||||
|
return slimValue;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isQuotaExceededError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.warn(`Cache quota exceeded for ${storageKey}; using in-memory models data only.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`Cache quota exceeded for ${storageKey}; skipping persistence for this payload.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSlimModelsCache(value) {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slimValue = { ...value };
|
||||||
|
if (slimValue.showByModel) {
|
||||||
|
delete slimValue.showByModel;
|
||||||
|
slimValue.showDetailsDeferred = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return slimValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQuotaExceededError(error) {
|
||||||
|
return error instanceof DOMException && (
|
||||||
|
error.code === 22 ||
|
||||||
|
error.code === 1014 ||
|
||||||
|
error.name === "QuotaExceededError" ||
|
||||||
|
error.name === "NS_ERROR_DOM_QUOTA_REACHED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupOrphanedServerCaches(servers = loadServers()) {
|
||||||
|
const validServerIds = new Set(servers.map((server) => server.id));
|
||||||
|
const keysToRemove = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < localStorage.length; index += 1) {
|
||||||
|
const key = localStorage.key(index);
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const suffix of SERVER_CACHE_SUFFIXES) {
|
||||||
|
const prefix = `llm_monitor_${suffix}_`;
|
||||||
|
if (!key.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverId = key.slice(prefix.length);
|
||||||
|
if (!validServerIds.has(serverId)) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearServerCaches(serverId) {
|
||||||
|
if (!serverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SERVER_CACHE_SUFFIXES.forEach((suffix) => {
|
||||||
|
localStorage.removeItem(getServerStorageKey(serverId, suffix));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCacheTimestamp(cacheValue) {
|
||||||
|
if (!cacheValue || !cacheValue.timestamp) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Date.parse(cacheValue.timestamp);
|
||||||
|
return Number.isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestServerCacheTimestamp(serverId, suffixes) {
|
||||||
|
return suffixes.reduce((latest, suffix) => {
|
||||||
|
const value = readServerCache(serverId, suffix);
|
||||||
|
return Math.max(latest, getCacheTimestamp(value));
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCacheStale(timestamp, maxAgeMs = DATA_REFRESH_INTERVAL_MS) {
|
||||||
|
if (!timestamp) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Date.now() - timestamp) >= maxAgeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDeferredShowDetails(cacheValue) {
|
||||||
|
return Boolean(cacheValue && cacheValue.showDetailsDeferred);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
class ServersPage {
|
||||||
|
constructor() {
|
||||||
|
this.form = document.getElementById("server-form");
|
||||||
|
this.serverIdInput = document.getElementById("server-id");
|
||||||
|
this.serverNameInput = document.getElementById("server-name");
|
||||||
|
this.serverHostInput = document.getElementById("server-host");
|
||||||
|
this.clearFormBtn = document.getElementById("clear-form-btn");
|
||||||
|
this.serversList = document.getElementById("servers-list");
|
||||||
|
this.serversCount = document.getElementById("servers-count");
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.form?.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
this.saveServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clearFormBtn?.addEventListener("click", () => this.resetForm());
|
||||||
|
this.renderServers();
|
||||||
|
}
|
||||||
|
|
||||||
|
saveServer() {
|
||||||
|
const name = this.serverNameInput?.value.trim() || "";
|
||||||
|
const host = normalizeHost(this.serverHostInput?.value || "");
|
||||||
|
|
||||||
|
if (!name || !host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingId = this.serverIdInput?.value || "";
|
||||||
|
const servers = loadServers();
|
||||||
|
|
||||||
|
if (existingId) {
|
||||||
|
const index = servers.findIndex((server) => server.id === existingId);
|
||||||
|
if (index >= 0) {
|
||||||
|
servers[index] = { ...servers[index], name, host };
|
||||||
|
}
|
||||||
|
saveServers(servers);
|
||||||
|
setActiveServerId(existingId);
|
||||||
|
} else {
|
||||||
|
const newServer = {
|
||||||
|
id: generateServerId(),
|
||||||
|
name,
|
||||||
|
host
|
||||||
|
};
|
||||||
|
servers.push(newServer);
|
||||||
|
saveServers(servers);
|
||||||
|
setActiveServerId(newServer.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resetForm();
|
||||||
|
this.renderServers();
|
||||||
|
}
|
||||||
|
|
||||||
|
editServer(serverId) {
|
||||||
|
const server = getServerById(serverId);
|
||||||
|
if (!server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serverIdInput.value = server.id;
|
||||||
|
this.serverNameInput.value = server.name;
|
||||||
|
this.serverHostInput.value = server.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteServer(serverId) {
|
||||||
|
const servers = loadServers().filter((server) => server.id !== serverId);
|
||||||
|
saveServers(servers);
|
||||||
|
clearServerCaches(serverId);
|
||||||
|
|
||||||
|
const activeServerId = getActiveServerId();
|
||||||
|
if (activeServerId === serverId) {
|
||||||
|
if (servers.length > 0) {
|
||||||
|
setActiveServerId(servers[0].id);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(ACTIVE_SERVER_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderServers();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectServer(serverId) {
|
||||||
|
setActiveServerId(serverId);
|
||||||
|
this.renderServers();
|
||||||
|
}
|
||||||
|
|
||||||
|
openAvailable(serverId) {
|
||||||
|
window.location.href = buildServerUrl("/models-available", serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
openRunning(serverId) {
|
||||||
|
window.location.href = buildServerUrl("/models-running", serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.serverIdInput.value = "";
|
||||||
|
this.serverNameInput.value = "";
|
||||||
|
this.serverHostInput.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
renderServers() {
|
||||||
|
const servers = loadServers();
|
||||||
|
const activeServerId = getActiveServerId();
|
||||||
|
|
||||||
|
if (this.serversCount) {
|
||||||
|
this.serversCount.textContent = `${servers.length} server${servers.length === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.serversList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (servers.length === 0) {
|
||||||
|
this.serversList.innerHTML = `
|
||||||
|
<div class="text-center py-10 text-gray-400 border border-dashed border-gray-600 rounded-lg">
|
||||||
|
No servers configured yet. Add your first Ollama endpoint in the control panel.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serversList.innerHTML = servers
|
||||||
|
.map((server) => {
|
||||||
|
const isActive = server.id === activeServerId;
|
||||||
|
return `
|
||||||
|
<div class="bg-gray-700 border ${isActive ? "border-purple-500" : "border-gray-600"} rounded-lg p-4">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold">${this.escapeHtml(server.name)}</h3>
|
||||||
|
<p class="text-xs text-gray-300 mt-1">${this.escapeHtml(server.host)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button data-action="select" data-server-id="${server.id}" class="bg-gray-800 hover:bg-gray-900 px-3 py-2 rounded text-xs">${isActive ? "Selected" : "Select"}</button>
|
||||||
|
<button data-action="available" data-server-id="${server.id}" class="bg-blue-700 hover:bg-blue-800 px-3 py-2 rounded text-xs">Available</button>
|
||||||
|
<button data-action="running" data-server-id="${server.id}" class="bg-green-700 hover:bg-green-800 px-3 py-2 rounded text-xs">Running</button>
|
||||||
|
<button data-action="edit" data-server-id="${server.id}" class="bg-amber-700 hover:bg-amber-800 px-3 py-2 rounded text-xs">Edit</button>
|
||||||
|
<button data-action="delete" data-server-id="${server.id}" class="bg-red-700 hover:bg-red-800 px-3 py-2 rounded text-xs">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
this.bindServerActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindServerActions() {
|
||||||
|
this.serversList.querySelectorAll("button[data-action]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const action = button.getAttribute("data-action");
|
||||||
|
const serverId = button.getAttribute("data-server-id") || "";
|
||||||
|
|
||||||
|
if (!serverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "select") this.selectServer(serverId);
|
||||||
|
if (action === "available") this.openAvailable(serverId);
|
||||||
|
if (action === "running") this.openRunning(serverId);
|
||||||
|
if (action === "edit") this.editServer(serverId);
|
||||||
|
if (action === "delete") this.deleteServer(serverId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
window.serversPage = new ServersPage();
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const CACHE_NAME = "llm-monitor-v3";
|
||||||
|
const APP_SHELL = [
|
||||||
|
"/",
|
||||||
|
"/servers",
|
||||||
|
"/models-running",
|
||||||
|
"/models-available",
|
||||||
|
"/static/css/output.css",
|
||||||
|
"/static/js/server-config.js",
|
||||||
|
"/static/js/app.js",
|
||||||
|
"/static/js/servers.js",
|
||||||
|
"/static/js/models-running.js",
|
||||||
|
"/static/js/data-sync.worker.js",
|
||||||
|
"/static/js/pwa-register.js",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/favicon.ico"
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
if (event.request.method !== "GET") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUrl = new URL(event.request.url);
|
||||||
|
const isApiRequest = requestUrl.pathname.startsWith("/api/");
|
||||||
|
|
||||||
|
if (isApiRequest) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request).catch(() =>
|
||||||
|
new Response(JSON.stringify({ detail: "Offline" }), {
|
||||||
|
status: 503,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then((cached) => {
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
if (!response || response.status !== 200 || response.type !== "basic") {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match("/servers"));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "LLM Monitor",
|
||||||
|
"short_name": "LLM Monitor",
|
||||||
|
"description": "Monitor available and running Ollama models.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#111827",
|
||||||
|
"theme_color": "#111827",
|
||||||
|
"lang": "en",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon.ico",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="it">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>LLM Monitor - Dashboard Ollama</title>
|
<title>LLM Monitor - Ollama Dashboard</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
<meta name="theme-color" content="#111827">
|
||||||
|
<meta name="application-name" content="LLM Monitor">
|
||||||
|
<meta name="description" content="Monitor available and running Ollama models.">
|
||||||
|
<!-- Tailwind CSS (compiled for production) -->
|
||||||
|
<link rel="stylesheet" href="/static/css/output.css">
|
||||||
<style>
|
<style>
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
@@ -12,6 +18,24 @@
|
|||||||
.animate-spin {
|
.animate-spin {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
.modal-body {
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 10px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #8b5cf6 #1f2937;
|
||||||
|
}
|
||||||
|
.modal-body::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
.modal-body::-webkit-scrollbar-track {
|
||||||
|
background: #1f2937;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.modal-body::-webkit-scrollbar-thumb {
|
||||||
|
background: #8b5cf6;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-900 text-white">
|
<body class="bg-gray-900 text-white">
|
||||||
@@ -27,9 +51,12 @@
|
|||||||
<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 id="servers-link" href="/servers" class="text-sm bg-gray-700 hover:bg-gray-600 px-3 py-2 rounded-lg transition">Servers</a>
|
||||||
|
<a id="running-link" href="/models-running" class="text-sm bg-gray-700 hover:bg-gray-600 px-3 py-2 rounded-lg transition">Running Models</a>
|
||||||
|
<span id="active-server-label" class="hidden text-xs text-gray-300 bg-gray-700 px-3 py-2 rounded-lg"></span>
|
||||||
<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">Checking...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,15 +69,15 @@
|
|||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-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="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
<div class="text-gray-400 text-sm font-medium">Modelli Caricati</div>
|
<div class="text-gray-400 text-sm font-medium">Loaded Models</div>
|
||||||
<div id="models-count" class="text-4xl font-bold mt-2">-</div>
|
<div id="models-count" class="text-4xl font-bold mt-2">-</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
<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 class="text-gray-400 text-sm font-medium">Total Size</div>
|
||||||
<div id="total-size" class="text-4xl font-bold mt-2">-</div>
|
<div id="total-size" class="text-4xl font-bold mt-2">-</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
<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 class="text-gray-400 text-sm font-medium">Ollama Status</div>
|
||||||
<div id="ollama-status" class="text-4xl font-bold mt-2">-</div>
|
<div id="ollama-status" class="text-4xl font-bold mt-2">-</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,25 +85,29 @@
|
|||||||
<!-- Models Section -->
|
<!-- Models Section -->
|
||||||
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
|
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-bold">Modelli Disponibili</h2>
|
<div>
|
||||||
|
<h2 class="text-xl font-bold">Available Models</h2>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">Hover or click a card to open the details modal.</p>
|
||||||
|
<p id="cache-mode-indicator" class="hidden text-xs text-amber-300 mt-2">Model details are loaded on demand to keep device storage usage low.</p>
|
||||||
|
</div>
|
||||||
<button id="refresh-btn" class="bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded-lg text-sm font-medium transition">
|
<button id="refresh-btn" class="bg-purple-600 hover:bg-purple-700 px-4 py-2 rounded-lg text-sm font-medium transition">
|
||||||
🔄 Aggiorna
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Models List -->
|
<!-- Models List -->
|
||||||
<div id="models-container" class="space-y-4">
|
<div id="models-container" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
<div class="text-center py-8">
|
<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>
|
<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>
|
<p class="text-gray-400 mt-4">Loading models...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">API Documentation</h3>
|
||||||
<p class="text-gray-300 mb-4">La API è documentata e testabile direttamente da:</p>
|
<p class="text-gray-300 mb-4">The API is documented and testable from:</p>
|
||||||
<div class="flex gap-3 flex-wrap">
|
<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">
|
<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
|
Swagger UI
|
||||||
@@ -92,11 +123,35 @@
|
|||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="bg-gray-800 border-t border-gray-700 mt-12">
|
<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">
|
<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>
|
<p>LLM Monitor v1.0.0 • Built by <a href="https://lucasacchi.net" target="_blank" class="text-purple-400 hover:text-purple-300">LucaSacchi.Net</a></p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Model Show Details Modal -->
|
||||||
|
<div id="model-details-modal" class="hidden fixed inset-0 z-50 items-center justify-center" aria-hidden="true">
|
||||||
|
<div id="model-details-backdrop" class="absolute inset-0 bg-black/70"></div>
|
||||||
|
<div id="model-details-dialog" class="relative w-full min-h-screen items-center justify-center p-4">
|
||||||
|
<div id="model-details-section" class="w-full max-w-4xl bg-gray-800 rounded-lg border border-gray-700 p-6 shadow-xl">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold">Model Details</h3>
|
||||||
|
<span id="model-details-name" class="text-sm text-purple-300 font-medium"></span>
|
||||||
|
</div>
|
||||||
|
<button id="model-details-close" type="button" class="text-gray-300 hover:text-white text-2xl leading-none px-2" aria-label="Close modal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body overflow-y-auto max-h-[75vh]">
|
||||||
|
<div id="model-details-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LLM Monitor Application -->
|
||||||
|
<!-- Web Worker for background data sync -->
|
||||||
|
<!-- localStorage for client-side persistence -->
|
||||||
|
<script src="/static/js/server-config.js"></script>
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
|
<script src="/static/js/pwa-register.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LLM Monitor - Running Models</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
<meta name="theme-color" content="#111827">
|
||||||
|
<meta name="application-name" content="LLM Monitor">
|
||||||
|
<meta name="description" content="View models currently loaded in Ollama memory.">
|
||||||
|
<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">Running Models</h1>
|
||||||
|
<p class="text-xs text-gray-400">Dedicated view for ollama ps output</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a id="servers-link" href="/servers" class="text-sm bg-gray-700 hover:bg-gray-600 px-3 py-2 rounded-lg transition">Servers</a>
|
||||||
|
<a id="available-link" href="/models-available" class="text-sm bg-gray-700 hover:bg-gray-600 px-3 py-2 rounded-lg transition">Available Models</a>
|
||||||
|
<span id="active-server-label" class="hidden text-xs text-gray-300 bg-gray-700 px-3 py-2 rounded-lg"></span>
|
||||||
|
<button id="refresh-btn" class="text-sm bg-purple-600 hover:bg-purple-700 px-3 py-2 rounded-lg transition">Refresh</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">Loaded in Memory</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">Estimated Total VRAM</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">Last 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">Ollama PS Output</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">Loading running models...</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 • Models currently loaded in memory (ollama ps)</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/server-config.js"></script>
|
||||||
|
<script src="/static/js/models-running.js"></script>
|
||||||
|
<script src="/static/js/pwa-register.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LLM Monitor - Servers</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
<meta name="theme-color" content="#111827">
|
||||||
|
<meta name="application-name" content="LLM Monitor">
|
||||||
|
<meta name="description" content="Manage Ollama servers and open detailed dashboards.">
|
||||||
|
<link rel="stylesheet" href="/static/css/output.css">
|
||||||
|
</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">LLM Monitor Servers</h1>
|
||||||
|
<p class="text-xs text-gray-400">Configure Ollama endpoints and open per-server dashboards</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/models-running" class="text-sm bg-gray-700 hover:bg-gray-600 px-3 py-2 rounded-lg transition">Running Models</a>
|
||||||
|
<a href="/models-available" class="text-sm bg-gray-700 hover:bg-gray-600 px-3 py-2 rounded-lg transition">Available Models</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 py-8 grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
<section class="xl:col-span-2 bg-gray-800 rounded-lg border border-gray-700 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-xl font-bold">Configured Servers</h2>
|
||||||
|
<span id="servers-count" class="text-sm text-gray-400">0 servers</span>
|
||||||
|
</div>
|
||||||
|
<div id="servers-list" class="space-y-3"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="bg-gray-800 rounded-lg border border-gray-700 p-6">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Control Panel</h2>
|
||||||
|
<form id="server-form" class="space-y-4">
|
||||||
|
<input id="server-id" type="hidden">
|
||||||
|
<div>
|
||||||
|
<label for="server-name" class="text-sm text-gray-300 block mb-1">Server Name</label>
|
||||||
|
<input id="server-name" type="text" required placeholder="Production Ollama" class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="server-host" class="text-sm text-gray-300 block mb-1">Ollama URL</label>
|
||||||
|
<input id="server-host" type="url" required placeholder="http://192.168.1.50:11434" class="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="save-server-btn" type="submit" class="flex-1 bg-purple-600 hover:bg-purple-700 rounded px-3 py-2 text-sm font-semibold transition">Save Server</button>
|
||||||
|
<button id="clear-form-btn" type="button" class="bg-gray-700 hover:bg-gray-600 rounded px-3 py-2 text-sm transition">Clear</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="text-xs text-gray-400 mt-4">All server profiles are saved to localStorage on this device.</p>
|
||||||
|
</section>
|
||||||
|
</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 • Multi-server PWA control panel</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/server-config.js"></script>
|
||||||
|
<script src="/static/js/servers.js"></script>
|
||||||
|
<script src="/static/js/pwa-register.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+2
-2
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# LLM Monitor Dashboard
|
# LLM Monitor Dashboard
|
||||||
llm-monitor:
|
llm-monitor:
|
||||||
@@ -24,6 +22,8 @@ services:
|
|||||||
|
|
||||||
# Istruzioni di avvio:
|
# Istruzioni di avvio:
|
||||||
# docker compose up -d # Avvia i servizi
|
# docker compose up -d # Avvia i servizi
|
||||||
|
# docker compose build --no-cache # Rebuild completo (consigliato se output.css e vuoto o UI rotta)
|
||||||
|
# docker exec llm-monitor-app wc -l /app/app/web/static/css/output.css # Verifica CSS compilato
|
||||||
# docker compose logs -f # Visualizza i log
|
# docker compose logs -f # Visualizza i log
|
||||||
# docker compose down # Ferma i servizi
|
# docker compose down # Ferma i servizi
|
||||||
# docker compose restart # Riavvia i servizi
|
# docker compose restart # Riavvia i servizi
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
# Development Setup - LLM Monitor
|
||||||
|
|
||||||
|
## 🛠️ Setup Locale
|
||||||
|
|
||||||
|
### 1. Installare Dipendenze Python
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Installare Dipendenze Node (per Tailwind CSS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Compilare Tailwind CSS
|
||||||
|
|
||||||
|
#### Modalità Development (watch mode)
|
||||||
|
```bash
|
||||||
|
npm run tailwind:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Questo comando:
|
||||||
|
- Compila `app/web/static/css/input.css` in `app/web/static/css/output.css`
|
||||||
|
- Rimane in watch mode per compilare automaticamente al salvataggio
|
||||||
|
- Legge la configurazione da `tailwind.config.js`
|
||||||
|
|
||||||
|
#### Modalità Production (minified)
|
||||||
|
```bash
|
||||||
|
npm run tailwind:build
|
||||||
|
```
|
||||||
|
|
||||||
|
Questo comando:
|
||||||
|
- Compila e minifica il CSS
|
||||||
|
- Ottimizzato per produzione
|
||||||
|
- Usato durante il build Docker
|
||||||
|
|
||||||
|
### 4. Avviare l'Applicazione
|
||||||
|
|
||||||
|
In una finestra di terminale (con `npm run tailwind:dev` in watch):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
python3 -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
O usar il comando Makefile:
|
||||||
|
```bash
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Accedi a: http://localhost:8000
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 Workflow di Sviluppo
|
||||||
|
|
||||||
|
### Sviluppare il Frontend
|
||||||
|
|
||||||
|
1. **Terminal 1 - Tailwind Watcher:**
|
||||||
|
```bash
|
||||||
|
npm run tailwind:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Terminal 2 - FastAPI Dev Server:**
|
||||||
|
```bash
|
||||||
|
source venv/bin/activate
|
||||||
|
uvicorn main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Modificare i file:**
|
||||||
|
- HTML: `app/web/templates/index.html`, `servers.html`, `models_running.html`
|
||||||
|
- CSS input: `app/web/static/css/input.css` (raramente, usa classi Tailwind)
|
||||||
|
- JavaScript: `app/web/static/js/app.js`, `servers.js`, `models-running.js`, `data-sync.worker.js`
|
||||||
|
|
||||||
|
> ⚠️ **Classi Tailwind dinamiche**: Le classi generate dinamicamente via `innerHTML` (es. in accordion o card) **non** vengono rilevate dal JIT scanner. Usa stili inline (`style="..."`) o classi hardcoded nei template HTML per queste situazioni.
|
||||||
|
|
||||||
|
4. **Compilato automaticamente:**
|
||||||
|
- Tailwind genera `app/web/static/css/output.css` automaticamente
|
||||||
|
- FastAPI recarica il server automaticamente
|
||||||
|
- Browser reload automatico (se abilitato)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 Build Docker
|
||||||
|
|
||||||
|
Il Dockerfile multi-stage:
|
||||||
|
|
||||||
|
1. **Stage 1 - CSS Builder (Node):**
|
||||||
|
- Installa dipendenze npm
|
||||||
|
- Compila Tailwind CSS
|
||||||
|
- Genera `app/web/static/css/output.css`
|
||||||
|
|
||||||
|
2. **Stage 2 - Python Builder:**
|
||||||
|
- Installa dipendenze Python
|
||||||
|
- Crea virtualenv
|
||||||
|
|
||||||
|
3. **Stage 3 - Runtime:**
|
||||||
|
- Copia CSS compilato dal Stage 1
|
||||||
|
- Copia Python packages dal Stage 2
|
||||||
|
- Immagine finale ottimizzata (~300MB)
|
||||||
|
|
||||||
|
### Build locale:
|
||||||
|
```bash
|
||||||
|
docker build -t llm-monitor:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Eseguire il container:
|
||||||
|
```bash
|
||||||
|
docker run -p 8000:8000 --env-file .env llm-monitor:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Configurazione Tailwind
|
||||||
|
|
||||||
|
File: `tailwind.config.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./app/web/templates/**/*.html",
|
||||||
|
"./app/web/static/**/*.js",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Content**: Specifica quali file Tailwind deve scansionare per le classi utilizzate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 CSS Architecture
|
||||||
|
|
||||||
|
### Input CSS
|
||||||
|
File: `app/web/static/css/input.css`
|
||||||
|
```css
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output CSS
|
||||||
|
File: `app/web/static/css/output.css` (generato)
|
||||||
|
- Contiene solo le classi Tailwind utilizzate
|
||||||
|
- Minificato in produzione (~30KB)
|
||||||
|
- Ottimizzato per performance
|
||||||
|
|
||||||
|
### Usage in HTML
|
||||||
|
File: `app/web/templates/index.html`
|
||||||
|
```html
|
||||||
|
<!-- Usa il CSS compilato (produzione) -->
|
||||||
|
<link rel="stylesheet" href="/static/css/output.css">
|
||||||
|
|
||||||
|
<!-- Fallback CDN per sviluppo (se output.css non esiste) -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Tips di Sviluppo
|
||||||
|
|
||||||
|
### Hot Reload CSS
|
||||||
|
```bash
|
||||||
|
npm run tailwind:dev
|
||||||
|
# Guarda i file e compila automaticamente
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug CSS Compilation
|
||||||
|
```bash
|
||||||
|
npm run tailwind:build
|
||||||
|
# Se il CSS non appare, verifica:
|
||||||
|
# 1. Le classi sono usate nei file HTML/JS?
|
||||||
|
# 2. C'è un errore nella sintassi CSS?
|
||||||
|
# 3. I percorsi in tailwind.config.js sono corretti?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aggiungere Nuove Classi Tailwind
|
||||||
|
1. Modifica i file HTML/JS con classi Tailwind
|
||||||
|
2. Tailwind watcher le detetta automaticamente
|
||||||
|
3. `output.css` viene rigenerato
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Nuova classe aggiunta -->
|
||||||
|
<div class="bg-gradient-to-r from-purple-500 to-pink-500">
|
||||||
|
<!-- Viene aggiunta automaticamente al CSS compilato -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Production Checklist
|
||||||
|
|
||||||
|
- [ ] Eseguire `npm run tailwind:build` per minificare
|
||||||
|
- [ ] Verificare che `output.css` sia generato
|
||||||
|
- [ ] Eseguire i test Python: `make test`
|
||||||
|
- [ ] Eseguire i test E2E: `npm run test:e2e`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
### Unit Test (pytest)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tutti i test
|
||||||
|
pytest tests/ -v
|
||||||
|
|
||||||
|
# Con coverage
|
||||||
|
pytest tests/ --cov=app
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Test (Playwright)
|
||||||
|
|
||||||
|
I test E2E verificano il comportamento del browser (cache-first, navigazione, PWA).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Installare i browser Playwright (prima volta)
|
||||||
|
npx playwright install --with-deps
|
||||||
|
|
||||||
|
# Eseguire i test E2E (richiede Ollama attivo)
|
||||||
|
OLLAMA_HOST=http://<ollama-host>:11434 npm run test:e2e
|
||||||
|
|
||||||
|
# Con report HTML
|
||||||
|
npm run test:e2e -- --reporter=html
|
||||||
|
```
|
||||||
|
|
||||||
|
I test si trovano in `tests/e2e/`. Il report viene generato in `playwright-report/` (gitignored).
|
||||||
|
|
||||||
|
### Makefile
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # pytest
|
||||||
|
make lint # flake8
|
||||||
|
make format # black
|
||||||
|
make dev # uvicorn --reload
|
||||||
|
make deploy-no-cache # Docker rebuild forzato
|
||||||
|
```
|
||||||
|
- [ ] Controllare che il container Docker usi il CSS compilato
|
||||||
|
- [ ] Test performance con Lighthouse
|
||||||
|
- [ ] Verifica bundle size `output.css`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Risorse
|
||||||
|
|
||||||
|
- [Tailwind CSS Documentation](https://tailwindcss.com/docs)
|
||||||
|
- [Tailwind CLI](https://tailwindcss.com/docs/installation)
|
||||||
|
- [FastAPI Hot Reload](https://fastapi.tiangolo.com/#example-upgrade)
|
||||||
|
- [Docker Multi-Stage Builds](https://docs.docker.com/build/building/multi-stage/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Ultimo aggiornamento:** Aprile 2024
|
||||||
+18
-6
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
**Versione:** 1.0.0
|
**Versione:** 1.0.0
|
||||||
**Data:** Aprile 2024
|
**Data:** Aprile 2024
|
||||||
**Autore:** Luca Sacchi
|
**Autore:** Luca Sacchi Ricciardi
|
||||||
|
**Detentore dei diritti:** Luca Sacchi Ricciardi (tutti i diritti riservati)
|
||||||
**Status:** Active Development
|
**Status:** Active Development
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -131,12 +132,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,11 +191,19 @@ 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
|
Scarica/carica un modello (**disabilitato di default**)
|
||||||
|
|
||||||
#### `DELETE /api/v1/models/{model_name}`
|
#### `DELETE /api/v1/models/{model_name}`
|
||||||
Elimina un modello
|
Elimina un modello (**disabilitato di default**)
|
||||||
|
|
||||||
|
#### Policy endpoint R/W
|
||||||
|
- Gli endpoint `POST/DELETE` sono **non disponibili** per default.
|
||||||
|
- Si abilitano solo con variabile ambiente `ENABLE_MODEL_RW_API=true`.
|
||||||
|
- Se non abilitati, gli endpoint non sono esposti in Swagger e rispondono con `404`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -216,7 +228,7 @@ Elimina un modello
|
|||||||
|
|
||||||
**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
|
||||||
@@ -244,7 +256,7 @@ Elimina un modello
|
|||||||
|
|
||||||
**Componenti:**
|
**Componenti:**
|
||||||
- Dockerfile multi-stage ottimizzato
|
- Dockerfile multi-stage ottimizzato
|
||||||
- docker-compose.yml con Ollama incluso
|
- docker-compose.yml per la sola dashboard (Ollama esterno/remoto)
|
||||||
- Health checks configurati
|
- Health checks configurati
|
||||||
- Sempre acceso fino all'arresto manuale
|
- Sempre acceso fino all'arresto manuale
|
||||||
|
|
||||||
@@ -659,7 +671,7 @@ llm-monitor/
|
|||||||
|
|
||||||
| Data | Versione | Autore | Cambiamenti |
|
| Data | Versione | Autore | Cambiamenti |
|
||||||
|------|----------|--------|------------|
|
|------|----------|--------|------------|
|
||||||
| 2024-04-24 | 1.0 | Luca Sacchi | Documento iniziale |
|
| 2024-04-24 | 1.0 | Luca Sacchi Ricciardi | Documento iniziale |
|
||||||
| 2024-04-25 | 1.1 | - | TBD |
|
| 2024-04-25 | 1.1 | - | TBD |
|
||||||
|
|
||||||
---
|
---
|
||||||
+16
-8
@@ -54,13 +54,17 @@ Template HTML con struttura base e caricamento di app.js
|
|||||||
|
|
||||||
## 💾 LocalStorage
|
## 💾 LocalStorage
|
||||||
|
|
||||||
### Chiavi memorizzate:
|
I dati sono memorizzati **per server** con chiavi dinamiche:
|
||||||
- `llm_monitor_health` - Dati health check (status, ollama_status, timestamp)
|
|
||||||
- `llm_monitor_models` - Dati modelli (lista, total, totalSize, timestamp)
|
|
||||||
|
|
||||||
### Struttura dati:
|
- `llm_monitor_health_<serverId>` - Dati health check
|
||||||
|
- `llm_monitor_models_<serverId>` - Dati modelli disponibili
|
||||||
|
- `llm_monitor_running_<serverId>` - Modelli in esecuzione
|
||||||
|
- `llm_monitor_servers` - Lista istanze Ollama configurate
|
||||||
|
- `llm_monitor_active_server` - ID del server attivo
|
||||||
|
|
||||||
**Health:**
|
La funzione `getServerStorageKey(serverId, suffix)` in `server-config.js` costruisce le chiavi.
|
||||||
|
|
||||||
|
### Struttura dati health:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
@@ -69,7 +73,7 @@ Template HTML con struttura base e caricamento di app.js
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Models:**
|
### Struttura dati models:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"models": [
|
"models": [
|
||||||
@@ -82,6 +86,9 @@ Template HTML con struttura base e caricamento di app.js
|
|||||||
],
|
],
|
||||||
"total": 1,
|
"total": 1,
|
||||||
"totalSize": "3.56 GB",
|
"totalSize": "3.56 GB",
|
||||||
|
"showByModel": {
|
||||||
|
"llama2": { "details": {}, "model_info": {}, "parameters": "..." }
|
||||||
|
},
|
||||||
"timestamp": "2024-01-15T10:30:00.000Z"
|
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -96,6 +103,8 @@ Template HTML con struttura base e caricamento di app.js
|
|||||||
### ✅ Offline Support
|
### ✅ Offline Support
|
||||||
- I dati rimangono in **localStorage** anche se il server è offline
|
- I dati rimangono in **localStorage** anche se il server è offline
|
||||||
- La dashboard mostra l'ultimo stato noto
|
- La dashboard mostra l'ultimo stato noto
|
||||||
|
- Il **Service Worker** (`service-worker.js`) mette in cache l'app shell (HTML, CSS, JS) per navigazione offline
|
||||||
|
- Cache name corrente: `llm-monitor-v3`
|
||||||
|
|
||||||
### ✅ Efficienza di Rete
|
### ✅ Efficienza di Rete
|
||||||
- Una sola fetch ogni 30 secondi (dal Worker)
|
- Una sola fetch ogni 30 secondi (dal Worker)
|
||||||
@@ -149,7 +158,6 @@ JSON.parse(localStorage.getItem('llm_monitor_models'))
|
|||||||
## 🚀 Ottimizzazioni Future
|
## 🚀 Ottimizzazioni Future
|
||||||
|
|
||||||
- [ ] IndexedDB per dati maggiori
|
- [ ] IndexedDB per dati maggiori
|
||||||
- [ ] Service Worker per offline mode completo
|
|
||||||
- [ ] Sincronizzazione tra tab (BroadcastChannel API)
|
- [ ] Sincronizzazione tra tab (BroadcastChannel API)
|
||||||
- [ ] Caching intelligente con TTL
|
- [ ] Caching intelligente con TTL
|
||||||
- [ ] Compressione dati (Zstandard/Brotli)
|
- [ ] Compressione dati (Zstandard/Brotli)
|
||||||
@@ -179,4 +187,4 @@ JSON.parse(localStorage.getItem('llm_monitor_models'))
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Sviluppato per LLM Monitor v1.0.0** 🦙
|
**Sviluppato per LLM Monitor v1.1.0** 🦙
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ API_PORT=8000
|
|||||||
# Numero di worker processes per uVicorn
|
# Numero di worker processes per uVicorn
|
||||||
API_WORKERS=4
|
API_WORKERS=4
|
||||||
|
|
||||||
|
# Abilita API R/W modelli (POST /pull, DELETE /models/{name})
|
||||||
|
# Default sicuro: false (endpoint non disponibili)
|
||||||
|
ENABLE_MODEL_RW_API=false
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
# ===========================================
|
# ===========================================
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
LLM Monitor - Dashboard per controllare i modelli caricati in Ollama
|
LLM Monitor - Dashboard to monitor Ollama models.
|
||||||
Entry point dell'applicazione FastAPI
|
FastAPI application entry point.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -8,29 +8,30 @@ from fastapi import FastAPI
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.openapi.docs import get_redoc_html
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Configurazione logging
|
# Logging configuration
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Importare le rotte
|
# Import API routes
|
||||||
from app.api.health import router as health_router
|
from app.api.health import router as health_router
|
||||||
from app.api.models import router as models_router
|
from app.api.models import router as models_router
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
# Creare l'app FastAPI
|
# Create FastAPI app
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LLM Monitor API",
|
title="LLM Monitor API",
|
||||||
description="Dashboard per il monitoraggio dei modelli LLM in Ollama",
|
description="Dashboard and API for monitoring Ollama LLM models",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url=None,
|
||||||
openapi_url="/openapi.json"
|
openapi_url="/openapi.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configurare CORS
|
# Configure CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.CORS_ORIGINS.split(","),
|
allow_origins=settings.CORS_ORIGINS.split(","),
|
||||||
@@ -39,37 +40,84 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Registrare le rotte API
|
# Register API routes
|
||||||
app.include_router(health_router, prefix="/api/v1", tags=["health"])
|
app.include_router(health_router, prefix="/api/v1", tags=["health"])
|
||||||
app.include_router(models_router, prefix="/api/v1", tags=["models"])
|
app.include_router(models_router, prefix="/api/v1", tags=["models"])
|
||||||
|
|
||||||
# Servire i file statici
|
# Serve static files
|
||||||
static_path = Path(__file__).parent / "app" / "web" / "static"
|
static_path = Path(__file__).parent / "app" / "web" / "static"
|
||||||
if static_path.exists():
|
if static_path.exists():
|
||||||
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
||||||
|
|
||||||
# Servire la dashboard web
|
# Serve web pages
|
||||||
templates_path = Path(__file__).parent / "app" / "web" / "templates"
|
templates_path = Path(__file__).parent / "app" / "web" / "templates"
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
"""Redirect alla dashboard"""
|
"""Primary page: configured servers selector and control panel."""
|
||||||
return FileResponse(templates_path / "index.html")
|
return FileResponse(templates_path / "servers.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/servers")
|
||||||
|
async def servers_page():
|
||||||
|
"""Configured Ollama servers page."""
|
||||||
|
return FileResponse(templates_path / "servers.html")
|
||||||
|
|
||||||
@app.get("/dashboard")
|
@app.get("/dashboard")
|
||||||
async def dashboard():
|
async def dashboard():
|
||||||
"""Dashboard principale"""
|
"""Legacy alias for configured servers page."""
|
||||||
|
return FileResponse(templates_path / "servers.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/models-available")
|
||||||
|
async def models_available_page():
|
||||||
|
"""Page listing models available on disk."""
|
||||||
return FileResponse(templates_path / "index.html")
|
return FileResponse(templates_path / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/models-running")
|
||||||
|
async def models_running_page():
|
||||||
|
"""Page dedicated to models resident in memory (ollama ps)."""
|
||||||
|
return FileResponse(templates_path / "models_running.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/manifest.webmanifest", include_in_schema=False)
|
||||||
|
async def web_manifest():
|
||||||
|
"""PWA web manifest."""
|
||||||
|
return FileResponse(static_path / "manifest.webmanifest", media_type="application/manifest+json")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/service-worker.js", include_in_schema=False)
|
||||||
|
async def service_worker():
|
||||||
|
"""PWA service worker with root scope."""
|
||||||
|
return FileResponse(static_path / "js" / "service-worker.js", media_type="application/javascript")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/redoc", include_in_schema=False)
|
||||||
|
async def redoc_html():
|
||||||
|
"""ReDoc documentation using a stable bundle."""
|
||||||
|
return get_redoc_html(
|
||||||
|
openapi_url=app.openapi_url,
|
||||||
|
title=f"{app.title} - ReDoc",
|
||||||
|
redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js",
|
||||||
|
with_google_fonts=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
|
async def favicon():
|
||||||
|
"""Application favicon."""
|
||||||
|
return FileResponse(static_path / "favicon.ico")
|
||||||
|
|
||||||
# Event hooks
|
# Event hooks
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
logger.info("🚀 LLM Monitor avviato")
|
logger.info("LLM Monitor started")
|
||||||
logger.info(f"📊 Ollama host: {settings.OLLAMA_HOST}")
|
logger.info(f"Ollama host: {settings.OLLAMA_HOST}")
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
async def shutdown_event():
|
async def shutdown_event():
|
||||||
logger.info("🛑 LLM Monitor arrestato")
|
logger.info("LLM Monitor stopped")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|||||||
+5
-2
@@ -1,13 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "llm-monitor",
|
"name": "llm-monitor",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"type": "commonjs",
|
||||||
"description": "Dashboard per controllare i modelli caricati in Ollama",
|
"description": "Dashboard per controllare i modelli caricati in Ollama",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tailwind:dev": "tailwindcss -i app/web/static/css/input.css -o app/web/static/css/output.css --watch",
|
"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"
|
"tailwind:build": "tailwindcss -i ./app/web/static/css/input.css -o ./app/web/static/css/output.css",
|
||||||
|
"test:e2e": "playwright test tests/e2e/cache-navigation.spec.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"tailwindcss": "^3.4.0"
|
"tailwindcss": "^3.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
|
||||||
|
const baseURL = process.env.TARGET_URL || 'http://127.0.0.1:8011';
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
timeout: 45000,
|
||||||
|
fullyParallel: false,
|
||||||
|
retries: 0,
|
||||||
|
reporter: 'list',
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
headless: true,
|
||||||
|
serviceWorkers: 'block'
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: 'python3 -m uvicorn main:app --host 127.0.0.1 --port 8011',
|
||||||
|
url: baseURL,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 30000
|
||||||
|
}
|
||||||
|
});
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="${PROJECT_DIR:-/opt/llm-monitor}"
|
||||||
|
CONTAINER_NAME="${CONTAINER_NAME:-llm-monitor-app}"
|
||||||
|
|
||||||
|
if [[ -d "$PROJECT_DIR" ]]; then
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
else
|
||||||
|
echo "[deploy] PROJECT_DIR non trovato: $PROJECT_DIR"
|
||||||
|
echo "[deploy] uso directory corrente: $PWD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[deploy] stop stack"
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
if [[ ! -f ".env" && -f ".env.local" ]]; then
|
||||||
|
echo "[deploy] .env non trovato, copio .env.local -> .env"
|
||||||
|
cp .env.local .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[deploy] build stack (no cache)"
|
||||||
|
docker compose build --no-cache
|
||||||
|
|
||||||
|
echo "[deploy] start stack"
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
echo "[deploy] waiting for container startup"
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
echo "[deploy] verify Tailwind CSS"
|
||||||
|
./scripts/verify-tailwind-css.sh "$CONTAINER_NAME"
|
||||||
|
|
||||||
|
echo "[deploy] completed successfully"
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONTAINER_NAME="${1:-llm-monitor-app}"
|
||||||
|
CSS_PATH="/app/app/web/static/css/output.css"
|
||||||
|
MIN_LINES="${MIN_TAILWIND_LINES:-100}"
|
||||||
|
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -Fxq "$CONTAINER_NAME"; then
|
||||||
|
echo "[verify-css] ERROR: container '$CONTAINER_NAME' non in esecuzione"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker exec "$CONTAINER_NAME" test -f "$CSS_PATH"; then
|
||||||
|
echo "[verify-css] ERROR: file CSS non trovato: $CSS_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
LINES=$(docker exec "$CONTAINER_NAME" wc -l "$CSS_PATH" | awk '{print $1}')
|
||||||
|
BYTES=$(docker exec "$CONTAINER_NAME" wc -c "$CSS_PATH" | awk '{print $1}')
|
||||||
|
|
||||||
|
echo "[verify-css] $CSS_PATH -> ${LINES} lines, ${BYTES} bytes"
|
||||||
|
|
||||||
|
if [[ "$LINES" -lt "$MIN_LINES" ]]; then
|
||||||
|
echo "[verify-css] ERROR: output.css ha meno di ${MIN_LINES} linee"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[verify-css] OK: Tailwind CSS compilato correttamente"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./app/web/templates/**/*.html",
|
||||||
|
"./app/web/static/**/*.js",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://192.168.254.115:11434';
|
||||||
|
const SERVER_ID = process.env.TEST_SERVER_ID || 'srv_e2e_cache';
|
||||||
|
const SERVER_NAME = process.env.TEST_SERVER_NAME || 'E2E Cache Server';
|
||||||
|
const QUIET_WINDOW_MS = Number(process.env.QUIET_WINDOW_MS || 1500);
|
||||||
|
const CACHE_WAIT_TIMEOUT_MS = Number(process.env.CACHE_WAIT_TIMEOUT_MS || 20000);
|
||||||
|
|
||||||
|
test.describe('cache-first server navigation', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(
|
||||||
|
({ serverId, serverName, host }) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'llm_monitor_servers',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: serverId,
|
||||||
|
name: serverName,
|
||||||
|
host
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
localStorage.setItem('llm_monitor_active_server', serverId);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: SERVER_ID,
|
||||||
|
serverName: SERVER_NAME,
|
||||||
|
host: OLLAMA_HOST
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serves cached data when navigating between running and available pages', async ({ context, page }) => {
|
||||||
|
const apiRequests = [];
|
||||||
|
|
||||||
|
context.on('request', (request) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
if (url.pathname.startsWith('/api/v1/')) {
|
||||||
|
apiRequests.push(url.pathname + url.search);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetApiRequests = () => {
|
||||||
|
apiRequests.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForQuietWindow = async (label) => {
|
||||||
|
await page.waitForTimeout(QUIET_WINDOW_MS);
|
||||||
|
expect(apiRequests, `${label} should not issue API requests while cache is fresh`).toEqual([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
await page.goto(`/models-running?server=${SERVER_ID}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
await page.waitForFunction(
|
||||||
|
(serverId) => {
|
||||||
|
return ['health', 'models', 'running'].every((suffix) => {
|
||||||
|
return Boolean(localStorage.getItem(`llm_monitor_${suffix}_${serverId}`));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
SERVER_ID,
|
||||||
|
{ timeout: CACHE_WAIT_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(apiRequests.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
resetApiRequests();
|
||||||
|
await page.goto(`/models-available?server=${SERVER_ID}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await waitForQuietWindow('running -> available');
|
||||||
|
|
||||||
|
resetApiRequests();
|
||||||
|
await page.goto(`/models-running?server=${SERVER_ID}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await waitForQuietWindow('available -> running');
|
||||||
|
|
||||||
|
resetApiRequests();
|
||||||
|
await page.goto(`/models-available?server=${SERVER_ID}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await waitForQuietWindow('running -> available again');
|
||||||
|
});
|
||||||
|
});
|
||||||
+119
-3
@@ -3,6 +3,7 @@ Test API endpoints
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
def test_health_check(client):
|
def test_health_check(client):
|
||||||
@@ -46,14 +47,83 @@ 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_models_ollama_offline(client):
|
|
||||||
"""Test getting models when Ollama is offline"""
|
def test_get_models_with_host_override(client, mock_models_response):
|
||||||
|
"""Test host override is propagated to upstream models API call."""
|
||||||
|
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", params={"host": "http://example-host:11434"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert mock_get.call_args.args[0] == "http://example-host:11434/api/tags"
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_with_invalid_host_returns_422(client):
|
||||||
|
"""Invalid host query parameter must be rejected."""
|
||||||
|
response = client.get("/api/v1/health", params={"host": "not-a-url"})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_show_with_invalid_host_returns_422(client):
|
||||||
|
"""Invalid host query parameter must be rejected on show endpoint."""
|
||||||
|
response = client.get("/api/v1/models/llama2/show", params={"host": "localhost:11434"})
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
with patch("requests.get") as mock_get:
|
||||||
mock_get.side_effect = Exception("Connection refused")
|
mock_get.side_effect = Exception("Connection refused")
|
||||||
|
|
||||||
response = client.get("/api/v1/models")
|
response = client.get("/api/v1/models/running")
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
|
|
||||||
|
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 = requests.exceptions.ConnectionError("Connection refused")
|
||||||
|
|
||||||
|
response = client.get("/api/v1/models")
|
||||||
|
assert response.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_models_returns_502_when_upstream_is_unavailable(client):
|
||||||
|
"""Non-200 upstream response should remain a 502, not be converted to 500."""
|
||||||
|
with patch("requests.get") as mock_get:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 503
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
response = client.get("/api/v1/models")
|
||||||
|
assert response.status_code == 502
|
||||||
|
|
||||||
def test_get_specific_model(client, mock_models_response):
|
def test_get_specific_model(client, mock_models_response):
|
||||||
"""Test getting specific model"""
|
"""Test getting specific model"""
|
||||||
with patch("requests.get") as mock_get:
|
with patch("requests.get") as mock_get:
|
||||||
@@ -78,6 +148,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,3 +196,15 @@ 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}/pull" not in schema["paths"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_endpoints_disabled_by_default(client):
|
||||||
|
"""POST/DELETE sui modelli devono essere non disponibili di default."""
|
||||||
|
response_pull = client.post("/api/v1/models/llama2/pull")
|
||||||
|
assert response_pull.status_code == 404
|
||||||
|
|
||||||
|
response_delete = client.delete("/api/v1/models/llama2")
|
||||||
|
assert response_delete.status_code == 404
|
||||||
|
|||||||
Reference in New Issue
Block a user