349 lines
14 KiB
Python
349 lines
14 KiB
Python
# ComfyUI_HealthCheck.py
|
||
# A lightweight health check plugin for ComfyUI
|
||
# Author: love530love
|
||
# Version: 1.0.7
|
||
|
||
import os
|
||
import sys
|
||
import threading
|
||
import io
|
||
import logging
|
||
import re
|
||
from pathlib import Path
|
||
from datetime import datetime
|
||
|
||
MAX_CAPTURE_CHARS = 200_000
|
||
|
||
|
||
# ===== Dummy Node Definition (Avoid IMPORT FAILED) =====
|
||
class HealthCheckDummyNode:
|
||
"""Placeholder node to prevent ComfyUI marking this file as failed"""
|
||
CATEGORY = "utils"
|
||
FUNCTION = "execute"
|
||
RETURN_TYPES = ()
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {"required": {}}
|
||
|
||
def execute(self):
|
||
return ()
|
||
|
||
|
||
NODE_CLASS_MAPPINGS = {
|
||
"HealthCheckDummy": HealthCheckDummyNode,
|
||
}
|
||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||
"HealthCheckDummy": "Health Check (Internal)",
|
||
}
|
||
|
||
|
||
# ===== Log Capture System =====
|
||
class LogCapture:
|
||
"""Captures ComfyUI startup logs to detect IMPORT FAILED"""
|
||
|
||
def __init__(self):
|
||
self.original_stdout = sys.stdout
|
||
self.original_stderr = sys.stderr
|
||
self.original_handler_streams = {}
|
||
self.captured = io.StringIO()
|
||
self.import_failed_lines = []
|
||
self.import_success_lines = []
|
||
self.import_times_complete = False # 标记是否完成导入统计
|
||
self._lock = threading.Lock()
|
||
self.stdout_proxy = None
|
||
self.stderr_proxy = None
|
||
|
||
def _append_capture(self, data):
|
||
self.captured.write(data)
|
||
if self.captured.tell() > MAX_CAPTURE_CHARS:
|
||
value = self.captured.getvalue()[-MAX_CAPTURE_CHARS:]
|
||
self.captured.seek(0)
|
||
self.captured.truncate(0)
|
||
self.captured.write(value)
|
||
|
||
def _process_line(self, line):
|
||
if "(IMPORT FAILED)" in line or "IMPORT FAILED:" in line:
|
||
self.import_failed_lines.append(line)
|
||
elif "seconds" in line and "custom_nodes" in line and "IMPORT FAILED" not in line:
|
||
self.import_success_lines.append(line)
|
||
elif "Import times for custom nodes:" in line:
|
||
self.import_times_complete = True
|
||
# 旧版兼容:触发延迟报告(延迟更长以覆盖新版额外输出)
|
||
trigger_delayed_report(10.0)
|
||
elif "To see the GUI go to:" in line:
|
||
# 新版 ComfyUI:服务器启动完成,触发延迟报告
|
||
# 延迟 15 秒确保覆盖后续异步插件初始化(DEPRECATION WARNING、
|
||
# web 资源加载、ComfyUI-Manager 缓存更新等)
|
||
# trigger_delayed_report 会自动取消之前的 timer,
|
||
# 所以即使 Import times 先触发,最终也会以这个更晚的触发为准
|
||
trigger_delayed_report(15.0)
|
||
elif "[ComfyUI-Manager] All startup tasks have been completed." in line:
|
||
# ComfyUI-Manager 启动完成,几乎立即输出
|
||
trigger_delayed_report(0.5)
|
||
|
||
@staticmethod
|
||
def _write_original(stream, data):
|
||
try:
|
||
stream.write(data)
|
||
except UnicodeEncodeError:
|
||
encoding = getattr(stream, "encoding", None) or "utf-8"
|
||
safe_data = data.encode(encoding, errors="replace").decode(encoding)
|
||
stream.write(safe_data)
|
||
|
||
def start(self):
|
||
capture = self
|
||
|
||
class TeeIO:
|
||
def __init__(self, stream_type):
|
||
self.stream_type = stream_type
|
||
self.buffer = ""
|
||
|
||
def write(self, data):
|
||
# Write to original stream
|
||
if self.stream_type == 'stdout':
|
||
capture._write_original(capture.original_stdout, data)
|
||
else:
|
||
capture._write_original(capture.original_stderr, data)
|
||
|
||
# Real-time detection
|
||
with capture._lock:
|
||
# Write to capture buffer
|
||
capture._append_capture(data)
|
||
self.buffer += data
|
||
if "\n" in self.buffer:
|
||
lines = self.buffer.split("\n")
|
||
self.buffer = lines[-1]
|
||
for line in lines[:-1]:
|
||
capture._process_line(line)
|
||
|
||
def flush(self):
|
||
if self.stream_type == 'stdout':
|
||
capture.original_stdout.flush()
|
||
else:
|
||
capture.original_stderr.flush()
|
||
|
||
def isatty(self):
|
||
return False
|
||
|
||
self.stdout_proxy = TeeIO('stdout')
|
||
self.stderr_proxy = TeeIO('stderr')
|
||
sys.stdout = self.stdout_proxy
|
||
sys.stderr = self.stderr_proxy
|
||
|
||
# ComfyUI configures logging before custom nodes are imported, so
|
||
# existing handlers keep pointing at the old streams unless updated.
|
||
for handler in logging.getLogger().handlers:
|
||
stream = getattr(handler, "stream", None)
|
||
if stream is capture.original_stdout:
|
||
capture.original_handler_streams[handler] = stream
|
||
handler.stream = sys.stdout
|
||
elif stream is capture.original_stderr:
|
||
capture.original_handler_streams[handler] = stream
|
||
handler.stream = sys.stderr
|
||
|
||
def stop(self):
|
||
for handler, stream in self.original_handler_streams.items():
|
||
handler.stream = stream
|
||
self.original_handler_streams.clear()
|
||
if sys.stdout is self.stdout_proxy:
|
||
sys.stdout = self.original_stdout
|
||
if sys.stderr is self.stderr_proxy:
|
||
sys.stderr = self.original_stderr
|
||
self.stdout_proxy = None
|
||
self.stderr_proxy = None
|
||
return self.captured.getvalue()
|
||
|
||
|
||
# Global capture instance
|
||
log_capture = LogCapture()
|
||
|
||
|
||
# ===== Statistics Functions =====
|
||
def count_plugins():
|
||
"""Count plugins in parent directory (custom_nodes), not current directory"""
|
||
base = Path(__file__).resolve().parent.parent
|
||
total = 0
|
||
folders = 0
|
||
pyfiles = 0
|
||
|
||
for item in base.iterdir():
|
||
if item.name.startswith("__"):
|
||
continue
|
||
if item.is_dir():
|
||
total += 1
|
||
folders += 1
|
||
elif item.suffix == ".py":
|
||
total += 1
|
||
pyfiles += 1
|
||
|
||
return total, folders, pyfiles
|
||
|
||
|
||
def get_node_count():
|
||
try:
|
||
import nodes
|
||
return len(nodes.NODE_CLASS_MAPPINGS)
|
||
except:
|
||
return -1
|
||
|
||
|
||
def extract_failed_plugins(log_lines):
|
||
"""Extract failed plugin names from captured log lines"""
|
||
failed = []
|
||
path_pattern = re.compile(
|
||
r"(?:(?:\(\s*IMPORT FAILED\s*\))|(?:IMPORT FAILED:))\s*:?\s*(.+custom_nodes[\\/][^\\/\s:]+)",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
for line in log_lines:
|
||
normalized = line.strip()
|
||
if "IMPORT FAILED" not in normalized:
|
||
continue
|
||
|
||
match = path_pattern.search(normalized)
|
||
path_text = match.group(1) if match else normalized
|
||
path_text = path_text.strip().strip("'\"")
|
||
parts = path_text.replace("\\", "/").split("/")
|
||
|
||
for i, part in enumerate(parts):
|
||
if part == "custom_nodes" and i + 1 < len(parts):
|
||
plugin_name = parts[i + 1].strip()
|
||
if plugin_name and plugin_name not in failed:
|
||
failed.append(plugin_name)
|
||
break
|
||
return failed
|
||
|
||
|
||
def extract_plugin_name_from_path(path_text):
|
||
"""Extract the plugin folder/file name after custom_nodes from a path."""
|
||
cleaned = path_text.strip().strip("'\"")
|
||
parts = cleaned.replace("\\", "/").split("/")
|
||
for index, part in enumerate(parts):
|
||
if part == "custom_nodes" and index + 1 < len(parts):
|
||
return parts[index + 1].strip()
|
||
return None
|
||
|
||
|
||
# ===== Report Output =====
|
||
BANNER = r"""
|
||
██████╗ ██████╗ ███╗ ███╗ ███████╗ ██╗ ██╗ ██╗ ██╗ ██╗
|
||
██╔════╝ ██╔═══██╗ ████╗ ████║ ██╔════╝ ╚██╗ ██╔╝ ██║ ██║ ██║
|
||
██║ ██║ ██║ ██╔████╔██║ █████╗ ╚████╔╝ ██║ ██║ ██║
|
||
██║ ██║ ██║ ██║╚██╔╝██║ ██╔══╝ ╚██╔╝ ██║ ██║ ██║
|
||
╚██████╗ ╚██████╔╝ ██║ ╚═╝ ██║ ██║ ██║ ╚████╔╝ ██║
|
||
╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝ ╚═╝
|
||
|
||
██╗ ██╗ ███████╗ █████╗ ██╗ ████████╗ ██╗ ██╗ ██████╗ ██╗ ██╗ ███████╗ ██████╗ ██╗ ██╗
|
||
██║ ██║ ██╔════╝ ██╔══██╗ ██║ ╚══██╔══╝ ██║ ██║ ██╔════╝ ██║ ██║ ██╔════╝ ██╔════╝ ██║ ██╔╝
|
||
███████║ █████╗ ███████║ ██║ ██║ ███████║ ██║ ███████║ █████╗ ██║ █████╔╝
|
||
██╔══██║ ██╔══╝ ██╔══██║ ██║ ██║ ██╔══██║ ██║ ██╔══██║ ██╔══╝ ██║ ██╔═██╗
|
||
██║ ██║ ███████╗ ██║ ██║ ███████╗ ██║ ██║ ██║ ╚██████╗ ██║ ██║ ███████╗ ╚██████╗ ██║ ██╗
|
||
╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝
|
||
|
||
🔍 ComfyUI HealthCheck v1.0.7
|
||
"""
|
||
|
||
_report_printed = False # 防止重复输出
|
||
_report_timer = None # 当前待执行的报告 timer
|
||
|
||
|
||
def print_report():
|
||
"""Generate and print health report"""
|
||
global _report_printed
|
||
if _report_printed:
|
||
return
|
||
_report_printed = True
|
||
|
||
try:
|
||
custom_nodes_dir = Path(__file__).resolve().parent.parent
|
||
total, folders, pyfiles = count_plugins()
|
||
node_count = get_node_count()
|
||
|
||
failed_plugins = extract_failed_plugins(log_capture.import_failed_lines)
|
||
|
||
failed_count = len(failed_plugins)
|
||
success_count = total - failed_count
|
||
|
||
health = (success_count / total * 100) if total else 0
|
||
|
||
# Color output
|
||
CYAN = "\033[96m"
|
||
GREEN = "\033[92m"
|
||
RED = "\033[91m"
|
||
YELLOW = "\033[93m"
|
||
WHITE = "\033[97m"
|
||
GRAY = "\033[90m"
|
||
BOLD = "\033[1m"
|
||
RESET = "\033[0m"
|
||
|
||
# 添加空行与其他输出分隔
|
||
print(f"\n\n{CYAN}{'=' * 60}{RESET}")
|
||
print(f"{CYAN}{BANNER}{RESET}")
|
||
print(f"{CYAN}{'=' * 60}{RESET}")
|
||
print(f"{BOLD}{'🚀 ComfyUI Plugin Health Report':^56}{RESET}")
|
||
print(f"{CYAN}{'=' * 60}{RESET}")
|
||
print(f"{WHITE}📦 扫描到已有的插件数/Total Plugins: {BOLD}{total}{RESET} {GRAY}(其中 插件文件夹数/folders: {folders}, 单独以 .py 形式存在的插件数/.py: {pyfiles}){RESET}")
|
||
print(f"{GREEN}✅ 已成功加载的插件数/Successful: {BOLD}{success_count}{RESET}")
|
||
print(f"{RED}❌ 加载失败需要排查原因的插件数/Failed: {BOLD}{failed_count}{RESET}")
|
||
print(f"{YELLOW}📊 健康度/Health: {BOLD}{health:.1f}%{RESET}")
|
||
print(f"{WHITE}🧠 已成功扫描到的节点数/Node Classes: {BOLD}{node_count}{RESET}")
|
||
|
||
if failed_plugins:
|
||
print(f"\n{RED}🚨 加载失败的插件/Failed Plugins:{RESET}")
|
||
for plugin in failed_plugins[:20]:
|
||
full_path = custom_nodes_dir / plugin
|
||
print(f"{RED} ✗ {plugin}{RESET}")
|
||
print(f"{GRAY} └─ {full_path}{RESET}")
|
||
if len(failed_plugins) > 20:
|
||
print(f"{RED} ... 还有/and {len(failed_plugins) - 20} more{RESET}")
|
||
print(f"\n{YELLOW}💡 排查提示/Troubleshooting Hint:{RESET}")
|
||
print(f"{YELLOW} 请查看上方启动日志中的 Traceback、Cannot import、ModuleNotFoundError、ImportError 等关键词。{RESET}")
|
||
print(f"{YELLOW} Search the startup log above for Traceback, Cannot import, ModuleNotFoundError, or ImportError.{RESET}")
|
||
else:
|
||
print(f"\n{GREEN}🎉 所有插件加载成功!/All plugins loaded successfully!{RESET}")
|
||
|
||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
print(f"\n{GRAY}检测时间戳/Checked at: {timestamp}{RESET}")
|
||
print(f"{CYAN}{'=' * 60}{RESET}\n")
|
||
|
||
except Exception as e:
|
||
print(f"\n[HealthCheck] Report generation failed: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
finally:
|
||
log_capture.stop()
|
||
|
||
|
||
def start_daemon_timer(delay, callback):
|
||
timer = threading.Timer(delay, callback)
|
||
timer.daemon = True
|
||
timer.start()
|
||
return timer
|
||
|
||
|
||
def trigger_delayed_report(delay=5.0):
|
||
"""在检测到导入完成后触发报告,支持自定义延迟
|
||
|
||
每次调用会取消之前的 timer,确保只有最后一个触发点生效。
|
||
例如:Import times (10s) → To see the GUI (15s),最终只会执行 15s 的那个。
|
||
"""
|
||
global _report_timer
|
||
if _report_timer is not None:
|
||
_report_timer.cancel()
|
||
_report_timer = start_daemon_timer(delay, print_report)
|
||
|
||
|
||
# ===== Initialization =====
|
||
log_capture.start()
|
||
|
||
|
||
# 备用:如果 60 秒内没有检测到导入完成标记,强制输出
|
||
def backup_timer():
|
||
if not _report_printed:
|
||
print("[HealthCheck] Backup timer triggered...")
|
||
print_report()
|
||
|
||
|
||
start_daemon_timer(60.0, backup_timer)
|