/** * LLM Monitor - Main App * Gestisce il Web Worker e aggiorna il DOM in modo efficiente */ class LLMMonitorApp { constructor() { this.worker = null; this.selectedModelName = null; this.isModalOpen = false; this.hoverOpenDelayMs = 180; this.hoverOpenTimer = null; this.lastData = { health: null, models: null }; this.init(); } init() { // 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(); }; } else { console.warn("Web Workers not supported, using main thread"); this.syncDataInMainThread(); } // Caricare dati da localStorage this.loadFromLocalStorage(); // 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 healthStr = localStorage.getItem("llm_monitor_health"); const modelsStr = localStorage.getItem("llm_monitor_models"); if (healthStr) { try { this.lastData.health = JSON.parse(healthStr); this.renderHealth(this.lastData.health); } catch (e) { console.error("Error parsing health data:", e); } } if (modelsStr) { try { this.lastData.models = JSON.parse(modelsStr); this.renderModels(this.lastData.models); } catch (e) { console.error("Error parsing models data:", e); } } } // Gestire messaggi dal Worker handleWorkerMessage(event) { const { type, health, modelsData } = event.data; 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)); } 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 { localStorage.setItem("llm_monitor_models", JSON.stringify(modelsData)); } catch (error) { console.warn("Cannot persist models in localStorage:", error); } this.renderModels(modelsData); if (this.selectedModelName) { this.showModelDetails(this.selectedModelName); } } } } // 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 = "Show details are not available for this model."; return; } detailsContent.textContent = JSON.stringify(showData, null, 2); } 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.worker) { this.worker.postMessage({ type: "SYNC_NOW" }); } else { this.syncDataInMainThread(); } } // Fallback: sincronizzazione nel main thread async syncDataInMainThread() { try { const response = await fetch("/api/v1/health"); if (response.ok) { const health = await response.json(); this.lastData.health = health; localStorage.setItem("llm_monitor_health", JSON.stringify(health)); this.renderHealth(health); } } catch (error) { console.error("Health check error:", error); } try { const response = await fetch("/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(`/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; localStorage.setItem("llm_monitor_models", JSON.stringify(modelsData)); this.renderModels(modelsData); if (this.selectedModelName) { this.showModelDetails(this.selectedModelName); } } } catch (error) { console.error("Models loading error:", error); } } } // Inizializzare l'app quando il DOM è pronto document.addEventListener("DOMContentLoaded", () => { window.app = new LLMMonitorApp(); });