/** * LLM Monitor - Main App * Gestisce il Web Worker e aggiorna il DOM in modo efficiente */ class LLMMonitorApp { constructor() { this.worker = null; this.activeServer = getActiveServer(); this.selectedModelName = null; this.isModalOpen = false; this.hoverOpenDelayMs = 180; this.hoverOpenTimer = null; this.lastData = { health: null, models: null }; this.init(); } init() { if (!this.activeServer) { this.renderNoServerState(); return; } this.updateServerContextUI(); // Caricare dati da localStorage prima di qualsiasi sync di rete. this.loadFromLocalStorage(); // Inizializzare il Web Worker if (typeof Worker !== 'undefined') { this.worker = new Worker('/static/js/data-sync.worker.js'); this.worker.onmessage = (event) => this.handleWorkerMessage(event); this.worker.onerror = (error) => { console.error("Worker error:", error); // Fallback: sincronizzazione nel main thread this.syncDataInMainThread(); }; const shouldSyncImmediately = this.shouldSyncImmediately(); this.worker.postMessage({ type: "SET_SERVER", serverId: this.activeServer.id, host: this.activeServer.host, syncImmediately: shouldSyncImmediately, lastSyncTimestamp: this.getLatestCacheTimestamp() }); if (shouldSyncImmediately) { this.renderLoadingState(); } } else if (this.shouldSyncImmediately()) { console.warn("Web Workers not supported, using main thread"); this.syncDataInMainThread(); } // Listener al pulsante manuale document.getElementById("refresh-btn")?.addEventListener("click", () => { this.requestSync(); }); // Chiusura modal con pulsante X document.getElementById("model-details-close")?.addEventListener("click", () => { this.hideModelDetails(); }); // Chiusura modal con click su overlay document.getElementById("model-details-backdrop")?.addEventListener("click", () => { this.hideModelDetails(); }); // Chiusura modal con tasto Esc document.addEventListener("keydown", (event) => { if (event.key === "Escape") { this.hideModelDetails(); } }); } // Caricare dati da localStorage loadFromLocalStorage() { const health = readServerCache(this.activeServer.id, "health"); const models = readServerCache(this.activeServer.id, "models"); if (health) { this.lastData.health = health; this.renderHealth(this.lastData.health); } if (models) { this.lastData.models = models; this.renderModels(this.lastData.models); } this.updateCacheModeIndicator(models); } // Gestire messaggi dal Worker handleWorkerMessage(event) { const { type, health, modelsData, runningData, 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 { writeServerCache(this.activeServer.id, "health", health); } catch (error) { console.warn("Cannot persist health in localStorage:", error); } this.renderHealth(health); } if (modelsData && JSON.stringify(this.lastData.models) !== JSON.stringify(modelsData)) { this.lastData.models = modelsData; try { const persistedModels = writeServerCache(this.activeServer.id, "models", modelsData); if (persistedModels) { this.lastData.models = persistedModels; } } catch (error) { console.warn("Cannot persist models in localStorage:", error); } this.updateCacheModeIndicator(this.lastData.models); this.renderModels(this.lastData.models); if (this.selectedModelName) { this.showModelDetails(this.selectedModelName); } } if (runningData) { try { writeServerCache(this.activeServer.id, "running", runningData); } catch (error) { console.warn("Cannot persist running models in localStorage:", error); } } } } // Renderizzare Health (aggiornamento granulare) renderHealth(health) { if (!health) return; const ollamaStatus = health.ollama_status; const statusEl = document.getElementById("status-indicator"); const statusText = document.getElementById("status-text"); const ollamaStatusEl = document.getElementById("ollama-status"); if (ollamaStatus === "online") { // Aggiornare solo se cambiato if (!statusEl.classList.contains("bg-green-500")) { statusEl.className = "w-3 h-3 bg-green-500 rounded-full"; statusText.className = "text-sm text-green-400"; statusText.textContent = "Ollama Online"; ollamaStatusEl.innerHTML = "๐ŸŸข Online"; } } else { if (!statusEl.classList.contains("bg-red-500")) { statusEl.className = "w-3 h-3 bg-red-500 rounded-full"; statusText.className = "text-sm text-red-400"; statusText.textContent = "Ollama Offline"; ollamaStatusEl.innerHTML = "๐Ÿ”ด Offline"; } } } // Renderizzare Modelli (aggiornamento granulare) renderModels(modelsData) { if (!modelsData) return; // Aggiornare conteggio document.getElementById("models-count").textContent = modelsData.total; // Aggiornare spazio totale document.getElementById("total-size").textContent = modelsData.totalSize; // Aggiornare lista modelli const container = document.getElementById("models-container"); const { models } = modelsData; if (models.length === 0) { container.innerHTML = `

No models loaded

`; return; } // Comparare con il rendering precedente (evitare re-render se identico) const newHTML = models.map(model => this.renderModelCard(model)).join(""); // Aggiornare solo se veramente diverso if (container.innerHTML !== newHTML) { container.innerHTML = newHTML; this.bindModelCardInteractions(); } } // Associare eventi card dopo ogni render (piu affidabile della delega su hover) bindModelCardInteractions() { const cards = document.querySelectorAll("#models-container [data-model-key]"); cards.forEach((card) => { if (card.dataset.modalBound === "true") { return; } const modelKey = card.getAttribute("data-model-key"); if (!modelKey) { return; } const modelName = decodeURIComponent(modelKey); card.dataset.modalBound = "true"; card.addEventListener("click", () => { this.toggleModelDetails(modelName); }); card.addEventListener("mouseenter", () => { if (this.hoverOpenTimer) { clearTimeout(this.hoverOpenTimer); } this.hoverOpenTimer = setTimeout(() => { this.showModelDetails(modelName); }, this.hoverOpenDelayMs); }); card.addEventListener("mouseleave", () => { if (this.hoverOpenTimer) { clearTimeout(this.hoverOpenTimer); this.hoverOpenTimer = null; } }); }); } toggleModelDetails(modelName) { if (this.isModalOpen && this.selectedModelName === modelName) { this.hideModelDetails(); return; } this.showModelDetails(modelName); } // Renderizzare singola card modello renderModelCard(model) { const formattedDate = this.formatDate(model.modified_at); const modelName = this.escapeHtml(model.name); const modelKey = encodeURIComponent(model.name); return `

${modelName}

Loaded

Size

${this.formatBytes(model.size)}

Last Updated

${formattedDate}

Digest

${this.escapeHtml(model.digest.substring(0, 64))}...

Hover or click to view show details

`; } showModelDetails(modelName) { const detailsModal = document.getElementById("model-details-modal"); const detailsDialog = document.getElementById("model-details-dialog"); const detailsName = document.getElementById("model-details-name"); const detailsContent = document.getElementById("model-details-content"); if (!detailsModal || !detailsDialog || !detailsName || !detailsContent || !this.lastData.models) { return; } const showByModel = this.lastData.models.showByModel || {}; const showData = showByModel[modelName]; this.selectedModelName = modelName; this.isModalOpen = true; detailsModal.classList.remove("hidden"); detailsModal.classList.add("flex"); detailsDialog.classList.add("flex"); document.body.classList.add("overflow-hidden"); detailsName.textContent = modelName; detailsModal.setAttribute("aria-hidden", "false"); if (!showData) { detailsContent.textContent = "Loading show details..."; this.loadModelShowDetails(modelName, detailsContent); return; } detailsContent.textContent = JSON.stringify(showData, null, 2); } async loadModelShowDetails(modelName, detailsContent) { try { const response = await fetch(this.buildApiUrl(`/api/v1/models/${encodeURIComponent(modelName)}/show`)); if (!response.ok) { throw new Error(`Failed to load show details for ${modelName}`); } const showData = await response.json(); if (!this.lastData.models) { return; } if (!this.lastData.models.showByModel) { this.lastData.models.showByModel = {}; } this.lastData.models.showByModel[modelName] = showData; if (this.selectedModelName === modelName) { detailsContent.textContent = JSON.stringify(showData, null, 2); } } catch (error) { console.error(error); if (this.selectedModelName === modelName) { detailsContent.textContent = "Show details are not available for this model."; } } } hideModelDetails() { const detailsModal = document.getElementById("model-details-modal"); const detailsDialog = document.getElementById("model-details-dialog"); if (!detailsModal || detailsModal.classList.contains("hidden")) { return; } detailsModal.classList.add("hidden"); detailsModal.classList.remove("flex"); detailsDialog?.classList.remove("flex"); document.body.classList.remove("overflow-hidden"); detailsModal.setAttribute("aria-hidden", "true"); this.isModalOpen = false; this.selectedModelName = null; } // Formattare bytes formatBytes(bytes) { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return (bytes / Math.pow(k, i)).toFixed(2) + " " + sizes[i]; } // Formattare data formatDate(dateString) { const date = new Date(dateString); return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } // Escapare HTML (prevenire XSS) escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Chiedere sincronizzazione manuale al Worker requestSync() { if (!this.activeServer) { return; } if (this.worker) { this.worker.postMessage({ type: "SYNC_NOW" }); } else { this.syncDataInMainThread(); } } // Fallback: sincronizzazione nel main thread async syncDataInMainThread() { if (!this.activeServer) { return; } try { const response = await fetch(this.buildApiUrl("/api/v1/health")); if (response.ok) { const health = await response.json(); this.lastData.health = health; writeServerCache(this.activeServer.id, "health", health); this.renderHealth(health); } } catch (error) { console.error("Health check error:", error); } try { const response = await fetch(this.buildApiUrl("/api/v1/models")); if (response.ok) { const data = await response.json(); const models = data.models || []; const showByModel = {}; await Promise.allSettled( models.map(async (model) => { const showResponse = await fetch(this.buildApiUrl(`/api/v1/models/${encodeURIComponent(model.name)}/show`)); if (showResponse.ok) { showByModel[model.name] = await showResponse.json(); } }) ); const modelsData = { models, total: models.length, totalSize: this.formatBytes(models.reduce((sum, m) => sum + m.size, 0)), showByModel, timestamp: new Date().toISOString() }; this.lastData.models = modelsData; const persistedModels = writeServerCache(this.activeServer.id, "models", modelsData); if (persistedModels) { this.lastData.models = persistedModels; } this.updateCacheModeIndicator(this.lastData.models); this.renderModels(this.lastData.models); if (this.selectedModelName) { this.showModelDetails(this.selectedModelName); } } } catch (error) { console.error("Models loading error:", error); } } getStorageKey(suffix) { return getServerStorageKey(this.activeServer.id, suffix); } shouldSyncImmediately() { const health = readServerCache(this.activeServer.id, "health"); const models = readServerCache(this.activeServer.id, "models"); if (!health || !models) { return true; } return isCacheStale(this.getLatestCacheTimestamp()); } getLatestCacheTimestamp() { return getLatestServerCacheTimestamp(this.activeServer.id, ["health", "models", "running"]); } 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"); const cacheModeIndicator = document.getElementById("cache-mode-indicator"); 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 (cacheModeIndicator) { cacheModeIndicator.classList.add("hidden"); } if (container) { container.innerHTML = `

No server selected

Configure or select a server from the control panel.

Open Servers Control Panel
`; } } updateCacheModeIndicator(modelsData) { const cacheModeIndicator = document.getElementById("cache-mode-indicator"); if (!cacheModeIndicator) { return; } if (hasDeferredShowDetails(modelsData)) { cacheModeIndicator.classList.remove("hidden"); return; } cacheModeIndicator.classList.add("hidden"); } renderLoadingState() { if (this.lastData.models) { return; } const container = document.getElementById("models-container"); if (!container) { return; } container.innerHTML = `

Loading models...

`; } } // Inizializzare l'app quando il DOM รจ pronto document.addEventListener("DOMContentLoaded", () => { window.app = new LLMMonitorApp(); });