feat: add multi-server control panel and host-aware sync
This commit is contained in:
+21
-5
@@ -2,11 +2,13 @@
|
||||
Health check endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
import requests
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
from app.config import settings
|
||||
|
||||
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)
|
||||
async def health_check():
|
||||
async def health_check(host: Optional[str] = Query(default=None)):
|
||||
"""
|
||||
Health check dell'API e dello stato di Ollama
|
||||
|
||||
Returns:
|
||||
HealthResponse: Status dell'API e di Ollama
|
||||
"""
|
||||
target_host = resolve_ollama_host(host)
|
||||
try:
|
||||
# Check Ollama
|
||||
response = requests.get(
|
||||
f"{settings.OLLAMA_HOST}/api/tags",
|
||||
f"{target_host}/api/tags",
|
||||
timeout=settings.OLLAMA_TIMEOUT
|
||||
)
|
||||
ollama_status = "online" if response.status_code == 200 else "offline"
|
||||
@@ -52,13 +67,14 @@ async def health_check():
|
||||
)
|
||||
|
||||
@router.get("/ready")
|
||||
async def ready():
|
||||
async def ready(host: Optional[str] = Query(default=None)):
|
||||
"""
|
||||
Readiness probe per Kubernetes/Docker
|
||||
"""
|
||||
target_host = resolve_ollama_host(host)
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{settings.OLLAMA_HOST}/api/tags",
|
||||
f"{target_host}/api/tags",
|
||||
timeout=5
|
||||
)
|
||||
if response.status_code == 200:
|
||||
|
||||
+26
-9
@@ -2,12 +2,13 @@
|
||||
Models endpoints - Gestione dei modelli Ollama
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
import requests
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -22,6 +23,18 @@ def ensure_rw_api_enabled() -> None:
|
||||
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):
|
||||
"""Informazioni su un modello"""
|
||||
name: str
|
||||
@@ -60,7 +73,7 @@ class ModelsResponse(BaseModel):
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
@@ -70,9 +83,10 @@ async def get_models():
|
||||
Raises:
|
||||
HTTPException: Se Ollama non è disponibile
|
||||
"""
|
||||
target_host = resolve_ollama_host(host)
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{settings.OLLAMA_HOST}/api/tags",
|
||||
f"{target_host}/api/tags",
|
||||
timeout=settings.OLLAMA_TIMEOUT
|
||||
)
|
||||
|
||||
@@ -121,16 +135,17 @@ async def get_models():
|
||||
|
||||
|
||||
@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`.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Payload con modelli running e conteggio
|
||||
"""
|
||||
target_host = resolve_ollama_host(host)
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{settings.OLLAMA_HOST}/api/ps",
|
||||
f"{target_host}/api/ps",
|
||||
timeout=settings.OLLAMA_TIMEOUT
|
||||
)
|
||||
|
||||
@@ -168,7 +183,7 @@ async def get_running_models() -> Dict[str, Any]:
|
||||
)
|
||||
|
||||
@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
|
||||
|
||||
@@ -181,9 +196,10 @@ async def get_model(model_name: str):
|
||||
Raises:
|
||||
HTTPException: Se il modello non esiste o Ollama non è disponibile
|
||||
"""
|
||||
target_host = resolve_ollama_host(host)
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{settings.OLLAMA_HOST}/api/tags",
|
||||
f"{target_host}/api/tags",
|
||||
timeout=settings.OLLAMA_TIMEOUT
|
||||
)
|
||||
|
||||
@@ -224,7 +240,7 @@ async def get_model(model_name: str):
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
@@ -234,9 +250,10 @@ async def get_model_show(model_name: str) -> Dict[str, Any]:
|
||||
Returns:
|
||||
Dict[str, Any]: Dati estesi del modello
|
||||
"""
|
||||
target_host = resolve_ollama_host(host)
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{settings.OLLAMA_HOST}/api/show",
|
||||
f"{target_host}/api/show",
|
||||
json={"model": model_name},
|
||||
timeout=settings.OLLAMA_TIMEOUT
|
||||
)
|
||||
|
||||
+92
-10
@@ -6,6 +6,7 @@
|
||||
class LLMMonitorApp {
|
||||
constructor() {
|
||||
this.worker = null;
|
||||
this.activeServer = getActiveServer();
|
||||
this.selectedModelName = null;
|
||||
this.isModalOpen = false;
|
||||
this.hoverOpenDelayMs = 180;
|
||||
@@ -18,6 +19,13 @@ class LLMMonitorApp {
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.activeServer) {
|
||||
this.renderNoServerState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateServerContextUI();
|
||||
|
||||
// Inizializzare il Web Worker
|
||||
if (typeof Worker !== 'undefined') {
|
||||
this.worker = new Worker('/static/js/data-sync.worker.js');
|
||||
@@ -27,6 +35,11 @@ class LLMMonitorApp {
|
||||
// Fallback: sincronizzazione nel main thread
|
||||
this.syncDataInMainThread();
|
||||
};
|
||||
this.worker.postMessage({
|
||||
type: "SET_SERVER",
|
||||
serverId: this.activeServer.id,
|
||||
host: this.activeServer.host
|
||||
});
|
||||
} else {
|
||||
console.warn("Web Workers not supported, using main thread");
|
||||
this.syncDataInMainThread();
|
||||
@@ -60,8 +73,8 @@ class LLMMonitorApp {
|
||||
|
||||
// Caricare dati da localStorage
|
||||
loadFromLocalStorage() {
|
||||
const healthStr = localStorage.getItem("llm_monitor_health");
|
||||
const modelsStr = localStorage.getItem("llm_monitor_models");
|
||||
const healthStr = localStorage.getItem(this.getStorageKey("health"));
|
||||
const modelsStr = localStorage.getItem(this.getStorageKey("models"));
|
||||
|
||||
if (healthStr) {
|
||||
try {
|
||||
@@ -84,13 +97,17 @@ class LLMMonitorApp {
|
||||
|
||||
// Gestire messaggi dal Worker
|
||||
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 (health && JSON.stringify(this.lastData.health) !== JSON.stringify(health)) {
|
||||
this.lastData.health = health;
|
||||
try {
|
||||
localStorage.setItem("llm_monitor_health", JSON.stringify(health));
|
||||
localStorage.setItem(this.getStorageKey("health"), JSON.stringify(health));
|
||||
} catch (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)) {
|
||||
this.lastData.models = modelsData;
|
||||
try {
|
||||
localStorage.setItem("llm_monitor_models", JSON.stringify(modelsData));
|
||||
localStorage.setItem(this.getStorageKey("models"), JSON.stringify(modelsData));
|
||||
} catch (error) {
|
||||
console.warn("Cannot persist models in localStorage:", error);
|
||||
}
|
||||
@@ -326,6 +343,10 @@ class LLMMonitorApp {
|
||||
|
||||
// Chiedere sincronizzazione manuale al Worker
|
||||
requestSync() {
|
||||
if (!this.activeServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.worker) {
|
||||
this.worker.postMessage({ type: "SYNC_NOW" });
|
||||
} else {
|
||||
@@ -335,12 +356,16 @@ class LLMMonitorApp {
|
||||
|
||||
// Fallback: sincronizzazione nel main thread
|
||||
async syncDataInMainThread() {
|
||||
if (!this.activeServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/v1/health");
|
||||
const response = await fetch(this.buildApiUrl("/api/v1/health"));
|
||||
if (response.ok) {
|
||||
const health = await response.json();
|
||||
this.lastData.health = health;
|
||||
localStorage.setItem("llm_monitor_health", JSON.stringify(health));
|
||||
localStorage.setItem(this.getStorageKey("health"), JSON.stringify(health));
|
||||
this.renderHealth(health);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -348,7 +373,7 @@ class LLMMonitorApp {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/v1/models");
|
||||
const response = await fetch(this.buildApiUrl("/api/v1/models"));
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const models = data.models || [];
|
||||
@@ -356,7 +381,7 @@ class LLMMonitorApp {
|
||||
const showByModel = {};
|
||||
await Promise.allSettled(
|
||||
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) {
|
||||
showByModel[model.name] = await showResponse.json();
|
||||
}
|
||||
@@ -371,7 +396,7 @@ class LLMMonitorApp {
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
this.lastData.models = modelsData;
|
||||
localStorage.setItem("llm_monitor_models", JSON.stringify(modelsData));
|
||||
localStorage.setItem(this.getStorageKey("models"), JSON.stringify(modelsData));
|
||||
this.renderModels(modelsData);
|
||||
if (this.selectedModelName) {
|
||||
this.showModelDetails(this.selectedModelName);
|
||||
@@ -381,6 +406,63 @@ class LLMMonitorApp {
|
||||
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
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
const API_BASE = "/api/v1";
|
||||
const REFRESH_INTERVAL = 30000; // 30 secondi
|
||||
let activeServerId = null;
|
||||
let activeHost = null;
|
||||
|
||||
// Formattare bytes
|
||||
function formatBytes(bytes) {
|
||||
@@ -17,14 +19,19 @@ function formatBytes(bytes) {
|
||||
|
||||
// Recuperare health
|
||||
async function fetchHealth() {
|
||||
if (!activeHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/health`);
|
||||
const response = await fetch(buildApiUrl(`${API_BASE}/health`));
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return {
|
||||
status: data.status,
|
||||
ollama_status: data.ollama_status,
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
serverId: activeServerId
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -35,8 +42,12 @@ async function fetchHealth() {
|
||||
|
||||
// Recuperare modelli
|
||||
async function fetchModels() {
|
||||
if (!activeHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
const data = await response.json();
|
||||
@@ -46,7 +57,8 @@ async function fetchModels() {
|
||||
models,
|
||||
total: models.length,
|
||||
totalSize: formatBytes(models.reduce((sum, m) => sum + m.size, 0)),
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
serverId: activeServerId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error loading models:", error);
|
||||
@@ -57,7 +69,7 @@ async function fetchModels() {
|
||||
// Recuperare dettagli show per un modello
|
||||
async function fetchModelShow(modelName) {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
@@ -89,6 +101,16 @@ async function fetchAllModelsShow(models) {
|
||||
|
||||
// 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 modelsData = await fetchModels();
|
||||
|
||||
@@ -103,18 +125,28 @@ async function syncData() {
|
||||
self.postMessage({
|
||||
type: "DATA_UPDATED",
|
||||
health,
|
||||
modelsData
|
||||
modelsData,
|
||||
serverId: activeServerId
|
||||
});
|
||||
}
|
||||
|
||||
// Eseguire la sincronizzazione iniziale
|
||||
syncData();
|
||||
function buildApiUrl(path) {
|
||||
const url = new URL(path, self.location.origin);
|
||||
url.searchParams.set("host", activeHost);
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
// Pianificare aggiornamenti periodici
|
||||
setInterval(syncData, REFRESH_INTERVAL);
|
||||
|
||||
// Gestire messaggi dal main thread
|
||||
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") {
|
||||
syncData();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
class RunningModelsPage {
|
||||
constructor() {
|
||||
this.activeServer = getActiveServer();
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.updateServerContextUI();
|
||||
|
||||
if (!this.activeServer) {
|
||||
this.renderNoServerState();
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById("refresh-btn")?.addEventListener("click", () => {
|
||||
this.loadRunningModels();
|
||||
});
|
||||
@@ -25,7 +33,7 @@ class RunningModelsPage {
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/v1/models/running");
|
||||
const response = await fetch(this.buildApiUrl("/api/v1/models/running"));
|
||||
if (!response.ok) {
|
||||
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) {
|
||||
const runningCountEl = document.getElementById("running-count");
|
||||
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 = [
|
||||
"/",
|
||||
"/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",
|
||||
@@ -66,7 +69,7 @@ self.addEventListener("fetch", (event) => {
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone));
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match("/models-running"));
|
||||
.catch(() => caches.match("/servers"));
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -51,7 +51,9 @@
|
||||
<h1 class="text-2xl font-bold">LLM Monitor</h1>
|
||||
</div>
|
||||
<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="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>
|
||||
@@ -147,6 +149,7 @@
|
||||
<!-- 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/pwa-register.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,6 +79,7 @@
|
||||
</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>
|
||||
|
||||
@@ -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("/")
|
||||
async def root():
|
||||
"""Primary page: models currently loaded in memory (ollama ps)."""
|
||||
return FileResponse(templates_path / "models_running.html")
|
||||
"""Primary page: configured servers selector and control panel."""
|
||||
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")
|
||||
async def dashboard():
|
||||
"""Legacy alias for the primary page."""
|
||||
return FileResponse(templates_path / "models_running.html")
|
||||
"""Legacy alias for configured servers page."""
|
||||
return FileResponse(templates_path / "servers.html")
|
||||
|
||||
|
||||
@app.get("/models-available")
|
||||
|
||||
@@ -47,6 +47,31 @@ def test_get_models(client, mock_models_response):
|
||||
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):
|
||||
"""Test getting running models (ollama ps)."""
|
||||
with patch("requests.get") as mock_get:
|
||||
|
||||
Reference in New Issue
Block a user