- Add model usage tracking via PromptExecutor hook - Support 12+ loader node types (checkpoints, LoRAs, VAEs, etc.) - Pattern matching for custom node loaders - Cross-platform file locking (Windows/Unix) - JSON storage with schema migrations - REST API endpoints for usage data - Sidebar panel with timeframe filtering and sorting - Stale model highlighting (>30 days unused)
512 lines
15 KiB
JavaScript
512 lines
15 KiB
JavaScript
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 usageData = null;
|
|
let isLoading = false;
|
|
|
|
/**
|
|
* 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 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: "📐" },
|
|
};
|
|
return categories[category] || { name: category, icon: "📁" };
|
|
}
|
|
|
|
/**
|
|
* Check if a model is stale (unused for > 30 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 > 30;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
`;
|
|
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>
|
|
<button class="modelpulse-refresh" title="Refresh">↻</button>
|
|
`;
|
|
main.appendChild(header);
|
|
|
|
// 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");
|
|
refreshBtn.addEventListener("click", () => refreshData(list, refreshBtn));
|
|
|
|
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;
|
|
}
|
|
|
|
const grouped = groupByCategory(usageData.models);
|
|
|
|
// 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;
|
|
|
|
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>
|
|
</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");
|
|
}
|
|
});
|