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 = `
`;
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 = `
`;
button.title = "View Workflow Profiler";
// Create popup container
const popup = createProfilerPopup();
button._profilerPopup = popup;
button._profilerData = null;
let hideTimeout = null;
// Add hover handler to show popup
button.addEventListener("mouseenter", async (e) => {
// Clear any pending hide timeout
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
debugLog("Profiler button hover enter");
// 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");
});
// Add handler to keep popup visible when hovering over it
popup.addEventListener("mouseenter", () => {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
});
// Hide popup when mouse leaves button
button.addEventListener("mouseleave", () => {
hideTimeout = setTimeout(() => {
popup.style.display = "none";
debugLog("Profiler popup closed (button leave)");
}, 300); // 300ms delay before hiding
});
// Hide popup when mouse leaves popup
popup.addEventListener("mouseleave", () => {
hideTimeout = setTimeout(() => {
popup.style.display = "none";
debugLog("Profiler popup closed (popup leave)");
}, 300); // 300ms delay before hiding
});
// 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]}`;
}
/**
* Render OOM Analysis content
*/
function renderOomAnalysis(container, oomStats) {
if (!oomStats) {
container.innerHTML = '
No OOM data available
';
return;
}
const {
total_oom_count,
total_workflows,
oom_rate,
recent_ooms = [],
node_type_ranking = [],
model_correlation = [],
recommendations = []
} = oomStats;
let html = '';
// Summary Cards
html += '';
html += `
💥 Total OOMs
${total_oom_count}
📈 Total Workflows
${total_workflows}
📉 OOM Rate
${oom_rate.toFixed(1)}%
`;
html += '
';
// Recent OOMs
if (recent_ooms.length > 0) {
html += '';
html += '
🕒 Recent OOM Events
';
html += '
';
recent_ooms.forEach(oom => {
const timestamp = new Date(oom.timestamp).toLocaleString();
const vramAtOomGB = (oom.vram_at_oom / (1024 ** 3)).toFixed(1);
const vramPeakGB = (oom.vram_peak / (1024 ** 3)).toFixed(1);
const models = oom.models_at_oom ? Object.values(oom.models_at_oom).flat() : [];
html += `
🎮 VRAM at OOM: ${vramAtOomGB} GB (${oom.vram_percent_before.toFixed(1)}% used)
📈 VRAM Peak: ${vramPeakGB} GB
⏱️ Execution: ${formatMs(oom.execution_time)}
`;
if (models.length > 0) {
html += '
';
html += '📦 Models Loaded: ';
html += models.map(m => `${m.base_name} (${m.vram_mb} MB)`).join(', ');
html += '
';
}
if (oom.error_message) {
html += `
⚠️ ${oom.error_message}
`;
}
html += '
';
});
html += '
';
}
// Node Type Ranking
if (node_type_ranking.length > 0) {
html += '';
html += '
🎯 Node Type OOM Frequency
';
html += '
';
html += '| Node Type | OOMs | Total Executions | OOM Rate | Avg VRAM at OOM | Last OOM |
';
html += '';
node_type_ranking.forEach(node => {
const lastOomDate = new Date(node.last_oom).toLocaleDateString();
const oomRateClass = node.oom_rate > 50 ? 'oom-rate-critical' : node.oom_rate > 10 ? 'oom-rate-warning' : '';
html += `
| ${node.node_type} |
${node.oom_count} |
${node.total_executions} |
${node.oom_rate.toFixed(1)}% |
${node.avg_vram_at_oom_mb} MB |
${lastOomDate} |
`;
});
html += '
';
}
// Model Correlation
if (model_correlation.length > 0) {
html += '';
html += '
📦 Model Correlation
';
html += '
';
html += '| Node Type | Models | OOM Count | Total VRAM |
';
html += '';
model_correlation.forEach(corr => {
html += `
| ${corr.node_type} |
${corr.models.join(', ')} |
${corr.oom_count} |
${corr.total_vram_mb} MB |
`;
});
html += '
';
}
// Recommendations
if (recommendations.length > 0) {
html += '';
html += '
💡 Recommendations
';
html += '
';
recommendations.forEach(rec => {
const severityClass = rec.severity === 'critical' ? 'oom-rec-critical' :
rec.severity === 'warning' ? 'oom-rec-warning' : 'oom-rec-info';
const icon = rec.severity === 'critical' ? '🛑' :
rec.severity === 'warning' ? '⚠️' : '💡';
html += `
`;
});
html += '
';
} else if (total_oom_count === 0) {
html += '';
html += '
✅ No OOM errors detected! Your workflows are running smoothly.
';
html += '
';
}
container.innerHTML = html;
}
/**
* Create profiler popup
*/
function createProfilerPopup() {
const popup = document.createElement("div");
popup.id = "swissarmyknife-profiler-popup";
popup.innerHTML = `
Top 10 Slowest Nodes
| Node Type |
Time |
VRAM |
Cache |
💥 |
| No data available |
`;
// 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 with OOM warning indicators
const statsGrid = popup.querySelector("#profiler-stats-grid");
// Helper to get warning class based on highest VRAM percentage in workflow
const getOomWarningClass = () => {
if (!latest.nodes) return '';
const nodeArray = Object.values(latest.nodes);
const maxVramPercent = Math.max(...nodeArray.map(n => n.vramPercentBefore || 0));
if (maxVramPercent >= 95) return 'profiler-stat-card-critical';
if (maxVramPercent >= 85) return 'profiler-stat-card-warning';
return '';
};
const warningClass = getOomWarningClass();
statsGrid.innerHTML = `
⏱️ Total Time
${formatDurationMs(latest.executionTime)}
🎮 VRAM Peak
${formatBytes(latest.totalVramPeak)}
💾 RAM Peak
${formatBytes(latest.totalRamPeak)}
✅ Cache Hits
${latest.cacheHits}
⚡ Executed
${latest.cacheMisses}
📦 Total Nodes
${latest.cacheHits + latest.cacheMisses}
`;
// Update node table (top 10 slowest) with OOM column
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 => `
| ${node.nodeType} |
${formatMs(node.executionTime)} |
${formatBytes(node.vramDelta)} |
${node.cacheHit ? '🟢' : '⚡'} |
${node.oomOccurred ? '💥' : ''} |
`).join('');
} else {
tbody.innerHTML = '| No executed nodes in latest workflow |
';
}
} else {
tbody.innerHTML = '| No node data available |
';
}
}
/**
* 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 = `
Latest Workflow Execution
Loading...
Previous Workflow Runs
Loading...
Node Performance Analytics
Loading...
💥 Out of Memory Analysis
Loading...
Profiler Settings
Loading...
`;
// 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 = `
${JSON.stringify(data.latest, null, 2)}
`;
}
const previousContent = document.getElementById("profiler-previous-content");
if (previousContent && data.history) {
previousContent.innerHTML = `
Total workflows: ${data.history.length}
${JSON.stringify(data.history.slice(0, 10), null, 2)}
`;
}
const analyticsContent = document.getElementById("profiler-analytics-content");
if (analyticsContent && data.node_averages) {
analyticsContent.innerHTML = `
${JSON.stringify(data.node_averages, null, 2)}
`;
}
// Update OOM Analysis tab
const oomContent = document.getElementById("profiler-oom-content");
if (oomContent && data.oom_stats) {
renderOomAnalysis(oomContent, data.oom_stats);
}
const settingsContent = document.getElementById("profiler-settings-content");
if (settingsContent) {
settingsContent.innerHTML = `
Profiler is currently enabled and running.
Settings and archive management coming soon.
`;
}
}
/**
* 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 = `
`;
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 = `
No models loaded
`;
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 = '📦 Loaded Models:
';
modelsHTML += '';
// Add summary with breakdown
const totalModelVRAM = (gpuData.total_vram_mb / 1024).toFixed(1);
const totalModelVRAMNum = parseFloat(totalModelVRAM);
modelsHTML += `
`;
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 = `
Failed to load model data
`;
}
}
}
/**
* 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");
}
});