feat: add multi-server control panel and host-aware sync
This commit is contained in:
+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:
|
||||||
|
|||||||
+26
-9
@@ -2,12 +2,13 @@
|
|||||||
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 Any, Dict, 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__)
|
||||||
@@ -22,6 +23,18 @@ def ensure_rw_api_enabled() -> None:
|
|||||||
detail="Endpoint non disponibile"
|
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
|
||||||
@@ -60,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
|
||||||
|
|
||||||
@@ -70,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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,16 +135,17 @@ async def get_models():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/models/running")
|
@router.get("/models/running")
|
||||||
async def get_running_models() -> Dict[str, Any]:
|
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`.
|
Recupera i modelli attualmente residenti in memoria, equivalenti a `ollama ps`.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict[str, Any]: Payload con modelli running e conteggio
|
Dict[str, Any]: Payload con modelli running e conteggio
|
||||||
"""
|
"""
|
||||||
|
target_host = resolve_ollama_host(host)
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
f"{settings.OLLAMA_HOST}/api/ps",
|
f"{target_host}/api/ps",
|
||||||
timeout=settings.OLLAMA_TIMEOUT
|
timeout=settings.OLLAMA_TIMEOUT
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -168,7 +183,7 @@ async def get_running_models() -> Dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@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
|
||||||
|
|
||||||
@@ -181,9 +196,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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -224,7 +240,7 @@ async def get_model(model_name: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/models/{model_name}/show")
|
@router.get("/models/{model_name}/show")
|
||||||
async def get_model_show(model_name: str) -> Dict[str, Any]:
|
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.
|
Recupera le informazioni estese di un modello tramite endpoint Ollama /api/show.
|
||||||
|
|
||||||
@@ -234,9 +250,10 @@ async def get_model_show(model_name: str) -> Dict[str, Any]:
|
|||||||
Returns:
|
Returns:
|
||||||
Dict[str, Any]: Dati estesi del modello
|
Dict[str, Any]: Dati estesi del modello
|
||||||
"""
|
"""
|
||||||
|
target_host = resolve_ollama_host(host)
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{settings.OLLAMA_HOST}/api/show",
|
f"{target_host}/api/show",
|
||||||
json={"model": model_name},
|
json={"model": model_name},
|
||||||
timeout=settings.OLLAMA_TIMEOUT
|
timeout=settings.OLLAMA_TIMEOUT
|
||||||
)
|
)
|
||||||
|
|||||||
+92
-10
@@ -6,6 +6,7 @@
|
|||||||
class LLMMonitorApp {
|
class LLMMonitorApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.worker = null;
|
this.worker = null;
|
||||||
|
this.activeServer = getActiveServer();
|
||||||
this.selectedModelName = null;
|
this.selectedModelName = null;
|
||||||
this.isModalOpen = false;
|
this.isModalOpen = false;
|
||||||
this.hoverOpenDelayMs = 180;
|
this.hoverOpenDelayMs = 180;
|
||||||
@@ -18,6 +19,13 @@ class LLMMonitorApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
if (!this.activeServer) {
|
||||||
|
this.renderNoServerState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateServerContextUI();
|
||||||
|
|
||||||
// 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');
|
||||||
@@ -27,6 +35,11 @@ class LLMMonitorApp {
|
|||||||
// Fallback: sincronizzazione nel main thread
|
// Fallback: sincronizzazione nel main thread
|
||||||
this.syncDataInMainThread();
|
this.syncDataInMainThread();
|
||||||
};
|
};
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "SET_SERVER",
|
||||||
|
serverId: this.activeServer.id,
|
||||||
|
host: this.activeServer.host
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
console.warn("Web Workers not supported, using main thread");
|
console.warn("Web Workers not supported, using main thread");
|
||||||
this.syncDataInMainThread();
|
this.syncDataInMainThread();
|
||||||
@@ -60,8 +73,8 @@ class LLMMonitorApp {
|
|||||||
|
|
||||||
// Caricare dati da localStorage
|
// Caricare dati da localStorage
|
||||||
loadFromLocalStorage() {
|
loadFromLocalStorage() {
|
||||||
const healthStr = localStorage.getItem("llm_monitor_health");
|
const healthStr = localStorage.getItem(this.getStorageKey("health"));
|
||||||
const modelsStr = localStorage.getItem("llm_monitor_models");
|
const modelsStr = localStorage.getItem(this.getStorageKey("models"));
|
||||||
|
|
||||||
if (healthStr) {
|
if (healthStr) {
|
||||||
try {
|
try {
|
||||||
@@ -84,13 +97,17 @@ class LLMMonitorApp {
|
|||||||
|
|
||||||
// Gestire messaggi dal Worker
|
// Gestire messaggi dal Worker
|
||||||
handleWorkerMessage(event) {
|
handleWorkerMessage(event) {
|
||||||
const { type, health, modelsData } = event.data;
|
const { type, health, modelsData, 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 {
|
try {
|
||||||
localStorage.setItem("llm_monitor_health", JSON.stringify(health));
|
localStorage.setItem(this.getStorageKey("health"), JSON.stringify(health));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Cannot persist health in localStorage:", error);
|
console.warn("Cannot persist health in localStorage:", error);
|
||||||
}
|
}
|
||||||
@@ -100,7 +117,7 @@ class LLMMonitorApp {
|
|||||||
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;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("llm_monitor_models", JSON.stringify(modelsData));
|
localStorage.setItem(this.getStorageKey("models"), JSON.stringify(modelsData));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Cannot persist models in localStorage:", error);
|
console.warn("Cannot persist models in localStorage:", error);
|
||||||
}
|
}
|
||||||
@@ -326,6 +343,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 {
|
||||||
@@ -335,12 +356,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));
|
localStorage.setItem(this.getStorageKey("health"), JSON.stringify(health));
|
||||||
this.renderHealth(health);
|
this.renderHealth(health);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -348,7 +373,7 @@ 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 || [];
|
||||||
@@ -356,7 +381,7 @@ class LLMMonitorApp {
|
|||||||
const showByModel = {};
|
const showByModel = {};
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
models.map(async (model) => {
|
models.map(async (model) => {
|
||||||
const showResponse = await fetch(`/api/v1/models/${encodeURIComponent(model.name)}/show`);
|
const showResponse = await fetch(this.buildApiUrl(`/api/v1/models/${encodeURIComponent(model.name)}/show`));
|
||||||
if (showResponse.ok) {
|
if (showResponse.ok) {
|
||||||
showByModel[model.name] = await showResponse.json();
|
showByModel[model.name] = await showResponse.json();
|
||||||
}
|
}
|
||||||
@@ -371,7 +396,7 @@ class LLMMonitorApp {
|
|||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
};
|
};
|
||||||
this.lastData.models = modelsData;
|
this.lastData.models = modelsData;
|
||||||
localStorage.setItem("llm_monitor_models", JSON.stringify(modelsData));
|
localStorage.setItem(this.getStorageKey("models"), JSON.stringify(modelsData));
|
||||||
this.renderModels(modelsData);
|
this.renderModels(modelsData);
|
||||||
if (this.selectedModelName) {
|
if (this.selectedModelName) {
|
||||||
this.showModelDetails(this.selectedModelName);
|
this.showModelDetails(this.selectedModelName);
|
||||||
@@ -381,6 +406,63 @@ class LLMMonitorApp {
|
|||||||
console.error("Models loading error:", error);
|
console.error("Models loading error:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getStorageKey(suffix) {
|
||||||
|
return `llm_monitor_${suffix}_${this.activeServer.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
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 (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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inizializzare l'app quando il DOM è pronto
|
// Inizializzare l'app quando il DOM è pronto
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
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;
|
||||||
|
|
||||||
// Formattare bytes
|
// Formattare bytes
|
||||||
function formatBytes(bytes) {
|
function formatBytes(bytes) {
|
||||||
@@ -17,14 +19,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,8 +42,12 @@ 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("Failed to load models");
|
if (!response.ok) throw new Error("Failed to load models");
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -46,7 +57,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);
|
||||||
@@ -57,7 +69,7 @@ async function fetchModels() {
|
|||||||
// Recuperare dettagli show per un modello
|
// Recuperare dettagli show per un modello
|
||||||
async function fetchModelShow(modelName) {
|
async function fetchModelShow(modelName) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/models/${encodeURIComponent(modelName)}/show`);
|
const response = await fetch(buildApiUrl(`${API_BASE}/models/${encodeURIComponent(modelName)}/show`));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -89,6 +101,16 @@ async function fetchAllModelsShow(models) {
|
|||||||
|
|
||||||
// Sincronizzare i dati
|
// Sincronizzare i dati
|
||||||
async function syncData() {
|
async function syncData() {
|
||||||
|
if (!activeHost) {
|
||||||
|
self.postMessage({
|
||||||
|
type: "DATA_UPDATED",
|
||||||
|
health: null,
|
||||||
|
modelsData: null,
|
||||||
|
serverId: activeServerId
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const health = await fetchHealth();
|
const health = await fetchHealth();
|
||||||
const modelsData = await fetchModels();
|
const modelsData = await fetchModels();
|
||||||
|
|
||||||
@@ -103,18 +125,28 @@ async function syncData() {
|
|||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: "DATA_UPDATED",
|
type: "DATA_UPDATED",
|
||||||
health,
|
health,
|
||||||
modelsData
|
modelsData,
|
||||||
|
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
|
// Pianificare aggiornamenti periodici
|
||||||
setInterval(syncData, REFRESH_INTERVAL);
|
setInterval(syncData, REFRESH_INTERVAL);
|
||||||
|
|
||||||
// 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;
|
||||||
|
syncData();
|
||||||
|
}
|
||||||
|
|
||||||
if (event.data.type === "SYNC_NOW") {
|
if (event.data.type === "SYNC_NOW") {
|
||||||
syncData();
|
syncData();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
class RunningModelsPage {
|
class RunningModelsPage {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.activeServer = getActiveServer();
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
this.updateServerContextUI();
|
||||||
|
|
||||||
|
if (!this.activeServer) {
|
||||||
|
this.renderNoServerState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById("refresh-btn")?.addEventListener("click", () => {
|
document.getElementById("refresh-btn")?.addEventListener("click", () => {
|
||||||
this.loadRunningModels();
|
this.loadRunningModels();
|
||||||
});
|
});
|
||||||
@@ -25,7 +33,7 @@ class RunningModelsPage {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/v1/models/running");
|
const response = await fetch(this.buildApiUrl("/api/v1/models/running"));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to load running models");
|
throw new Error("Failed to load running models");
|
||||||
}
|
}
|
||||||
@@ -46,6 +54,55 @@ class RunningModelsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
renderStats(models) {
|
||||||
const runningCountEl = document.getElementById("running-count");
|
const runningCountEl = document.getElementById("running-count");
|
||||||
const vramTotalEl = document.getElementById("vram-total");
|
const vramTotalEl = document.getElementById("vram-total");
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const SERVER_STORAGE_KEY = "llm_monitor_servers";
|
||||||
|
const ACTIVE_SERVER_KEY = "llm_monitor_active_server";
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
const CACHE_NAME = "llm-monitor-v1";
|
const CACHE_NAME = "llm-monitor-v2";
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
"/",
|
"/",
|
||||||
|
"/servers",
|
||||||
"/models-running",
|
"/models-running",
|
||||||
"/models-available",
|
"/models-available",
|
||||||
"/static/css/output.css",
|
"/static/css/output.css",
|
||||||
|
"/static/js/server-config.js",
|
||||||
"/static/js/app.js",
|
"/static/js/app.js",
|
||||||
|
"/static/js/servers.js",
|
||||||
"/static/js/models-running.js",
|
"/static/js/models-running.js",
|
||||||
"/static/js/data-sync.worker.js",
|
"/static/js/data-sync.worker.js",
|
||||||
"/static/js/pwa-register.js",
|
"/static/js/pwa-register.js",
|
||||||
@@ -66,7 +69,7 @@ self.addEventListener("fetch", (event) => {
|
|||||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone));
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone));
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
.catch(() => caches.match("/models-running"));
|
.catch(() => caches.match("/servers"));
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,7 +51,9 @@
|
|||||||
<h1 class="text-2xl font-bold">LLM Monitor</h1>
|
<h1 class="text-2xl font-bold">LLM Monitor</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href="/models-running" class="text-sm bg-gray-700 hover:bg-gray-600 px-3 py-2 rounded-lg transition">Running Models</a>
|
<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">Checking...</span>
|
<span id="status-text" class="text-sm text-gray-400">Checking...</span>
|
||||||
@@ -147,6 +149,7 @@
|
|||||||
<!-- LLM Monitor Application -->
|
<!-- LLM Monitor Application -->
|
||||||
<!-- Web Worker for background data sync -->
|
<!-- Web Worker for background data sync -->
|
||||||
<!-- localStorage for client-side persistence -->
|
<!-- 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>
|
<script src="/static/js/pwa-register.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -34,7 +34,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a href="/models-available" class="text-sm bg-gray-700 hover:bg-gray-600 px-3 py-2 rounded-lg transition">Available Models</a>
|
<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>
|
<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>
|
</div>
|
||||||
@@ -77,6 +79,7 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/server-config.js"></script>
|
||||||
<script src="/static/js/models-running.js"></script>
|
<script src="/static/js/models-running.js"></script>
|
||||||
<script src="/static/js/pwa-register.js"></script>
|
<script src="/static/js/pwa-register.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -54,13 +54,19 @@ templates_path = Path(__file__).parent / "app" / "web" / "templates"
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
"""Primary page: models currently loaded in memory (ollama ps)."""
|
"""Primary page: configured servers selector and control panel."""
|
||||||
return FileResponse(templates_path / "models_running.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():
|
||||||
"""Legacy alias for the primary page."""
|
"""Legacy alias for configured servers page."""
|
||||||
return FileResponse(templates_path / "models_running.html")
|
return FileResponse(templates_path / "servers.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/models-available")
|
@app.get("/models-available")
|
||||||
|
|||||||
@@ -47,6 +47,31 @@ def test_get_models(client, mock_models_response):
|
|||||||
assert data["models"][0]["name"] == "llama2"
|
assert data["models"][0]["name"] == "llama2"
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
def test_get_running_models(client):
|
||||||
"""Test getting running models (ollama ps)."""
|
"""Test getting running models (ollama ps)."""
|
||||||
with patch("requests.get") as mock_get:
|
with patch("requests.get") as mock_get:
|
||||||
|
|||||||
Reference in New Issue
Block a user