541 lines
19 KiB
JavaScript
541 lines
19 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);
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
writeServerCache(this.activeServer.id, "models", modelsData);
|
|
} catch (error) {
|
|
console.warn("Cannot persist models in localStorage:", error);
|
|
}
|
|
this.renderModels(modelsData);
|
|
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.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;
|
|
writeServerCache(this.activeServer.id, "models", modelsData);
|
|
this.renderModels(modelsData);
|
|
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");
|
|
|
|
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>
|
|
`;
|
|
}
|
|
}
|
|
|
|
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();
|
|
});
|