Files
llm-monitor/app/web/static/js/app.js
T

734 lines
27 KiB
JavaScript

/**
* 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 = `
<div class="text-center py-8 text-gray-400">
<p>No models loaded</p>
</div>
`;
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 `
<div data-model-key="${modelKey}" class="bg-gray-700 rounded-lg p-4 border border-gray-600 hover:border-purple-500 hover:-translate-y-0.5 transition cursor-pointer h-full">
<div class="flex items-start justify-between mb-3">
<h3 class="text-lg font-semibold">${modelName}</h3>
<span class="bg-purple-600 px-3 py-1 rounded text-xs font-medium">Loaded</span>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-gray-400">Size</p>
<p class="font-semibold">${this.formatBytes(model.size)}</p>
</div>
<div>
<p class="text-gray-400">Last Updated</p>
<p class="font-semibold">${formattedDate}</p>
</div>
</div>
<div class="mt-3">
<p class="text-gray-400 text-xs">Digest</p>
<p class="font-mono text-xs bg-gray-800 p-2 rounded mt-1 break-all">${this.escapeHtml(model.digest.substring(0, 64))}...</p>
</div>
<p class="text-xs text-purple-300 mt-3">Hover or click to view show details</p>
</div>
`;
}
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.innerHTML = `
<div class="flex items-center gap-2 text-gray-400 text-sm py-4">
<svg class="animate-spin w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
</svg>
Loading details…
</div>`;
this.loadModelShowDetails(modelName, detailsContent);
return;
}
detailsContent.innerHTML = this.buildAccordionHTML(showData);
}
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.innerHTML = this.buildAccordionHTML(showData);
}
} catch (error) {
console.error(error);
if (this.selectedModelName === modelName) {
detailsContent.innerHTML = '<p class="text-gray-400 text-sm py-2">Show details are not available for this model.</p>';
}
}
}
// ── Accordion helpers ────────────────────────────────────────────────────
buildAccordionHTML(showData) {
if (!showData || typeof showData !== "object") {
return '<p class="text-gray-400 text-sm py-2">No details available.</p>';
}
const sectionOrder = ["details", "model_info", "parameters", "template", "modelfile", "license"];
const allKeys = Object.keys(showData);
const orderedKeys = [
...sectionOrder.filter(k => k in showData),
...allKeys.filter(k => !sectionOrder.includes(k))
];
let html = '<div class="space-y-2">';
orderedKeys.forEach((key, index) => {
const value = showData[key];
const isFirst = index === 0;
const contentId = `acc-${key.replace(/[^a-z0-9]/gi, "-")}`;
const label = this.formatAccordionLabel(key);
const body = this.renderAccordionBody(key, value);
html += `
<div class="border border-gray-700 rounded-lg overflow-hidden">
<button type="button"
class="accordion-header w-full flex items-center justify-between px-4 py-2.5 bg-gray-800 hover:bg-gray-700 text-left transition-colors duration-150"
onclick="app.toggleAccordion('${contentId}', this)"
aria-expanded="${isFirst}">
<span class="font-semibold text-sm text-gray-200">${label}</span>
<svg class="accordion-chevron text-gray-400"
width="16" height="16" style="flex-shrink:0;transition:transform 0.2s;transform:${isFirst ? "rotate(180deg)" : "rotate(0deg)"}"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="${contentId}" class="accordion-content bg-gray-900 border-t border-gray-700 ${isFirst ? "" : "hidden"}">
<div class="px-4 py-3">${body}</div>
</div>
</div>`;
});
html += "</div>";
return html;
}
formatAccordionLabel(key) {
const labels = {
details: "Details",
model_info: "Model Info",
parameters: "Parameters",
template: "Template",
modelfile: "Modelfile",
license: "License"
};
const icons = {
details: "▦",
model_info: "🧠",
parameters: "⚙",
template: "📄",
modelfile: "📦",
license: "📜"
};
const icon = icons[key] || "▸";
const text = labels[key] || key.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
return `<span class="mr-2 text-base">${icon}</span>${text}`;
}
renderAccordionBody(key, value) {
if (key === "details" && value && typeof value === "object" && !Array.isArray(value)) {
return this.renderDetailsGrid(value);
}
if (key === "model_info" && value && typeof value === "object" && !Array.isArray(value)) {
return this.renderModelInfoTable(value);
}
if (typeof value === "string") {
return `<pre class="text-xs text-gray-300 whitespace-pre-wrap font-mono leading-relaxed max-h-60 overflow-y-auto">${this.escapeHtml(value)}</pre>`;
}
if (value && typeof value === "object") {
return this.renderKeyValueList(value);
}
return `<span class="text-sm text-gray-300">${this.escapeHtml(String(value))}</span>`;
}
renderDetailsGrid(details) {
const labelMap = {
family: "Family",
families: "Families",
parameter_size: "Parameters",
quantization_level: "Quantization",
format: "Format",
parent_model: "Parent Model"
};
let html = '<div class="grid grid-cols-2 gap-x-6 gap-y-3">';
for (const [k, v] of Object.entries(details)) {
const label = labelMap[k] || k.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
const display = Array.isArray(v) ? v.join(", ") : String(v);
html += `
<div class="flex flex-col">
<span class="text-xs text-gray-500 uppercase tracking-wide">${this.escapeHtml(label)}</span>
<span class="text-sm text-gray-200 font-medium mt-0.5">${this.escapeHtml(display)}</span>
</div>`;
}
html += "</div>";
return html;
}
renderModelInfoTable(modelInfo) {
let html = '<dl class="space-y-1.5">';
for (const [k, v] of Object.entries(modelInfo)) {
const display = typeof v === "object" ? JSON.stringify(v) : String(v);
html += `
<div class="flex gap-3 text-xs">
<dt class="text-gray-500 font-mono shrink-0 w-5/12 truncate" title="${this.escapeHtml(k)}">${this.escapeHtml(k)}</dt>
<dd class="text-gray-300 break-all">${this.escapeHtml(display)}</dd>
</div>`;
}
html += "</dl>";
return html;
}
renderKeyValueList(obj) {
let html = '<dl class="space-y-1.5">';
for (const [k, v] of Object.entries(obj)) {
const display = typeof v === "object" ? JSON.stringify(v) : String(v);
html += `
<div class="flex gap-3 text-xs">
<dt class="text-gray-500 shrink-0 w-1/3">${this.escapeHtml(k)}</dt>
<dd class="text-gray-300 break-all">${this.escapeHtml(display)}</dd>
</div>`;
}
html += "</dl>";
return html;
}
escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
toggleAccordion(contentId, btn) {
const content = document.getElementById(contentId);
if (!content) return;
const isHidden = content.classList.contains("hidden");
content.classList.toggle("hidden", !isHidden);
const chevron = btn.querySelector(".accordion-chevron");
if (chevron) {
chevron.style.transform = isHidden ? "rotate(180deg)" : "";
}
btn.setAttribute("aria-expanded", String(isHidden));
}
// ── Fine accordion helpers ───────────────────────────────────────────────
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 = `
<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>
`;
}
}
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 = `
<div class="text-center py-8">
<div class="animate-spin inline-block w-8 h-8 border-4 border-gray-600 border-t-purple-500 rounded-full"></div>
<p class="text-gray-400 mt-4">Loading models...</p>
</div>
`;
}
}
// Inizializzare l'app quando il DOM è pronto
document.addEventListener("DOMContentLoaded", () => {
window.app = new LLMMonitorApp();
});