feat: add multi-server control panel and host-aware sync

This commit is contained in:
Luca Sacchi Ricciardi
2026-04-25 15:40:20 +02:00
parent 3ba6a9a41c
commit f60781bd7f
13 changed files with 641 additions and 41 deletions
+21 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
+40 -8
View File
@@ -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();
} }
+58 -1
View File
@@ -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");
+99
View File
@@ -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}`;
}
+178
View File
@@ -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();
});
+5 -2
View File
@@ -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"));
}) })
); );
}); });
+4 -1
View File
@@ -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>
+4 -1
View File
@@ -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>
+79
View File
@@ -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>
+10 -4
View File
@@ -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")
+25
View File
@@ -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: