Files
sammykumar-ComfyUI-SwissArm…/web/js/resource_monitor.js
T

1260 lines
47 KiB
JavaScript

import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
/**
* Swiss Army Knife - Resource Monitor Extension
*
* This extension adds a resource monitor UI to the ComfyUI top bar,
* similar to Crystools' resource monitor but integrated with Swiss Army Knife.
*
* Features:
* - Restart button in the top bar (using ComfyButtonGroup like Crystools)
* - Future: CPU/RAM/GPU/VRAM monitoring
*/
// Version indicator - check console for this on page load
console.log("%c[🔪SwissArmyKnife][ResourceMonitor] Version 2.0.0 - Loaded with colored logs and tooltip fixes", "color: #10b981; font-weight: bold; font-size: 14px;");
const EXTENSION_NAME = "comfyui_swissarmyknife.resource_monitor";
// Debug logging
const isDebugEnabled = () => {
try {
return app.extensionManager?.setting?.get("SwissArmyKnife.debug_mode") || false;
} catch (error) {
return false;
}
};
const debugLog = (...args) => {
if (isDebugEnabled()) {
console.log("%c[🔪SwissArmyKnife][ResourceMonitor]", "color: #3b82f6; font-weight: bold;", ...args);
}
};
/**
* Creates the restart button element
*/
function createRestartButton() {
debugLog("Creating restart button");
const button = document.createElement("button");
button.id = "swissarmyknife-restart-button";
button.className = "comfyui-button";
button.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
`;
button.title = "Restart ComfyUI Server";
// Add click handler with restart functionality
button.addEventListener("click", async () => {
debugLog("Restart button clicked");
try {
debugLog("Sending restart request...");
// Disable button to prevent double-clicks and show loading state
button.disabled = true;
button.style.opacity = "0.6";
button.title = "Restarting...";
// Show notification immediately
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "warn",
summary: "Server Restarting",
detail: "Waiting for ComfyUI to become available...",
life: 30000, // Keep visible longer during restart
});
}
// Call the restart API endpoint
// Note: The server may terminate before sending a complete response,
// so we don't rely on the response - just trigger and reload
fetch("/swissarmyknife/restart", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
}).catch(() => {
// Ignore fetch errors - server is restarting
debugLog("Fetch error (expected - server restarting)");
});
debugLog("Server restart initiated");
// Wait for initial shutdown (give server time to start shutting down)
await new Promise(resolve => setTimeout(resolve, 2000));
// Poll for server availability with health check
const maxAttempts = 60; // Try for up to 60 seconds
const pollInterval = 1000; // Check every second
let attempts = 0;
let serverAvailable = false;
debugLog("Starting health check polling...");
while (attempts < maxAttempts && !serverAvailable) {
attempts++;
try {
// Try to fetch a simple endpoint to check if server is responding
const response = await fetch("/system_stats", {
method: "GET",
cache: "no-cache"
});
if (response.ok) {
debugLog(`Server is available after ${attempts} attempts (${attempts} seconds)`);
serverAvailable = true;
break;
}
} catch (error) {
// Server not ready yet, continue polling
if (attempts % 5 === 0) {
debugLog(`Health check attempt ${attempts}/${maxAttempts}...`);
}
}
// Wait before next attempt
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
if (serverAvailable) {
debugLog("Server is back online, reloading page...");
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "success",
summary: "Server Restarted",
detail: "Reloading page now...",
life: 2000
});
}
// Give a brief moment for the toast to show, then hard reload
setTimeout(() => {
// Force a hard reload that bypasses the cache
// Using multiple approaches for maximum browser compatibility
// Method 1: Add cache-busting parameter
const url = new URL(window.location.href);
url.searchParams.set('_reload', Date.now());
// Method 2: Set cache control headers via meta tag
const meta = document.createElement('meta');
meta.httpEquiv = 'Cache-Control';
meta.content = 'no-cache, no-store, must-revalidate';
document.head.appendChild(meta);
// Method 3: Use location.replace with cache-busting URL
window.location.replace(url.href);
}, 500);
} else {
debugLog("Server did not become available within timeout");
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "warn",
summary: "Server Taking Longer Than Expected",
detail: "Please refresh manually if needed",
life: 10000
});
}
button.disabled = false;
button.textContent = "Restart";
}
} catch (error) {
console.error("%c[🔪SwissArmyKnife][ResourceMonitor]", "color: #ef4444; font-weight: bold;", "Error restarting server:", error);
button.disabled = false;
button.textContent = "Restart";
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "Restart Failed",
detail: error.message,
life: 5000
});
}
}
});
return button;
}
/**
* Creates the profiler button element
*/
function createProfilerButton() {
debugLog("Creating profiler button");
const button = document.createElement("button");
button.id = "swissarmyknife-profiler-button";
button.className = "comfyui-button";
button.innerHTML = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="20" x2="18" y2="10"></line>
<line x1="12" y1="20" x2="12" y2="4"></line>
<line x1="6" y1="20" x2="6" y2="14"></line>
</svg>
`;
button.title = "View Workflow Profiler";
// Create popup container
const popup = createProfilerPopup();
button._profilerPopup = popup;
button._profilerData = null;
// Add click handler to toggle popup
button.addEventListener("click", async (e) => {
e.stopPropagation();
debugLog("Profiler button clicked");
const isVisible = popup.style.display === "block";
if (!isVisible) {
// Always fetch latest data on open
try {
debugLog("Fetching profiler data...");
const response = await fetchWithRetry("/swissarmyknife/profiler/stats");
const result = await response.json();
debugLog("Profiler data received:", result);
if (result.success && result.data) {
button._profilerData = result.data;
updateProfilerPopupContent(popup, result.data);
} else {
console.warn("%c[🔪SwissArmyKnife][Profiler]", "color: #f59e0b; font-weight: bold;", "No data in response:", result);
}
} catch (error) {
console.error("%c[🔪SwissArmyKnife][Profiler]", "color: #ef4444; font-weight: bold;", "Error fetching profiler data:", error);
}
// Position popup to the left of the action buttons
const actionButtonsRect = document.getElementById("swissarmyknife-action-buttons").getBoundingClientRect();
popup.style.right = `${window.innerWidth - actionButtonsRect.left + 12}px`;
popup.style.top = `50%`;
popup.style.transform = `translateY(-50%)`;
popup.style.left = 'auto';
popup.style.bottom = 'auto';
popup.style.display = "block";
debugLog("Profiler popup opened");
} else {
popup.style.display = "none";
debugLog("Profiler popup closed");
}
});
// Close popup when clicking outside
document.addEventListener("click", (e) => {
if (popup.style.display === "block" &&
!popup.contains(e.target) &&
!button.contains(e.target)) {
popup.style.display = "none";
}
});
// Append popup to body
document.body.appendChild(popup);
return button;
}
/**
* Loads CSS stylesheet for the resource monitor
*/
function loadResourceMonitorStyles() {
debugLog("Loading resource monitor stylesheet");
const linkId = "swissarmyknife-resource-monitor-styles";
// Check if stylesheet already exists
if (document.getElementById(linkId)) {
debugLog("Resource monitor stylesheet already loaded");
return;
}
const link = document.createElement("link");
link.id = linkId;
link.rel = "stylesheet";
link.type = "text/css";
link.href = "extensions/ComfyUI-SwissArmyKnife/css/resource-monitor.css";
document.head.appendChild(link);
debugLog("Resource monitor stylesheet loaded");
}
/**
* Fetch with retry logic
*/
async function fetchWithRetry(url, maxAttempts = 3) {
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await fetch(url);
if (response.ok) {
return response;
}
} catch (error) {
if (i < maxAttempts - 1) {
await new Promise(r => setTimeout(r, 100 * Math.pow(2, i)));
continue;
}
throw error;
}
}
throw new Error(`Failed to fetch ${url} after ${maxAttempts} attempts`);
}
/**
* Format milliseconds to readable string
*/
function formatMs(ms) {
if (ms === null || ms === undefined) return "N/A";
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function formatDurationMs(ms) {
if (ms === null || ms === undefined) return "N/A";
const totalSeconds = ms / 1000;
let minutes = Math.floor(totalSeconds / 60);
let seconds = totalSeconds - minutes * 60;
seconds = Math.round(seconds * 100) / 100;
if (seconds >= 60) {
minutes += 1;
seconds = 0;
}
if (minutes <= 0) {
const precision = seconds < 10 ? 2 : 1;
return `${seconds.toFixed(precision)}s`;
}
const precision = seconds < 10 ? 2 : 1;
const secondsValue = Number(seconds.toFixed(precision));
const secondsStr = Number.isInteger(secondsValue) ? secondsValue.toFixed(0) : secondsValue.toString();
return `${minutes}m ${secondsStr}s`;
}
/**
* Format bytes to human readable
*/
function formatBytes(bytes) {
if (bytes === null || bytes === undefined) return "N/A";
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
/**
* Create profiler popup
*/
function createProfilerPopup() {
const popup = document.createElement("div");
popup.id = "swissarmyknife-profiler-popup";
popup.innerHTML = `
<div class="profiler-popup-header">
<div class="profiler-popup-title">Workflow Profiler</div>
<button class="profiler-popup-close">✕</button>
</div>
<div class="profiler-stats-grid" id="profiler-stats-grid">
<!-- Stats cards will be inserted here -->
</div>
<div class="profiler-node-table-container">
<div class="profiler-node-table-title">Top 10 Slowest Nodes</div>
<table class="profiler-node-table" id="profiler-node-table">
<thead>
<tr>
<th>Node Type</th>
<th>Time</th>
<th>VRAM</th>
<th>Cache</th>
</tr>
</thead>
<tbody id="profiler-node-table-body">
<tr><td colspan="4" style="text-align: center; padding: 1rem;">No data available</td></tr>
</tbody>
</table>
</div>
<div class="profiler-popup-footer">
<button class="profiler-btn profiler-btn-primary" id="profiler-view-full">View Full History</button>
<button class="profiler-btn profiler-btn-danger" id="profiler-clear-history">Clear History</button>
</div>
`;
// Close button handler
popup.querySelector(".profiler-popup-close").addEventListener("click", (e) => {
e.stopPropagation();
popup.style.display = "none";
});
// View Full History button handler
popup.querySelector("#profiler-view-full").addEventListener("click", (e) => {
e.stopPropagation();
openProfilerModal();
});
// Clear History button handler
popup.querySelector("#profiler-clear-history").addEventListener("click", async (e) => {
e.stopPropagation();
if (confirm("Are you sure you want to clear profiler history?")) {
try {
await fetch("/swissarmyknife/profiler/archive", { method: "POST" });
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "success",
summary: "History Cleared",
detail: "Profiler history has been archived",
life: 3000,
});
}
// Refresh data
const button = document.getElementById("swissarmyknife-profiler-button");
if (button) {
button._profilerData = null;
}
} catch (error) {
console.error("%c[🔪SwissArmyKnife][Profiler]", "color: #ef4444; font-weight: bold;", "Error clearing history:", error);
}
}
});
return popup;
}
/**
* Update profiler popup content with latest data
*/
function updateProfilerPopupContent(popup, data) {
debugLog("Updating profiler popup content", data);
if (!data || !data.latest) {
return;
}
const latest = data.latest;
// Update stats grid
const statsGrid = popup.querySelector("#profiler-stats-grid");
statsGrid.innerHTML = `
<div class="profiler-stat-card">
<div class="profiler-stat-label">⏱️ Total Time</div>
<div class="profiler-stat-value">${formatDurationMs(latest.executionTime)}</div>
</div>
<div class="profiler-stat-card">
<div class="profiler-stat-label">🎮 VRAM Peak</div>
<div class="profiler-stat-value">${formatBytes(latest.totalVramPeak)}</div>
</div>
<div class="profiler-stat-card">
<div class="profiler-stat-label">💾 RAM Peak</div>
<div class="profiler-stat-value">${formatBytes(latest.totalRamPeak)}</div>
</div>
<div class="profiler-stat-card">
<div class="profiler-stat-label">✅ Cache Hits</div>
<div class="profiler-stat-value">${latest.cacheHits}</div>
</div>
<div class="profiler-stat-card">
<div class="profiler-stat-label">⚡ Executed</div>
<div class="profiler-stat-value">${latest.cacheMisses}</div>
</div>
<div class="profiler-stat-card">
<div class="profiler-stat-label">📦 Total Nodes</div>
<div class="profiler-stat-value">${latest.cacheHits + latest.cacheMisses}</div>
</div>
`;
// Update node table (top 10 slowest)
const tbody = popup.querySelector("#profiler-node-table-body");
if (latest.nodes && Object.keys(latest.nodes).length > 0) {
const nodeArray = Object.values(latest.nodes)
.filter(n => n.executionTime !== null)
.sort((a, b) => (b.executionTime || 0) - (a.executionTime || 0))
.slice(0, 10);
if (nodeArray.length > 0) {
tbody.innerHTML = nodeArray.map(node => `
<tr>
<td>${node.nodeType}</td>
<td>${formatMs(node.executionTime)}</td>
<td>${formatBytes(node.vramDelta)}</td>
<td class="profiler-cache-icon">${node.cacheHit ? '🟢' : '⚡'}</td>
</tr>
`).join('');
} else {
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; padding: 1rem;">No executed nodes in latest workflow</td></tr>';
}
} else {
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center; padding: 1rem;">No node data available</td></tr>';
}
}
/**
* Open profiler modal with full details
*/
function openProfilerModal() {
debugLog("Opening profiler modal");
// Create modal if it doesn't exist
let modal = document.getElementById("swissarmyknife-profiler-modal");
if (!modal) {
modal = createProfilerModal();
document.body.appendChild(modal);
}
modal.style.display = "block";
// Fetch latest data
fetchProfilerDataForModal();
}
/**
* Create profiler modal
*/
function createProfilerModal() {
const modal = document.createElement("div");
modal.id = "swissarmyknife-profiler-modal";
modal.innerHTML = `
<div class="profiler-modal-content">
<div class="profiler-modal-header">
<div class="profiler-modal-title">📊 Workflow Profiler</div>
<button class="profiler-modal-close">✕</button>
</div>
<div class="profiler-tabs">
<button class="profiler-tab active" data-tab="latest">📊 Latest Run</button>
<button class="profiler-tab" data-tab="previous">📈 Previous Runs</button>
<button class="profiler-tab" data-tab="analytics">🔍 Node Analytics</button>
<button class="profiler-tab" data-tab="settings">⚙️ Settings</button>
</div>
<div class="profiler-tab-content">
<div class="profiler-tab-panel active" id="profiler-tab-latest">
<h3>Latest Workflow Execution</h3>
<div id="profiler-latest-content">Loading...</div>
</div>
<div class="profiler-tab-panel" id="profiler-tab-previous">
<h3>Previous Workflow Runs</h3>
<div id="profiler-previous-content">Loading...</div>
</div>
<div class="profiler-tab-panel" id="profiler-tab-analytics">
<h3>Node Performance Analytics</h3>
<div id="profiler-analytics-content">Loading...</div>
</div>
<div class="profiler-tab-panel" id="profiler-tab-settings">
<h3>Profiler Settings</h3>
<div id="profiler-settings-content">Loading...</div>
</div>
</div>
</div>
`;
// Close button
modal.querySelector(".profiler-modal-close").addEventListener("click", () => {
modal.style.display = "none";
});
// Tab switching
modal.querySelectorAll(".profiler-tab").forEach(tab => {
tab.addEventListener("click", () => {
const tabId = tab.dataset.tab;
// Update active tab
modal.querySelectorAll(".profiler-tab").forEach(t => t.classList.remove("active"));
modal.querySelectorAll(".profiler-tab-panel").forEach(p => p.classList.remove("active"));
tab.classList.add("active");
modal.querySelector(`#profiler-tab-${tabId}`).classList.add("active");
});
});
// Close on outside click
modal.addEventListener("click", (e) => {
if (e.target === modal) {
modal.style.display = "none";
}
});
return modal;
}
/**
* Fetch profiler data for modal
*/
async function fetchProfilerDataForModal() {
try {
const response = await fetchWithRetry("/swissarmyknife/profiler/stats");
const result = await response.json();
if (result.success && result.data) {
updateModalContent(result.data);
}
} catch (error) {
console.error("%c[🔪SwissArmyKnife][Profiler]", "color: #ef4444; font-weight: bold;", "Error fetching modal data:", error);
}
}
/**
* Update modal content with profiler data
*/
function updateModalContent(data) {
// TODO: Implement full modal content updates for all tabs
// For now, show basic data
const latestContent = document.getElementById("profiler-latest-content");
if (latestContent && data.latest) {
latestContent.innerHTML = `
<pre style="background: rgba(0,0,0,0.3); padding: 1rem; border-radius: 8px; overflow-x: auto; color: white;">
${JSON.stringify(data.latest, null, 2)}
</pre>
`;
}
const previousContent = document.getElementById("profiler-previous-content");
if (previousContent && data.history) {
previousContent.innerHTML = `
<p style="color: rgba(255,255,255,0.7);">Total workflows: ${data.history.length}</p>
<pre style="background: rgba(0,0,0,0.3); padding: 1rem; border-radius: 8px; overflow-x: auto; max-height: 400px; color: white;">
${JSON.stringify(data.history.slice(0, 10), null, 2)}
</pre>
`;
}
const analyticsContent = document.getElementById("profiler-analytics-content");
if (analyticsContent && data.node_averages) {
analyticsContent.innerHTML = `
<pre style="background: rgba(0,0,0,0.3); padding: 1rem; border-radius: 8px; overflow-x: auto; max-height: 400px; color: white;">
${JSON.stringify(data.node_averages, null, 2)}
</pre>
`;
}
const settingsContent = document.getElementById("profiler-settings-content");
if (settingsContent) {
settingsContent.innerHTML = `
<p style="color: rgba(255,255,255,0.7);">Profiler is currently enabled and running.</p>
<p style="color: rgba(255,255,255,0.5); font-size: 0.875rem;">Settings and archive management coming soon.</p>
`;
}
}
/**
* Create GPU tooltip showing loaded models
*/
function createGPUTooltip(gpuId) {
debugLog(`📝 Creating GPU tooltip element for GPU ${gpuId}`);
// Check if tooltip already exists
const existingTooltip = document.getElementById(`swissarmyknife-gpu-tooltip-${gpuId}`);
if (existingTooltip) {
debugLog(`♻️ Tooltip for GPU ${gpuId} already exists, reusing it`);
return existingTooltip;
}
const tooltip = document.createElement("div");
tooltip.className = "swissarmyknife-gpu-tooltip";
tooltip.id = `swissarmyknife-gpu-tooltip-${gpuId}`;
tooltip.style.display = "none";
tooltip.innerHTML = `
<div class="gpu-tooltip-header">GPU ${gpuId}</div>
<div class="gpu-tooltip-content" id="gpu-tooltip-content-${gpuId}">
<div class="gpu-tooltip-loading">Loading...</div>
</div>
`;
document.body.appendChild(tooltip);
debugLog(`✅ GPU tooltip appended to body for GPU ${gpuId}`);
return tooltip;
}
/**
* Update GPU tooltip with loaded models data
*/
async function updateGPUTooltip(gpuId, monitorElement, actualVramUsedGB = null) {
debugLog(`[updateGPUTooltip] Starting update for GPU ${gpuId}, actual VRAM: ${actualVramUsedGB} GB`);
try {
const response = await fetchWithRetry("/swissarmyknife/profiler/loaded_models", 1);
debugLog(`[updateGPUTooltip] Response status: ${response.status} ${response.ok ? 'OK' : 'FAILED'}`);
if (!response.ok) {
throw new Error("Failed to fetch loaded models");
}
const result = await response.json();
debugLog(`[updateGPUTooltip] API response:`, result);
debugLog(`[updateGPUTooltip] Available GPU IDs:`, Object.keys(result.data || {}));
if (!result.success || !result.data) {
debugLog(`[updateGPUTooltip] ❌ Invalid response from API`);
throw new Error("Invalid response");
}
const gpuData = result.data[gpuId];
debugLog(`[updateGPUTooltip] GPU ${gpuId} data:`, gpuData);
const contentEl = document.getElementById(`gpu-tooltip-content-${gpuId}`);
if (!gpuData || !gpuData.models || gpuData.models.length === 0) {
debugLog(`[updateGPUTooltip] ⚠️ No models found for GPU ${gpuId}`);
debugLog(`[updateGPUTooltip] - gpuData exists: ${!!gpuData}`);
debugLog(`[updateGPUTooltip] - models array exists: ${!!(gpuData && gpuData.models)}`);
debugLog(`[updateGPUTooltip] - models length: ${gpuData && gpuData.models ? gpuData.models.length : 0}`);
contentEl.innerHTML = `
<div class="gpu-tooltip-empty">No models loaded</div>
`;
return;
}
// Sort models by VRAM usage (descending)
const sortedModels = [...gpuData.models].sort((a, b) => b.vram_mb - a.vram_mb);
debugLog(`[updateGPUTooltip] ✅ Found ${sortedModels.length} models on GPU ${gpuId}:`);
sortedModels.forEach((model, idx) => {
debugLog(`[updateGPUTooltip] ${idx + 1}. ${model.name} (${model.type}): ${model.vram_mb} MB [${model.class_name}]`);
});
// Build models list HTML
let modelsHTML = '<div class="gpu-tooltip-section">📦 Loaded Models:</div>';
modelsHTML += '<div class="gpu-tooltip-models">';
sortedModels.forEach(model => {
const icon = getModelIcon(model.type);
const vramGB = (model.vram_mb / 1024).toFixed(1);
// Extract clean model name from display_name (remove type/size/layer info in parentheses)
// "Wan2_2-T2V-A14B-HIGH (checkpoint, 8.5 GB, 505 layers)" -> "Wan2_2-T2V-A14B-HIGH"
let displayName = model.name;
const parenIndex = displayName.indexOf(' (');
if (parenIndex > 0) {
displayName = displayName.substring(0, parenIndex);
}
modelsHTML += `
<div class="gpu-tooltip-model">
<div class="gpu-tooltip-model-info">
<span class="gpu-tooltip-model-icon">${icon}</span>
<span class="gpu-tooltip-model-name" title="${model.name}">${displayName}</span>
</div>
<div class="gpu-tooltip-model-vram">${vramGB} GB</div>
</div>
`;
});
modelsHTML += '</div>';
// Add summary with breakdown
const totalModelVRAM = (gpuData.total_vram_mb / 1024).toFixed(1);
const totalModelVRAMNum = parseFloat(totalModelVRAM);
modelsHTML += `
<div class="gpu-tooltip-summary">
<div class="gpu-tooltip-summary-item">
<span>📦 Model VRAM:</span>
<span class="gpu-tooltip-summary-value">${totalModelVRAM} GB</span>
</div>`;
// If we have actual VRAM usage from resource monitor, show the difference
if (actualVramUsedGB !== null && actualVramUsedGB > 0) {
const otherVRAM = Math.max(0, actualVramUsedGB - totalModelVRAMNum).toFixed(1);
const otherPercent = actualVramUsedGB > 0 ? ((parseFloat(otherVRAM) / actualVramUsedGB) * 100).toFixed(0) : 0;
modelsHTML += `
<div class="gpu-tooltip-summary-item">
<span>🖥️ System/Framework:</span>
<span class="gpu-tooltip-summary-value">${otherVRAM} GB</span>
</div>
<div class="gpu-tooltip-summary-item" style="border-top: 1px solid rgba(255,255,255,0.1); margin-top: 4px; padding-top: 4px;">
<span><strong>💾 Total GPU VRAM:</strong></span>
<span class="gpu-tooltip-summary-value"><strong>${actualVramUsedGB.toFixed(1)} GB</strong></span>
</div>`;
}
modelsHTML += `
<div class="gpu-tooltip-summary-item">
<span>🔢 Model Count:</span>
<span class="gpu-tooltip-summary-value">${gpuData.model_count}</span>
</div>
</div>
`;
contentEl.innerHTML = modelsHTML;
} catch (error) {
debugLog("❌ [updateGPUTooltip] Error updating GPU tooltip:", error);
debugLog(`[updateGPUTooltip] Error stack:`, error.stack);
const contentEl = document.getElementById(`gpu-tooltip-content-${gpuId}`);
if (contentEl) {
contentEl.innerHTML = `
<div class="gpu-tooltip-error">Failed to load model data</div>
`;
}
}
}
/**
* Get icon for model type
*/
function getModelIcon(type) {
const icons = {
'checkpoint': '🎨',
'lora': '🎯',
'vae': '🔄',
'controlnet': '🎮',
'clip': '📝',
'style': '✨',
'gligen': '🔮',
'upscaler': '⬆️',
'model': '📦'
};
return icons[type] || '📦';
}
/**
* Position tooltip near monitor element
*/
function positionTooltip(tooltip, monitorElement) {
const rect = monitorElement.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
// Position above the monitor element
tooltip.style.left = `${rect.left + (rect.width / 2) - (tooltipRect.width / 2)}px`;
tooltip.style.bottom = `${window.innerHeight - rect.top + 10}px`;
tooltip.style.top = 'auto';
}
/**
* Create a monitor display element with inline label and background bar
*/
function createMonitorDisplay(label, id, enableTooltip = false) {
const monitor = document.createElement("div");
monitor.className = "swissarmyknife-monitor";
monitor.id = `swissarmyknife-monitor-${id}`;
// Background progress bar (absolute positioned)
const bgBar = document.createElement("div");
bgBar.className = "swissarmyknife-monitor-bg";
bgBar.id = `swissarmyknife-monitor-bg-${id}`;
const contentWrapper = document.createElement("div");
contentWrapper.className = "swissarmyknife-monitor-content";
// Only create label if one is provided
if (label) {
const labelEl = document.createElement("span");
labelEl.className = "swissarmyknife-monitor-label";
labelEl.textContent = label;
contentWrapper.appendChild(labelEl);
contentWrapper.appendChild(document.createTextNode(" "));
}
// Add tooltip functionality for VRAM metrics (works with or without label)
if (enableTooltip && id.includes("vram")) {
debugLog(`🔍 Tooltip enabled for ID: ${id}`);
const gpuMatch = id.match(/vram(\d+)/);
if (gpuMatch) {
const gpuId = parseInt(gpuMatch[1]);
debugLog(`✅ Creating tooltip for GPU ${gpuId} (ID: ${id})`);
const tooltip = createGPUTooltip(gpuId);
let hideTimeout = null;
// Show tooltip on hover
monitor.addEventListener("mouseenter", () => {
debugLog(`🖱️ Mouse entered VRAM monitor for GPU ${gpuId}`);
// Clear any pending hide timeout
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
tooltip.style.display = "block";
// Get actual VRAM usage from the monitor value
const valueEl = document.getElementById(`swissarmyknife-monitor-value-vram${gpuId}`);
let actualVramGB = null;
if (valueEl) {
const match = valueEl.textContent.match(/([\d.]+)\s*GB/);
if (match) {
actualVramGB = parseFloat(match[1]);
debugLog(`📊 Actual VRAM from monitor: ${actualVramGB} GB`);
}
}
updateGPUTooltip(gpuId, monitor, actualVramGB);
// Position after a brief delay to allow content to render
setTimeout(() => positionTooltip(tooltip, monitor), 50);
});
monitor.addEventListener("mouseleave", () => {
debugLog(`🖱️ Mouse left VRAM monitor for GPU ${gpuId}`);
// Delay hiding to allow moving mouse to tooltip
hideTimeout = setTimeout(() => {
tooltip.style.display = "none";
}, 200);
});
// Keep tooltip visible when hovering over it
tooltip.addEventListener("mouseenter", () => {
debugLog(`🖱️ Mouse entered tooltip for GPU ${gpuId}`);
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
});
// Hide tooltip when leaving it
tooltip.addEventListener("mouseleave", () => {
debugLog(`🖱️ Mouse left tooltip for GPU ${gpuId}`);
tooltip.style.display = "none";
});
// Update tooltip position on scroll/resize
window.addEventListener("scroll", () => {
if (tooltip.style.display === "block") {
positionTooltip(tooltip, monitor);
}
});
} else {
debugLog(`❌ Failed to match GPU ID from: ${id}`);
}
} else {
debugLog(`⏭️ Skipping tooltip for ID: ${id} (enableTooltip: ${enableTooltip})`);
}
const valueEl = document.createElement("span");
valueEl.className = "swissarmyknife-monitor-value";
valueEl.id = `swissarmyknife-monitor-value-${id}`;
valueEl.textContent = "--";
contentWrapper.appendChild(valueEl);
monitor.appendChild(bgBar);
monitor.appendChild(contentWrapper);
return monitor;
}
/**
* Update monitor value with gradient background based on percentage
*/
function updateMonitorValue(id, value, percent = null) {
const valueEl = document.getElementById(`swissarmyknife-monitor-value-${id}`);
if (!valueEl) return;
valueEl.textContent = value;
const bgBar = document.getElementById(`swissarmyknife-monitor-bg-${id}`);
if (!bgBar) return;
// Set gradient background bar based on percentage
if (percent !== null) {
// Determine color based on percentage thresholds
let color;
if (percent < 40) {
color = "34, 197, 94"; // Green 0-40%
} else if (percent < 60) {
color = "234, 179, 8"; // Yellow 40-60%
} else if (percent < 80) {
color = "249, 115, 22"; // Orange 60-80%
} else {
color = "239, 68, 68"; // Red 80-100%
}
// Set width and color
bgBar.style.width = `${percent}%`;
bgBar.style.backgroundColor = `rgba(${color}, 0.2)`;
} else {
bgBar.style.width = "0%";
bgBar.style.backgroundColor = "transparent";
}
}
/**
* Format bytes to GB
*/
function formatGB(bytes) {
if (bytes === null || bytes === undefined) return "N/A";
const gb = bytes / (1024 ** 3);
return gb.toFixed(1) + " GB";
}
/**
* Format CPU frequency in GHz
*/
function formatGHz(mhz) {
if (mhz === null || mhz === undefined) return "N/A";
const ghz = mhz / 1000;
return ghz.toFixed(2) + " GHz";
}
/**
* Format percentage
*/
function formatPercent(percent) {
if (percent === null || percent === undefined) return "N/A";
return Math.round(percent) + "%";
}
/**
* Format temperature
*/
function formatTemp(temp) {
if (temp === null || temp === undefined) return "N/A";
return Math.round(temp) + "°";
}
/**
* Extract compact GPU model name from full GPU name
* Examples:
* "NVIDIA GeForce RTX 3090 Ti" -> "3090Ti"
* "NVIDIA GeForce RTX 3080" -> "3080"
* "NVIDIA RTX A6000" -> "A6000"
* "AMD Radeon RX 7900 XTX" -> "7900XTX"
*/
function extractGPUModel(fullName) {
if (!fullName) return "GPU";
// Remove common prefixes
let name = fullName
.replace(/NVIDIA\s+GeForce\s+/gi, "")
.replace(/NVIDIA\s+/gi, "")
.replace(/AMD\s+Radeon\s+/gi, "")
.replace(/AMD\s+/gi, "")
.replace(/RTX\s+/gi, "")
.replace(/GTX\s+/gi, "");
// Extract the model number/name (e.g., "3090 Ti", "A6000", "7900 XTX")
const match = name.match(/([A-Z]?\d+\s*[A-Za-z]*)/);
if (match) {
// Remove spaces and capitalize properly (e.g., "3090 Ti" -> "3090Ti")
return match[1].replace(/\s+/g, "");
}
// Fallback to first meaningful word
const words = name.trim().split(/\s+/);
return words[0] || "GPU";
}
/**
* Handle monitor data update
*/
function handleMonitorUpdate(data) {
debugLog("Monitor update received:", data);
const { hardware, gpu } = data;
// Update CPU - show percentage
if (hardware?.cpu_percent !== null && hardware?.cpu_percent !== undefined) {
updateMonitorValue("cpu", formatPercent(hardware.cpu_percent), hardware.cpu_percent);
}
// Update RAM - show used GB
if (hardware?.memory) {
const mem = hardware.memory;
const value = formatGB(mem.used);
updateMonitorValue("ram", value, mem.percent);
}
// Update CPU temperature
if (hardware?.cpu_temp !== null && hardware?.cpu_temp !== undefined) {
updateMonitorValue("cpu-temp", formatTemp(hardware.cpu_temp));
}
// Update GPU info
if (gpu?.devices && gpu.devices.length > 0) {
gpu.devices.forEach((device, index) => {
if (!device.available) return;
// Update GPU label (shows utilization percentage)
if (device.utilization !== null && device.utilization !== undefined) {
updateMonitorValue(`gpu${index}-label`, formatPercent(device.utilization), device.utilization);
}
// Update VRAM - show used GB
if (device.vram) {
const vram = device.vram;
const value = formatGB(vram.used);
updateMonitorValue(`vram${index}`, value, vram.percent);
}
// Update GPU temperature
if (device.temperature !== null && device.temperature !== undefined) {
updateMonitorValue(`gpu${index}-temp`, formatTemp(device.temperature));
}
});
}
}
// Register extension - instance creation deferred to setup
app.registerExtension({
name: EXTENSION_NAME,
async setup() {
debugLog("Resource Monitor extension setup started");
// Load stylesheet first
loadResourceMonitorStyles();
// Create button group DIV (plain DOM, no deprecated imports)
const buttonGroup = document.createElement("div");
buttonGroup.id = "swissarmyknife-resource-monitor";
buttonGroup.className = "comfyui-button-group"; // Same class as Crystools
const monitorWrapper = document.createElement("div");
monitorWrapper.id = "swissarmyknife-resource-monitor-inner";
// Fetch initial status to determine what monitors to create
try {
const response = await fetch("/swissarmyknife/monitor/status");
const result = await response.json();
if (result.success && result.data) {
const { hardware, gpu } = result.data;
// Add CPU monitor
if (hardware?.available) {
monitorWrapper.appendChild(createMonitorDisplay("CPU", "cpu"));
// Add CPU temp if available
if (hardware.cpu_temp !== null) {
monitorWrapper.appendChild(createMonitorDisplay("", "cpu-temp"));
}
monitorWrapper.appendChild(createMonitorDisplay("RAM", "ram"));
}
// Add GPU monitors
if (gpu?.devices && gpu.devices.length > 0) {
gpu.devices.forEach((device, index) => {
if (!device.available) return;
// Extract compact GPU model name
const gpuModel = extractGPUModel(device.name);
// Add GPU model label (no tooltip)
monitorWrapper.appendChild(createMonitorDisplay(gpuModel.toUpperCase(), `gpu${index}-label`, false));
// Add VRAM monitor with tooltip showing loaded models
monitorWrapper.appendChild(createMonitorDisplay("", `vram${index}`, true));
// Add GPU temp if available
if (device.temperature !== null && device.temperature !== undefined) {
monitorWrapper.appendChild(createMonitorDisplay("", `gpu${index}-temp`));
}
});
}
// Update with initial data
handleMonitorUpdate(result.data);
}
} catch (error) {
console.error("%c[🔪SwissArmyKnife][ResourceMonitor]", "color: #ef4444; font-weight: bold;", "Error fetching initial status:", error);
}
buttonGroup.appendChild(monitorWrapper);
// Append monitors to body as floating element
document.body.appendChild(buttonGroup);
debugLog("Resource monitor inserted as floating bar");
const setExecutionGlowState = (isExecuting) => {
if (!buttonGroup) {
return;
}
buttonGroup.classList.toggle("swissarmyknife-is-executing", Boolean(isExecuting));
};
const clearExecutionGlow = () => setExecutionGlowState(false);
api.addEventListener("execution_start", () => setExecutionGlowState(true));
[
"execution_success",
"execution_error",
"execution_cancelled",
"execution_end",
].forEach((eventName) => {
api.addEventListener(eventName, clearExecutionGlow);
});
// Create separate button group for profiler and restart
const actionButtonGroup = document.createElement("div");
actionButtonGroup.id = "swissarmyknife-action-buttons";
actionButtonGroup.className = "comfyui-button-group";
// Add profiler button
const profilerButton = createProfilerButton();
actionButtonGroup.appendChild(profilerButton);
// Add restart button
const restartButton = createRestartButton();
actionButtonGroup.appendChild(restartButton);
// Append action buttons to body as separate floating element
document.body.appendChild(actionButtonGroup);
debugLog("Action buttons inserted as separate floating bar");
// Listen for monitor updates via WebSocket
api.addEventListener("swissarmyknife.monitor", (event) => {
handleMonitorUpdate(event.detail);
});
// Listen for profiler updates via WebSocket
api.addEventListener("swissarmyknife.profiler", (event) => {
debugLog("Profiler update received", event.detail);
// Update cached data
if (profilerButton._profilerData) {
profilerButton._profilerData = event.detail;
// Update popup if visible
const popup = profilerButton._profilerPopup;
if (popup && popup.style.display === "block") {
updateProfilerPopupContent(popup, event.detail);
}
}
});
// Listen for ComfyUI executed events to refresh profiler
api.addEventListener("executed", () => {
debugLog("Node executed, refreshing profiler data");
// Refresh profiler data after a short delay to ensure backend has processed
setTimeout(async () => {
try {
const response = await fetchWithRetry("/swissarmyknife/profiler/stats");
const result = await response.json();
if (result.success && result.data) {
profilerButton._profilerData = result.data;
// Update popup if visible
const popup = profilerButton._profilerPopup;
if (popup && popup.style.display === "block") {
updateProfilerPopupContent(popup, result.data);
}
}
} catch (error) {
debugLog("Error refreshing profiler data:", error);
}
}, 500);
});
debugLog("Resource Monitor extension setup completed");
}
});