diff --git a/app/api/health.py b/app/api/health.py index 319a470..e65a19a 100644 --- a/app/api/health.py +++ b/app/api/health.py @@ -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: diff --git a/app/api/models.py b/app/api/models.py index 3830831..fe2ce40 100644 --- a/app/api/models.py +++ b/app/api/models.py @@ -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 ) diff --git a/app/web/static/js/app.js b/app/web/static/js/app.js index 70dbc56..94fd3d9 100644 --- a/app/web/static/js/app.js +++ b/app/web/static/js/app.js @@ -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 = ` +
+

No server selected

+

Configure or select a server from the control panel.

+ Open Servers Control Panel +
+ `; + } + } } // Inizializzare l'app quando il DOM è pronto diff --git a/app/web/static/js/data-sync.worker.js b/app/web/static/js/data-sync.worker.js index 98fbc12..d8d73df 100644 --- a/app/web/static/js/data-sync.worker.js +++ b/app/web/static/js/data-sync.worker.js @@ -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(); } diff --git a/app/web/static/js/models-running.js b/app/web/static/js/models-running.js index b4bc5c2..52a27fe 100644 --- a/app/web/static/js/models-running.js +++ b/app/web/static/js/models-running.js @@ -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 = ` +
+

No server selected

+

Select a server in the control panel to load ollama ps data.

+ Open Servers Control Panel +
+ `; + } + } + renderStats(models) { const runningCountEl = document.getElementById("running-count"); const vramTotalEl = document.getElementById("vram-total"); diff --git a/app/web/static/js/server-config.js b/app/web/static/js/server-config.js new file mode 100644 index 0000000..ef07e98 --- /dev/null +++ b/app/web/static/js/server-config.js @@ -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}`; +} diff --git a/app/web/static/js/servers.js b/app/web/static/js/servers.js new file mode 100644 index 0000000..fbcc516 --- /dev/null +++ b/app/web/static/js/servers.js @@ -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 = ` +
+ No servers configured yet. Add your first Ollama endpoint in the control panel. +
+ `; + return; + } + + this.serversList.innerHTML = servers + .map((server) => { + const isActive = server.id === activeServerId; + return ` +
+
+
+

${this.escapeHtml(server.name)}

+

${this.escapeHtml(server.host)}

+
+
+ + + + + +
+
+
+ `; + }) + .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(); +}); diff --git a/app/web/static/js/service-worker.js b/app/web/static/js/service-worker.js index 045ad62..18786b2 100644 --- a/app/web/static/js/service-worker.js +++ b/app/web/static/js/service-worker.js @@ -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")); }) ); }); diff --git a/app/web/templates/index.html b/app/web/templates/index.html index 88c3775..cf39735 100644 --- a/app/web/templates/index.html +++ b/app/web/templates/index.html @@ -51,7 +51,9 @@

LLM Monitor

- Running Models + Servers + Running Models +
Checking... @@ -147,6 +149,7 @@ + diff --git a/app/web/templates/models_running.html b/app/web/templates/models_running.html index 3f97207..bfb7599 100644 --- a/app/web/templates/models_running.html +++ b/app/web/templates/models_running.html @@ -34,7 +34,9 @@
- Available Models + Servers + Available Models +
@@ -77,6 +79,7 @@ + diff --git a/app/web/templates/servers.html b/app/web/templates/servers.html new file mode 100644 index 0000000..a19b50f --- /dev/null +++ b/app/web/templates/servers.html @@ -0,0 +1,79 @@ + + + + + + LLM Monitor - Servers + + + + + + + + +
+
+
+
+
+
+ 🌐 +
+
+

LLM Monitor Servers

+

Configure Ollama endpoints and open per-server dashboards

+
+
+ +
+
+
+ +
+
+
+
+

Configured Servers

+ 0 servers +
+
+
+ +
+

Control Panel

+
+ +
+ + +
+
+ + +
+
+ + +
+
+

All server profiles are saved to localStorage on this device.

+
+
+
+ + +
+ + + + + + diff --git a/main.py b/main.py index 122a0f8..1edade3 100644 --- a/main.py +++ b/main.py @@ -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") diff --git a/tests/test_api.py b/tests/test_api.py index 16b52f0..1593d66 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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: