Files
Setmaster fb28135b82 Add file size display for models
- Show model file sizes (GB/MB) in the sidebar next to last used time
- Use ComfyUI's folder_paths for reliable model path resolution
- Support all configured model locations including extra_model_paths
- Handle GGUF models that can be in unet or clip folders
- Update README with new features
2026-01-15 12:27:04 -05:00

816 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../scripts/app.js";
/**
* ModelPulse - Usage Tracker Extension
*
* Displays model usage statistics in a sidebar panel.
*/
// State management
let currentTimeframe = "all";
let currentSort = "last_used";
let searchQuery = "";
let usageData = null;
let isLoading = false;
let showSettings = false;
// Settings with defaults
let settings = {
staleThresholdDays: 30,
};
// Load settings from localStorage
function loadSettings() {
try {
const saved = localStorage.getItem("modelpulse_settings");
if (saved) {
settings = { ...settings, ...JSON.parse(saved) };
}
} catch (e) {
console.warn("[ModelPulse] Failed to load settings:", e);
}
}
// Save settings to localStorage
function saveSettings() {
try {
localStorage.setItem("modelpulse_settings", JSON.stringify(settings));
} catch (e) {
console.warn("[ModelPulse] Failed to save settings:", e);
}
}
// Initialize settings on load
loadSettings();
/**
* Fetch usage data from the API
*/
async function fetchUsageData(timeframe = "all", sortBy = "last_used") {
try {
const response = await fetch(
`/modelpulse/usage?timeframe=${timeframe}&sort=${sortBy}`
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("[ModelPulse] Error fetching usage data:", error);
return null;
}
}
/**
* Format file size to human readable string (GB or MB)
*/
function formatFileSize(bytes) {
if (bytes === null || bytes === undefined) return null;
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1) {
return `${gb.toFixed(1)} GB`;
}
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(0)} MB`;
}
/**
* Format ISO date to relative time string
*/
function formatLastUsed(isoDate) {
if (!isoDate) return "Never";
const date = new Date(isoDate);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 30) return `${diffDays}d ago`;
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths < 12) return `${diffMonths}mo ago`;
const diffYears = Math.floor(diffDays / 365);
return `${diffYears}y ago`;
}
/**
* Get category display info
*/
function getCategoryInfo(category) {
const categories = {
checkpoint: { name: "Checkpoints", icon: "📦" },
lora: { name: "LoRAs", icon: "✨" },
vae: { name: "VAEs", icon: "⚙️" },
controlnet: { name: "ControlNets", icon: "🎛️" },
clip: { name: "CLIP", icon: "💬" },
unet: { name: "UNETs", icon: "🖥️" },
upscaler: { name: "Upscalers", icon: "🔍" },
style_model: { name: "Style Models", icon: "🎨" },
gligen: { name: "GLIGEN", icon: "📐" },
gguf: { name: "GGUF", icon: "🗜️" },
};
return categories[category] || { name: category, icon: "📁" };
}
/**
* Check if a model is stale (unused for > threshold days)
*/
function isStale(lastUsed) {
if (!lastUsed) return true;
const date = new Date(lastUsed);
const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
return diffDays > settings.staleThresholdDays;
}
/**
* Filter models by search query
*/
function filterModels(models, query) {
if (!query || !query.trim()) return models;
const lowerQuery = query.toLowerCase().trim();
return models.filter(model =>
model.name.toLowerCase().includes(lowerQuery) ||
model.category.toLowerCase().includes(lowerQuery)
);
}
/**
* Group models by category
*/
function groupByCategory(models) {
const groups = {};
for (const model of models) {
const cat = model.category;
if (!groups[cat]) {
groups[cat] = [];
}
groups[cat].push(model);
}
return groups;
}
/**
* Create the styles for the UI
*/
function createStyles() {
const style = document.createElement("style");
style.textContent = `
.modelpulse-container {
display: flex;
flex-direction: column;
height: 100%;
font-family: var(--comfy-font, sans-serif);
font-size: 13px;
color: var(--input-text, #ddd);
}
.modelpulse-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border-bottom: 1px solid var(--border-color, #444);
}
.modelpulse-title {
font-size: 15px;
font-weight: 600;
}
.modelpulse-refresh {
background: transparent;
border: none;
color: var(--input-text, #ddd);
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
font-size: 14px;
}
.modelpulse-refresh:hover {
background: var(--comfy-menu-bg-hover, #333);
}
.modelpulse-refresh.loading {
opacity: 0.5;
cursor: wait;
}
.modelpulse-tabs {
display: flex;
border-bottom: 1px solid var(--border-color, #444);
}
.modelpulse-tab {
flex: 1;
padding: 10px;
text-align: center;
cursor: pointer;
background: transparent;
border: none;
color: var(--input-text, #ddd);
border-bottom: 2px solid transparent;
transition: all 0.15s ease;
}
.modelpulse-tab:hover {
background: var(--comfy-menu-bg-hover, #333);
}
.modelpulse-tab.active {
border-bottom-color: var(--primary-color, #4a9eff);
color: var(--primary-color, #4a9eff);
}
.modelpulse-controls {
display: flex;
padding: 8px 12px;
gap: 8px;
align-items: center;
border-bottom: 1px solid var(--border-color, #444);
}
.modelpulse-sort-label {
font-size: 12px;
color: var(--descrip-text, #999);
}
.modelpulse-sort {
flex: 1;
padding: 6px 8px;
background: var(--comfy-input-bg, #222);
border: 1px solid var(--border-color, #444);
border-radius: 4px;
color: var(--input-text, #ddd);
font-size: 12px;
cursor: pointer;
}
.modelpulse-list {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.modelpulse-category {
margin-bottom: 4px;
}
.modelpulse-category-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--comfy-menu-bg, #2a2a2a);
cursor: pointer;
user-select: none;
}
.modelpulse-category-header:hover {
background: var(--comfy-menu-bg-hover, #333);
}
.modelpulse-category-icon {
font-size: 14px;
}
.modelpulse-category-name {
flex: 1;
font-weight: 500;
}
.modelpulse-category-count {
font-size: 11px;
color: var(--descrip-text, #999);
background: var(--comfy-input-bg, #222);
padding: 2px 6px;
border-radius: 10px;
}
.modelpulse-category-arrow {
font-size: 10px;
transition: transform 0.15s ease;
}
.modelpulse-category.collapsed .modelpulse-category-arrow {
transform: rotate(-90deg);
}
.modelpulse-category-models {
display: block;
}
.modelpulse-category.collapsed .modelpulse-category-models {
display: none;
}
.modelpulse-model {
display: flex;
flex-direction: column;
padding: 10px 12px 10px 32px;
border-bottom: 1px solid var(--border-color, #333);
cursor: default;
}
.modelpulse-model:hover {
background: var(--comfy-menu-bg-hover, #333);
}
.modelpulse-model-name {
font-weight: 500;
margin-bottom: 4px;
word-break: break-all;
}
.modelpulse-model-stats {
display: flex;
gap: 16px;
font-size: 11px;
color: var(--descrip-text, #999);
}
.modelpulse-model.stale .modelpulse-model-name {
color: var(--error-text, #ff6b6b);
}
.modelpulse-model.stale .modelpulse-last-used {
color: var(--error-text, #ff6b6b);
}
.modelpulse-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: var(--descrip-text, #999);
text-align: center;
padding: 20px;
}
.modelpulse-empty-icon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.modelpulse-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
color: var(--descrip-text, #999);
}
.modelpulse-search-container {
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #444);
}
.modelpulse-search {
width: 100%;
padding: 8px 10px;
background: var(--comfy-input-bg, #222);
border: 1px solid var(--border-color, #444);
border-radius: 4px;
color: var(--input-text, #ddd);
font-size: 13px;
box-sizing: border-box;
}
.modelpulse-search:focus {
outline: none;
border-color: var(--primary-color, #4a9eff);
}
.modelpulse-search::placeholder {
color: var(--descrip-text, #999);
}
.modelpulse-header-buttons {
display: flex;
gap: 4px;
}
.modelpulse-settings-btn {
background: transparent;
border: none;
color: var(--input-text, #ddd);
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
font-size: 14px;
}
.modelpulse-settings-btn:hover {
background: var(--comfy-menu-bg-hover, #333);
}
.modelpulse-settings-panel {
padding: 12px;
background: var(--comfy-menu-bg, #2a2a2a);
border-bottom: 1px solid var(--border-color, #444);
}
.modelpulse-settings-title {
font-weight: 600;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modelpulse-settings-close {
background: transparent;
border: none;
color: var(--input-text, #ddd);
cursor: pointer;
font-size: 16px;
padding: 2px 6px;
border-radius: 4px;
}
.modelpulse-settings-close:hover {
background: var(--comfy-menu-bg-hover, #333);
}
.modelpulse-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.modelpulse-setting-row:last-child {
margin-bottom: 0;
}
.modelpulse-setting-label {
font-size: 12px;
color: var(--input-text, #ddd);
}
.modelpulse-setting-input {
width: 60px;
padding: 4px 8px;
background: var(--comfy-input-bg, #222);
border: 1px solid var(--border-color, #444);
border-radius: 4px;
color: var(--input-text, #ddd);
font-size: 12px;
text-align: center;
}
.modelpulse-setting-input:focus {
outline: none;
border-color: var(--primary-color, #4a9eff);
}
.modelpulse-no-results {
padding: 20px;
text-align: center;
color: var(--descrip-text, #999);
font-size: 12px;
}
.modelpulse-reset-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color, #444);
}
.modelpulse-reset-btn {
padding: 4px 12px;
background: var(--error-text, #ff6b6b);
border: none;
border-radius: 4px;
color: #fff;
font-size: 12px;
cursor: pointer;
}
.modelpulse-reset-btn:hover {
background: #ff5252;
}
.modelpulse-reset-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
return style;
}
/**
* Render the ModelPulse UI into the container element
*/
async function renderModelPulseUI(container) {
// Add styles
container.appendChild(createStyles());
// Create main container
const main = document.createElement("div");
main.className = "modelpulse-container";
container.appendChild(main);
// Header
const header = document.createElement("div");
header.className = "modelpulse-header";
header.innerHTML = `
<span class="modelpulse-title">Usage</span>
<div class="modelpulse-header-buttons">
<button class="modelpulse-settings-btn" title="Settings">⚙</button>
<button class="modelpulse-refresh" title="Refresh">↻</button>
</div>
`;
main.appendChild(header);
// Settings panel (hidden by default)
const settingsPanel = document.createElement("div");
settingsPanel.className = "modelpulse-settings-panel";
settingsPanel.style.display = "none";
settingsPanel.innerHTML = `
<div class="modelpulse-settings-title">
<span>Settings</span>
<button class="modelpulse-settings-close">×</button>
</div>
<div class="modelpulse-setting-row">
<label class="modelpulse-setting-label">Stale threshold (days)</label>
<input type="number" class="modelpulse-setting-input" id="stale-threshold"
value="${settings.staleThresholdDays}" min="1" max="365">
</div>
<div class="modelpulse-setting-row modelpulse-reset-row">
<label class="modelpulse-setting-label">Reset all tracking data</label>
<button class="modelpulse-reset-btn" id="reset-data">Reset</button>
</div>
`;
main.appendChild(settingsPanel);
// Search bar
const searchContainer = document.createElement("div");
searchContainer.className = "modelpulse-search-container";
searchContainer.innerHTML = `
<input type="text" class="modelpulse-search" placeholder="Search models..." value="${searchQuery}">
`;
main.appendChild(searchContainer);
// Timeframe tabs
const tabs = document.createElement("div");
tabs.className = "modelpulse-tabs";
tabs.innerHTML = `
<button class="modelpulse-tab ${currentTimeframe === 'all' ? 'active' : ''}" data-timeframe="all">All Time</button>
<button class="modelpulse-tab ${currentTimeframe === 'month' ? 'active' : ''}" data-timeframe="month">Month</button>
<button class="modelpulse-tab ${currentTimeframe === 'week' ? 'active' : ''}" data-timeframe="week">Week</button>
`;
main.appendChild(tabs);
// Controls (sort dropdown)
const controls = document.createElement("div");
controls.className = "modelpulse-controls";
controls.innerHTML = `
<span class="modelpulse-sort-label">Sort:</span>
<select class="modelpulse-sort">
<option value="last_used" ${currentSort === 'last_used' ? 'selected' : ''}>Last Used</option>
<option value="usage_count" ${currentSort === 'usage_count' ? 'selected' : ''}>Usage Count</option>
<option value="name" ${currentSort === 'name' ? 'selected' : ''}>Name</option>
</select>
`;
main.appendChild(controls);
// Model list container
const list = document.createElement("div");
list.className = "modelpulse-list";
main.appendChild(list);
// Event handlers
const refreshBtn = header.querySelector(".modelpulse-refresh");
const settingsBtn = header.querySelector(".modelpulse-settings-btn");
const settingsClose = settingsPanel.querySelector(".modelpulse-settings-close");
const staleThresholdInput = settingsPanel.querySelector("#stale-threshold");
const searchInput = searchContainer.querySelector(".modelpulse-search");
refreshBtn.addEventListener("click", () => refreshData(list, refreshBtn));
// Settings toggle
settingsBtn.addEventListener("click", () => {
showSettings = !showSettings;
settingsPanel.style.display = showSettings ? "block" : "none";
});
settingsClose.addEventListener("click", () => {
showSettings = false;
settingsPanel.style.display = "none";
});
// Settings change handler
staleThresholdInput.addEventListener("change", () => {
const value = parseInt(staleThresholdInput.value, 10);
if (value >= 1 && value <= 365) {
settings.staleThresholdDays = value;
saveSettings();
renderModelList(list); // Re-render to update stale highlighting
}
});
// Reset button handler
const resetBtn = settingsPanel.querySelector("#reset-data");
resetBtn.addEventListener("click", async () => {
if (!confirm("Are you sure you want to reset all tracking data? This cannot be undone.")) {
return;
}
resetBtn.disabled = true;
resetBtn.textContent = "Resetting...";
try {
const response = await fetch("/modelpulse/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
await refreshData(list, refreshBtn);
} catch (error) {
console.error("[ModelPulse] Error resetting data:", error);
alert("Failed to reset tracking data. Check console for details.");
} finally {
resetBtn.disabled = false;
resetBtn.textContent = "Reset";
}
});
// Search handler with debounce
let searchTimeout;
searchInput.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchQuery = searchInput.value;
renderModelList(list);
}, 200);
});
tabs.addEventListener("click", (e) => {
const tab = e.target.closest(".modelpulse-tab");
if (tab) {
currentTimeframe = tab.dataset.timeframe;
tabs.querySelectorAll(".modelpulse-tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
refreshData(list, refreshBtn);
}
});
const sortSelect = controls.querySelector(".modelpulse-sort");
sortSelect.addEventListener("change", () => {
currentSort = sortSelect.value;
refreshData(list, refreshBtn);
});
// Initial load
await refreshData(list, refreshBtn);
}
/**
* Refresh the data and re-render the list
*/
async function refreshData(listContainer, refreshBtn) {
if (isLoading) return;
isLoading = true;
refreshBtn.classList.add("loading");
listContainer.innerHTML = '<div class="modelpulse-loading">Loading...</div>';
try {
usageData = await fetchUsageData(currentTimeframe, currentSort);
renderModelList(listContainer);
} catch (error) {
listContainer.innerHTML = `
<div class="modelpulse-empty">
<div class="modelpulse-empty-icon">⚠️</div>
<div>Error loading data</div>
</div>
`;
} finally {
isLoading = false;
refreshBtn.classList.remove("loading");
}
}
/**
* Render the model list grouped by category
*/
function renderModelList(container) {
container.innerHTML = "";
if (!usageData || !usageData.models || usageData.models.length === 0) {
container.innerHTML = `
<div class="modelpulse-empty">
<div class="modelpulse-empty-icon">📊</div>
<div>No model usage recorded yet</div>
<div style="margin-top: 8px; font-size: 11px;">Run some workflows to start tracking</div>
</div>
`;
return;
}
// Apply search filter
const filteredModels = filterModels(usageData.models, searchQuery);
if (filteredModels.length === 0) {
container.innerHTML = `
<div class="modelpulse-no-results">
No models found matching "${searchQuery}"
</div>
`;
return;
}
const grouped = groupByCategory(filteredModels);
// Sort categories by total usage
const sortedCategories = Object.keys(grouped).sort((a, b) => {
const aTotal = grouped[a].reduce((sum, m) => sum + m.timeframe_count, 0);
const bTotal = grouped[b].reduce((sum, m) => sum + m.timeframe_count, 0);
return bTotal - aTotal;
});
for (const category of sortedCategories) {
const models = grouped[category];
const catInfo = getCategoryInfo(category);
const catDiv = document.createElement("div");
catDiv.className = "modelpulse-category";
// Category header
const catHeader = document.createElement("div");
catHeader.className = "modelpulse-category-header";
catHeader.innerHTML = `
<span class="modelpulse-category-icon">${catInfo.icon}</span>
<span class="modelpulse-category-name">${catInfo.name}</span>
<span class="modelpulse-category-count">${models.length}</span>
<span class="modelpulse-category-arrow">▼</span>
`;
catHeader.addEventListener("click", () => {
catDiv.classList.toggle("collapsed");
});
catDiv.appendChild(catHeader);
// Models list
const modelsDiv = document.createElement("div");
modelsDiv.className = "modelpulse-category-models";
for (const model of models) {
const modelDiv = document.createElement("div");
modelDiv.className = "modelpulse-model";
if (isStale(model.last_used)) {
modelDiv.classList.add("stale");
}
const countLabel = currentTimeframe === "all" ? "Uses" : "Uses (period)";
const count = currentTimeframe === "all" ? model.usage_count : model.timeframe_count;
const sizeStr = formatFileSize(model.file_size);
modelDiv.innerHTML = `
<div class="modelpulse-model-name" title="${model.model_id}">${model.name}</div>
<div class="modelpulse-model-stats">
<span class="modelpulse-usage">${countLabel}: ${count}</span>
<span class="modelpulse-last-used">Last: ${formatLastUsed(model.last_used)}</span>
${sizeStr ? `<span class="modelpulse-size">${sizeStr}</span>` : ""}
</div>
`;
modelsDiv.appendChild(modelDiv);
}
catDiv.appendChild(modelsDiv);
container.appendChild(catDiv);
}
}
// Register the extension
app.registerExtension({
name: "ModelPulse",
async setup() {
// Register sidebar tab
app.extensionManager.registerSidebarTab({
id: "modelpulse",
icon: "pi pi-chart-bar",
title: "Usage",
tooltip: "Track model usage frequency",
type: "custom",
render: (el) => {
renderModelPulseUI(el);
}
});
console.log("[ModelPulse] Extension loaded");
}
});