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
+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"));
})
);
});