fix: serve cached server data before background sync

This commit is contained in:
Luca Sacchi Ricciardi
2026-04-25 15:57:37 +02:00
parent f60781bd7f
commit 9649f2ccfb
4 changed files with 306 additions and 46 deletions
+66 -27
View File
@@ -26,6 +26,9 @@ class LLMMonitorApp {
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');
@@ -35,19 +38,22 @@ class LLMMonitorApp {
// 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
host: this.activeServer.host,
syncImmediately: shouldSyncImmediately,
lastSyncTimestamp: this.getLatestCacheTimestamp()
});
} else {
if (shouldSyncImmediately) {
this.renderLoadingState();
}
} else if (this.shouldSyncImmediately()) {
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();
@@ -73,31 +79,23 @@ class LLMMonitorApp {
// Caricare dati da localStorage
loadFromLocalStorage() {
const healthStr = localStorage.getItem(this.getStorageKey("health"));
const modelsStr = localStorage.getItem(this.getStorageKey("models"));
const health = readServerCache(this.activeServer.id, "health");
const models = readServerCache(this.activeServer.id, "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 (health) {
this.lastData.health = health;
this.renderHealth(this.lastData.health);
}
if (modelsStr) {
try {
this.lastData.models = JSON.parse(modelsStr);
this.renderModels(this.lastData.models);
} catch (e) {
console.error("Error parsing models data:", e);
}
if (models) {
this.lastData.models = models;
this.renderModels(this.lastData.models);
}
}
// Gestire messaggi dal Worker
handleWorkerMessage(event) {
const { type, health, modelsData, serverId } = event.data;
const { type, health, modelsData, runningData, serverId } = event.data;
if (serverId && this.activeServer && serverId !== this.activeServer.id) {
return;
@@ -107,7 +105,7 @@ class LLMMonitorApp {
if (health && JSON.stringify(this.lastData.health) !== JSON.stringify(health)) {
this.lastData.health = health;
try {
localStorage.setItem(this.getStorageKey("health"), JSON.stringify(health));
writeServerCache(this.activeServer.id, "health", health);
} catch (error) {
console.warn("Cannot persist health in localStorage:", error);
}
@@ -117,7 +115,7 @@ class LLMMonitorApp {
if (modelsData && JSON.stringify(this.lastData.models) !== JSON.stringify(modelsData)) {
this.lastData.models = modelsData;
try {
localStorage.setItem(this.getStorageKey("models"), JSON.stringify(modelsData));
writeServerCache(this.activeServer.id, "models", modelsData);
} catch (error) {
console.warn("Cannot persist models in localStorage:", error);
}
@@ -126,6 +124,14 @@ class LLMMonitorApp {
this.showModelDetails(this.selectedModelName);
}
}
if (runningData) {
try {
writeServerCache(this.activeServer.id, "running", runningData);
} catch (error) {
console.warn("Cannot persist running models in localStorage:", error);
}
}
}
}
@@ -365,7 +371,7 @@ class LLMMonitorApp {
if (response.ok) {
const health = await response.json();
this.lastData.health = health;
localStorage.setItem(this.getStorageKey("health"), JSON.stringify(health));
writeServerCache(this.activeServer.id, "health", health);
this.renderHealth(health);
}
} catch (error) {
@@ -396,7 +402,7 @@ class LLMMonitorApp {
timestamp: new Date().toISOString()
};
this.lastData.models = modelsData;
localStorage.setItem(this.getStorageKey("models"), JSON.stringify(modelsData));
writeServerCache(this.activeServer.id, "models", modelsData);
this.renderModels(modelsData);
if (this.selectedModelName) {
this.showModelDetails(this.selectedModelName);
@@ -408,7 +414,22 @@ class LLMMonitorApp {
}
getStorageKey(suffix) {
return `llm_monitor_${suffix}_${this.activeServer.id}`;
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) {
@@ -463,6 +484,24 @@ class LLMMonitorApp {
`;
}
}
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
+54 -4
View File
@@ -7,6 +7,7 @@ const API_BASE = "/api/v1";
const REFRESH_INTERVAL = 30000; // 30 secondi
let activeServerId = null;
let activeHost = null;
let nextSyncTimeout = null;
// Formattare bytes
function formatBytes(bytes) {
@@ -99,6 +100,30 @@ async function fetchAllModelsShow(models) {
return showByModel;
}
async function fetchRunningModels() {
if (!activeHost) {
return null;
}
try {
const response = await fetch(buildApiUrl(`${API_BASE}/models/running`));
if (!response.ok) {
throw new Error("Failed to load running models");
}
const data = await response.json();
return {
models: data.models || [],
total: data.total || (data.models || []).length,
timestamp: new Date().toISOString(),
serverId: activeServerId
};
} catch (error) {
console.error("Error loading running models:", error);
return null;
}
}
// Sincronizzare i dati
async function syncData() {
if (!activeHost) {
@@ -113,6 +138,7 @@ async function syncData() {
const health = await fetchHealth();
const modelsData = await fetchModels();
const runningData = await fetchRunningModels();
if (modelsData && modelsData.models.length > 0) {
modelsData.showByModel = await fetchAllModelsShow(modelsData.models);
@@ -126,6 +152,7 @@ async function syncData() {
type: "DATA_UPDATED",
health,
modelsData,
runningData,
serverId: activeServerId
});
}
@@ -136,18 +163,41 @@ function buildApiUrl(path) {
return `${url.pathname}${url.search}`;
}
// Pianificare aggiornamenti periodici
setInterval(syncData, REFRESH_INTERVAL);
function clearNextSync() {
if (nextSyncTimeout) {
clearTimeout(nextSyncTimeout);
nextSyncTimeout = null;
}
}
function scheduleNextSync(lastSyncTimestamp = 0) {
clearNextSync();
const ageMs = lastSyncTimestamp ? Math.max(0, Date.now() - lastSyncTimestamp) : REFRESH_INTERVAL;
const delayMs = Math.max(0, REFRESH_INTERVAL - ageMs);
nextSyncTimeout = setTimeout(async () => {
await syncData();
scheduleNextSync(Date.now());
}, delayMs);
}
// Gestire messaggi dal main thread
self.onmessage = (event) => {
if (event.data.type === "SET_SERVER") {
activeServerId = event.data.serverId || null;
activeHost = event.data.host || null;
syncData();
const lastSyncTimestamp = Number(event.data.lastSyncTimestamp || 0);
if (event.data.syncImmediately) {
syncData().finally(() => scheduleNextSync(Date.now()));
return;
}
scheduleNextSync(lastSyncTimestamp);
}
if (event.data.type === "SYNC_NOW") {
syncData();
syncData().finally(() => scheduleNextSync(Date.now()));
}
};
+132 -15
View File
@@ -1,6 +1,8 @@
class RunningModelsPage {
constructor() {
this.activeServer = getActiveServer();
this.worker = null;
this.lastRunningData = null;
this.init();
}
@@ -12,25 +14,105 @@ class RunningModelsPage {
return;
}
document.getElementById("refresh-btn")?.addEventListener("click", () => {
this.loadRunningModels();
});
this.loadFromLocalStorage();
this.loadRunningModels();
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);
this.loadRunningModels(true);
};
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.lastRunningData) {
this.renderLoadingState();
}
} else if (this.shouldSyncImmediately()) {
this.loadRunningModels(true);
}
document.getElementById("refresh-btn")?.addEventListener("click", () => {
if (this.worker) {
this.worker.postMessage({ type: "SYNC_NOW" });
} else {
this.loadRunningModels(true);
}
});
}
async loadRunningModels() {
loadFromLocalStorage() {
const runningData = readServerCache(this.activeServer.id, "running");
if (!runningData) {
return;
}
this.lastRunningData = runningData;
this.renderStats(runningData.models || [], runningData.timestamp);
this.renderRunningModels(runningData.models || []);
}
handleWorkerMessage(event) {
const { type, health, modelsData, runningData, serverId } = event.data;
if (type !== "DATA_UPDATED") {
return;
}
if (serverId && serverId !== this.activeServer.id) {
return;
}
if (health) {
try {
writeServerCache(this.activeServer.id, "health", health);
} catch (error) {
console.warn("Cannot persist health in localStorage:", error);
}
}
if (modelsData) {
try {
writeServerCache(this.activeServer.id, "models", modelsData);
} catch (error) {
console.warn("Cannot persist models in localStorage:", error);
}
}
if (!runningData) {
return;
}
this.lastRunningData = runningData;
try {
writeServerCache(this.activeServer.id, "running", runningData);
} catch (error) {
console.warn("Cannot persist running models in localStorage:", error);
}
this.renderStats(runningData.models || [], runningData.timestamp);
this.renderRunningModels(runningData.models || []);
}
async loadRunningModels(forceNetwork = false) {
const container = document.getElementById("running-models");
if (!container) {
return;
}
container.innerHTML = `
<div class="text-center py-8">
<div class="inline-block w-8 h-8 border-4 border-gray-600 border-t-purple-500 rounded-full animate-spin"></div>
<p class="text-gray-400 mt-4">Refreshing data...</p>
</div>
`;
if (!forceNetwork && this.lastRunningData) {
this.renderStats(this.lastRunningData.models || [], this.lastRunningData.timestamp);
this.renderRunningModels(this.lastRunningData.models || []);
return;
}
this.renderLoadingState();
try {
const response = await fetch(this.buildApiUrl("/api/v1/models/running"));
@@ -40,8 +122,16 @@ class RunningModelsPage {
const data = await response.json();
const models = data.models || [];
const runningData = {
models,
total: data.total || models.length,
timestamp: new Date().toISOString(),
serverId: this.activeServer.id
};
this.renderStats(models);
this.lastRunningData = runningData;
writeServerCache(this.activeServer.id, "running", runningData);
this.renderStats(models, runningData.timestamp);
this.renderRunningModels(models);
} catch (error) {
container.innerHTML = `
@@ -49,11 +139,24 @@ class RunningModelsPage {
<p>Failed to load ollama ps output</p>
</div>
`;
this.renderStats([]);
this.renderStats([], null);
console.error(error);
}
}
shouldSyncImmediately() {
const running = readServerCache(this.activeServer.id, "running");
if (!running) {
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);
@@ -103,7 +206,7 @@ class RunningModelsPage {
}
}
renderStats(models) {
renderStats(models, timestamp = null) {
const runningCountEl = document.getElementById("running-count");
const vramTotalEl = document.getElementById("vram-total");
const lastRefreshEl = document.getElementById("last-refresh");
@@ -117,7 +220,7 @@ class RunningModelsPage {
vramTotalEl.textContent = this.formatBytes(totalVram);
}
if (lastRefreshEl) {
lastRefreshEl.textContent = new Date().toLocaleString("en-US");
lastRefreshEl.textContent = timestamp ? this.formatDateTime(timestamp) : "-";
}
}
@@ -210,6 +313,20 @@ class RunningModelsPage {
div.textContent = String(text);
return div.innerHTML;
}
renderLoadingState() {
const container = document.getElementById("running-models");
if (!container || this.lastRunningData) {
return;
}
container.innerHTML = `
<div class="text-center py-8">
<div class="inline-block w-8 h-8 border-4 border-gray-600 border-t-purple-500 rounded-full animate-spin"></div>
<p class="text-gray-400 mt-4">Refreshing data...</p>
</div>
`;
}
}
document.addEventListener("DOMContentLoaded", () => {
+54
View File
@@ -1,5 +1,6 @@
const SERVER_STORAGE_KEY = "llm_monitor_servers";
const ACTIVE_SERVER_KEY = "llm_monitor_active_server";
const DATA_REFRESH_INTERVAL_MS = 30000;
function normalizeHost(host) {
if (!host) {
@@ -97,3 +98,56 @@ function buildServerUrl(path, serverId) {
}
return `${url.pathname}${url.search}`;
}
function getServerStorageKey(serverId, suffix) {
return `llm_monitor_${suffix}_${serverId}`;
}
function readServerCache(serverId, suffix) {
if (!serverId) {
return null;
}
const raw = localStorage.getItem(getServerStorageKey(serverId, suffix));
if (!raw) {
return null;
}
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function writeServerCache(serverId, suffix, value) {
if (!serverId) {
return;
}
localStorage.setItem(getServerStorageKey(serverId, suffix), JSON.stringify(value));
}
function getCacheTimestamp(cacheValue) {
if (!cacheValue || !cacheValue.timestamp) {
return 0;
}
const parsed = Date.parse(cacheValue.timestamp);
return Number.isNaN(parsed) ? 0 : parsed;
}
function getLatestServerCacheTimestamp(serverId, suffixes) {
return suffixes.reduce((latest, suffix) => {
const value = readServerCache(serverId, suffix);
return Math.max(latest, getCacheTimestamp(value));
}, 0);
}
function isCacheStale(timestamp, maxAgeMs = DATA_REFRESH_INTERVAL_MS) {
if (!timestamp) {
return true;
}
return (Date.now() - timestamp) >= maxAgeMs;
}