/** * 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.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(); }); // Listener click card modello document.getElementById("models-container")?.addEventListener("click", (event) => { const card = event.target.closest("[data-model-key]"); if (!card) { return; } const modelKey = card.getAttribute("data-model-key"); if (!modelKey) { return; } this.showModelDetails(decodeURIComponent(modelKey)); }); } // 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 = `

Nessun modello caricato

`; 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; } } // 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}

Caricato

Dimensione

${this.formatBytes(model.size)}

Ultimo aggiornamento

${formattedDate}

Digest

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

Clicca per vedere dettagli show

`; } showModelDetails(modelName) { const detailsSection = document.getElementById("model-details-section"); const detailsName = document.getElementById("model-details-name"); const detailsContent = document.getElementById("model-details-content"); if (!detailsSection || !detailsName || !detailsContent || !this.lastData.models) { return; } const showByModel = this.lastData.models.showByModel || {}; const showData = showByModel[modelName]; this.selectedModelName = modelName; detailsSection.classList.remove("hidden"); detailsName.textContent = modelName; detailsSection.scrollIntoView({ behavior: "smooth", block: "start" }); if (!showData) { detailsContent.textContent = "Dettagli show non disponibili per questo modello."; return; } detailsContent.textContent = JSON.stringify(showData, null, 2); } // 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("it-IT", { 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(); });