335 lines
11 KiB
JavaScript
335 lines
11 KiB
JavaScript
class RunningModelsPage {
|
|
constructor() {
|
|
this.activeServer = getActiveServer();
|
|
this.worker = null;
|
|
this.lastRunningData = null;
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.updateServerContextUI();
|
|
|
|
if (!this.activeServer) {
|
|
this.renderNoServerState();
|
|
return;
|
|
}
|
|
|
|
this.loadFromLocalStorage();
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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"));
|
|
if (!response.ok) {
|
|
throw new Error("Failed to load running models");
|
|
}
|
|
|
|
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.lastRunningData = runningData;
|
|
writeServerCache(this.activeServer.id, "running", runningData);
|
|
this.renderStats(models, runningData.timestamp);
|
|
this.renderRunningModels(models);
|
|
} catch (error) {
|
|
container.innerHTML = `
|
|
<div class="text-center py-8 text-red-400">
|
|
<p>Failed to load ollama ps output</p>
|
|
</div>
|
|
`;
|
|
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);
|
|
return `${url.pathname}${url.search}`;
|
|
}
|
|
|
|
updateServerContextUI() {
|
|
if (!this.activeServer) {
|
|
return;
|
|
}
|
|
|
|
const serverLabel = document.getElementById("active-server-label");
|
|
if (serverLabel) {
|
|
serverLabel.textContent = `Server: ${this.activeServer.name}`;
|
|
serverLabel.classList.remove("hidden");
|
|
}
|
|
|
|
const availableLink = document.getElementById("available-link");
|
|
if (availableLink) {
|
|
availableLink.href = buildServerUrl("/models-available", this.activeServer.id);
|
|
}
|
|
|
|
const serversLink = document.getElementById("servers-link");
|
|
if (serversLink) {
|
|
serversLink.href = "/servers";
|
|
}
|
|
}
|
|
|
|
renderNoServerState() {
|
|
const container = document.getElementById("running-models");
|
|
const runningCountEl = document.getElementById("running-count");
|
|
const vramTotalEl = document.getElementById("vram-total");
|
|
const lastRefreshEl = document.getElementById("last-refresh");
|
|
|
|
if (runningCountEl) runningCountEl.textContent = "0";
|
|
if (vramTotalEl) vramTotalEl.textContent = "0 B";
|
|
if (lastRefreshEl) lastRefreshEl.textContent = "-";
|
|
|
|
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">Select a server in the control panel to load ollama ps data.</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>
|
|
`;
|
|
}
|
|
}
|
|
|
|
renderStats(models, timestamp = null) {
|
|
const runningCountEl = document.getElementById("running-count");
|
|
const vramTotalEl = document.getElementById("vram-total");
|
|
const lastRefreshEl = document.getElementById("last-refresh");
|
|
|
|
const totalVram = models.reduce((sum, model) => sum + (model.size_vram || 0), 0);
|
|
|
|
if (runningCountEl) {
|
|
runningCountEl.textContent = String(models.length);
|
|
}
|
|
if (vramTotalEl) {
|
|
vramTotalEl.textContent = this.formatBytes(totalVram);
|
|
}
|
|
if (lastRefreshEl) {
|
|
lastRefreshEl.textContent = timestamp ? this.formatDateTime(timestamp) : "-";
|
|
}
|
|
}
|
|
|
|
renderRunningModels(models) {
|
|
const container = document.getElementById("running-models");
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
if (models.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="text-center py-8 text-gray-400">
|
|
<p>No models are currently loaded in memory.</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = models
|
|
.map((model) => this.renderModelCard(model))
|
|
.join("");
|
|
}
|
|
|
|
renderModelCard(model) {
|
|
const name = this.escapeHtml(model.name || "unknown");
|
|
const modelId = this.escapeHtml(model.model || "-");
|
|
const size = this.formatBytes(model.size || 0);
|
|
const sizeVram = this.formatBytes(model.size_vram || 0);
|
|
const processor = this.escapeHtml(model.details?.processor || "-");
|
|
const expiresAt = model.expires_at ? this.formatDateTime(model.expires_at) : "-";
|
|
|
|
return `
|
|
<div class="bg-gray-700 rounded-lg p-4 border border-gray-600">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h3 class="text-lg font-semibold">${name}</h3>
|
|
<p class="text-xs text-gray-400 mt-1">${modelId}</p>
|
|
</div>
|
|
<span class="bg-green-700 text-green-100 text-xs px-2 py-1 rounded">Ready</span>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-4 text-sm">
|
|
<div class="bg-gray-800 rounded p-3">
|
|
<p class="text-gray-400 text-xs">Model Size</p>
|
|
<p class="font-semibold mt-1">${size}</p>
|
|
</div>
|
|
<div class="bg-gray-800 rounded p-3">
|
|
<p class="text-gray-400 text-xs">VRAM Used</p>
|
|
<p class="font-semibold mt-1">${sizeVram}</p>
|
|
</div>
|
|
<div class="bg-gray-800 rounded p-3">
|
|
<p class="text-gray-400 text-xs">Processor</p>
|
|
<p class="font-semibold mt-1">${processor}</p>
|
|
</div>
|
|
<div class="bg-gray-800 rounded p-3">
|
|
<p class="text-gray-400 text-xs">Unload Time</p>
|
|
<p class="font-semibold mt-1">${expiresAt}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
formatBytes(bytes) {
|
|
if (!bytes || bytes <= 0) {
|
|
return "0 B";
|
|
}
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
const value = bytes / Math.pow(1024, index);
|
|
return `${value.toFixed(2)} ${units[index]}`;
|
|
}
|
|
|
|
formatDateTime(isoDate) {
|
|
const date = new Date(isoDate);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return "-";
|
|
}
|
|
|
|
return date.toLocaleString("en-US", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit"
|
|
});
|
|
}
|
|
|
|
escapeHtml(text) {
|
|
const div = document.createElement("div");
|
|
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", () => {
|
|
window.runningModelsPage = new RunningModelsPage();
|
|
});
|