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
"""
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
View File
@@ -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
View File
@@ -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
+40 -8
View File
@@ -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();
}
+58 -1
View File
@@ -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");
+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 = [
"/",
"/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"));
})
);
});
+4 -1
View File
@@ -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>
+4 -1
View File
@@ -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>
+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>