Files
ComfyUI-LM-Remote/static/lm_stats_ui.js
T
Ethanfel b39ea7a647 fix: address bug review findings
- Debounce MutationObserver (200ms) and scope to #modelGrid when available
- Sanitize data.updated via parseInt before innerHTML to prevent XSS
- Guard pagination against page_size=0 (division by zero) and negative values
- Reject non-POST requests on /api/lm-extra/fetch-stats (state-mutating)
- Early return in upsert_batch for empty rows list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 14:48:14 +01:00

231 lines
9.5 KiB
JavaScript

// static/lm_stats_ui.js
/**
* CivitAI Stats UI — card badges, sort dropdown options, fetch button.
*
* Injected into LoRA Manager pages by the LM-Remote proxy.
* Reads stats from enriched /api/lm/loras/list responses and
* patches the DOM to show badges, sort options, and a fetch button.
*/
(function () {
"use strict";
// ── Compact number formatting ──────────────────────────────────
function formatCompact(n) {
if (n == null) return null;
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, "") + "k";
return String(n);
}
// ── Badge creation ─────────────────────────────────────────────
function createStatBadge(icon, value, title) {
if (value == null) return null;
const badge = document.createElement("span");
badge.className = "lm-stat-badge";
badge.title = title;
badge.innerHTML = `<i class="fas fa-${icon}"></i> ${formatCompact(value)}`;
return badge;
}
// ── Inject CSS ─────────────────────────────────────────────────
function injectStyles() {
if (document.getElementById("lm-stats-styles")) return;
const style = document.createElement("style");
style.id = "lm-stats-styles";
style.textContent = `
.lm-stat-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
padding: 2px 6px;
}
.lm-stat-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
padding: 1px 5px;
border-radius: 4px;
background: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.8);
white-space: nowrap;
}
.lm-stat-badge i {
font-size: 10px;
opacity: 0.7;
}
`;
document.head.appendChild(style);
}
// ── Intercept list API to capture stats ────────────────────────
const _statsMap = {};
const _origFetch = window.fetch;
window.fetch = async function (...args) {
const response = await _origFetch.apply(this, args);
const url = typeof args[0] === "string" ? args[0] : args[0]?.url;
if (url && url.includes("/api/lm/") && url.includes("/list")) {
try {
const clone = response.clone();
const data = await clone.json();
(data.items || []).forEach((item) => {
if (item.sha256 && item.download_count != null) {
_statsMap[item.sha256] = {
download_count: item.download_count,
rating: item.rating,
rating_count: item.rating_count,
thumbs_up_count: item.thumbs_up_count,
};
}
});
} catch (e) { /* ignore parse errors */ }
}
return response;
};
// ── Patch model cards ──────────────────────────────────────────
function patchCards() {
const cards = document.querySelectorAll(".model-card:not([data-stats-patched])");
cards.forEach((card) => {
card.setAttribute("data-stats-patched", "1");
const sha = card.dataset.sha256;
if (!sha || !_statsMap[sha]) return;
const stats = _statsMap[sha];
const container = document.createElement("div");
container.className = "lm-stat-badges";
const dlBadge = createStatBadge("download", stats.download_count, "Downloads");
const ratingBadge = createStatBadge("star",
stats.rating ? Number(stats.rating.toFixed(1)) : null, "Rating");
const thumbsBadge = createStatBadge("thumbs-up", stats.thumbs_up_count, "Likes");
[dlBadge, ratingBadge, thumbsBadge].forEach((b) => {
if (b) container.appendChild(b);
});
if (container.children.length > 0) {
// Insert inside .model-info, after .model-name
const modelInfo = card.querySelector(".model-info");
if (modelInfo) {
modelInfo.appendChild(container);
}
}
});
}
// ── Patch sort dropdown ────────────────────────────────────────
function patchSortDropdown() {
const select = document.getElementById("sortSelect");
if (!select || select.querySelector('[value="downloads:desc"]')) return;
const group = document.createElement("optgroup");
group.label = "CivitAI Stats";
const options = [
["downloads:desc", "Most downloaded"],
["downloads:asc", "Least downloaded"],
["rating:desc", "Highest rated"],
["rating:asc", "Lowest rated"],
["thumbsup:desc", "Most liked"],
["thumbsup:asc", "Least liked"],
];
options.forEach(([value, label]) => {
const opt = document.createElement("option");
opt.value = value;
opt.textContent = label;
group.appendChild(opt);
});
select.appendChild(group);
}
// ── Toolbar "Fetch Stats" button ───────────────────────────────
function addFetchStatsButton() {
const toolbar = document.querySelector(".action-buttons");
if (!toolbar || document.getElementById("fetchStatsBtn")) return;
const group = document.createElement("div");
group.className = "control-group";
group.innerHTML = `
<button id="fetchStatsBtn" data-action="fetch-stats"
title="Fetch CivitAI stats (downloads, ratings, likes)">
<i class="fas fa-chart-bar"></i> <span>Fetch Stats</span>
</button>
`;
// Insert before the bulk operations button
const bulkBtn = document.getElementById("bulkOperationsBtn");
if (bulkBtn && bulkBtn.closest(".control-group")) {
toolbar.insertBefore(group, bulkBtn.closest(".control-group"));
} else {
toolbar.appendChild(group);
}
group.querySelector("button").addEventListener("click", async () => {
const btn = document.getElementById("fetchStatsBtn");
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> <span>Fetching...</span>';
try {
const resp = await _origFetch("/api/lm-extra/fetch-stats", { method: "POST" });
const data = await resp.json();
if (data.success) {
const count = parseInt(data.updated, 10) || 0;
btn.innerHTML = `<i class="fas fa-check"></i> <span>${count} updated</span>`;
setTimeout(() => {
btn.innerHTML = '<i class="fas fa-chart-bar"></i> <span>Fetch Stats</span>';
btn.disabled = false;
}, 3000);
// Trigger page reload to show new stats
const sortSelect = document.getElementById("sortSelect");
if (sortSelect) {
sortSelect.dispatchEvent(new Event("change"));
}
} else {
throw new Error(data.error || "Unknown error");
}
} catch (err) {
btn.innerHTML = '<i class="fas fa-exclamation-triangle"></i> <span>Error</span>';
console.error("[LM-Stats] Fetch failed:", err);
setTimeout(() => {
btn.innerHTML = '<i class="fas fa-chart-bar"></i> <span>Fetch Stats</span>';
btn.disabled = false;
}, 3000);
}
});
}
// ── Observe DOM for card rendering ─────────────────────────────
function startObserver() {
injectStyles();
patchSortDropdown();
addFetchStatsButton();
patchCards();
let debounceTimer = null;
const observer = new MutationObserver(() => {
if (debounceTimer) return;
debounceTimer = setTimeout(() => {
debounceTimer = null;
patchCards();
patchSortDropdown();
addFetchStatsButton();
}, 200);
});
const grid = document.getElementById("modelGrid");
observer.observe(grid || document.body, { childList: true, subtree: true });
}
// ── Init ───────────────────────────────────────────────────────
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", startObserver);
} else {
startObserver();
}
})();