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
+
+
+
+
+
+
+ 🌐
+
+
+
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/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 @@
@@ -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 @@
+
+
+