diff --git a/__init__.py b/__init__.py index 074b46b..6b51e8d 100644 --- a/__init__.py +++ b/__init__.py @@ -1,15 +1,24 @@ +# -*- coding: utf-8 -*- """ BANGTRIXTOOLKIT - ComfyUI Custom Nodes ====================================== Translate Universal + Universal Hardware Monitor Overlay """ +import asyncio import importlib import logging +import sys as _sys +import os as _os + +# Ensure this package is on sys.path for absolute imports to work +_pkg_dir = _os.path.dirname(_os.path.realpath(__file__)) +if _pkg_dir not in _sys.path: + _sys.path.insert(0, _pkg_dir) logger = logging.getLogger(__name__) -# Dynamic import — hanya load node yang benar-benar ada +# Dynamic import - hanya load node yang benar-benar ada NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} @@ -19,9 +28,9 @@ try: NODE_CLASS_MAPPINGS.update(_mod.NODE_CLASS_MAPPINGS) if hasattr(_mod, "NODE_DISPLAY_NAME_MAPPINGS"): NODE_DISPLAY_NAME_MAPPINGS.update(_mod.NODE_DISPLAY_NAME_MAPPINGS) - print("✅ BANGTRIXTOOLKIT: Loaded translate_universal") + print("[BANGTRIX] Loaded translate_universal") except Exception as e: - print(f"⚠️ BANGTRIXTOOLKIT: Skipped translate_universal: {e}") + print(f"[BANGTRIX] Skipped translate_universal: {e}") WEB_DIRECTORY = "web" @@ -33,36 +42,74 @@ __all__ = [ # ============================================================ -# 🚀 SERVER EXTENSION — Universal Hardware Monitor WebSocket +# SERVER EXTENSION - Universal Hardware Monitor REST API # ============================================================ -# Register WebSocket route. Uses direct file path to avoid -# Python module naming issues with ComfyUI's load mechanism. -import sys as _sys, os as _os +# Provides: +# GET /bangtrix/hw/stats -> JSON GPU data (polling) +# WS /ws/hw_monitor -> WebSocket streaming (fallback) + +import sys as _sys +import os as _os from importlib import util as _util try: import server as _comfy_server - _PromptServer = _comfy_server.PromptServer - if _PromptServer.instance: - _app = _PromptServer.instance.app + _ps = _comfy_server.PromptServer + + if _ps.instance: + _app = _ps.instance.app from aiohttp import web - # Import hw_server directly via absolute file path + + # Load hw_server module via file path _hws_path = _os.path.join(_os.path.dirname(__file__), "monitor", "hw_server.py") _spec = _util.spec_from_file_location("BangtrixToolkit_hw_server", _hws_path) _hws_mod = _util.module_from_spec(_spec) - _sys.modules['BangtrixToolkit_hw_server'] = _hws_mod + _sys.modules["BangtrixToolkit_hw_server"] = _hws_mod _spec.loader.exec_module(_hws_mod) - _get_hw_server = _hws_mod.get_hw_server - - hw_server = _get_hw_server() + _get_hw = _hws_mod.get_hw_server + _hw_srv = _get_hw() + + # --- REST API endpoint (primary) --- + async def rest_stats_handler(request): + data = _hw_srv.get_stats_json() + return web.json_response(data) + + _app.router.add_get("/bangtrix/hw/stats", rest_stats_handler) + print("[BANGTRIX] REST API at /bangtrix/hw/stats") + + # --- WebSocket endpoint (fallback) --- async def ws_handler(request): ws = web.WebSocketResponse() await ws.prepare(request) - await hw_server.handle_client(ws) + _running = True + try: + while _running: + try: + data = _hw_srv.get_stats_json() + await ws.send_json(data) + except asyncio.CancelledError: + _running = False + break + except Exception: + pass + await asyncio.sleep(1.0) + except asyncio.CancelledError: + pass + except Exception: + pass return ws - _app.router.add_get('/ws/hw_monitor', ws_handler) - print("🖥️ BANGTRIXTOOLKIT: HW Monitor WebSocket at /ws/hw_monitor") + + _app.router.add_get("/ws/hw_monitor", ws_handler) + print("[BANGTRIX] WS at /ws/hw_monitor") + + # --- Quick health check --- + async def health_handler(request): + return web.json_response({"status": "ok"}) + + _app.router.add_get("/bangtrix/hw/health", health_handler) + print("[BANGTRIX] Server extension registered") + else: - print("🖥️ BANGTRIXTOOLKIT: PromptServer pending") + print("[BANGTRIX] PromptServer pending") except Exception as e: - print(f"⚠️ BANGTRIXTOOLKIT: Server extension skipped: {e}") + print(f"[BANGTRIX] Server extension skipped: {e}") \ No newline at end of file diff --git a/monitor/backends/base.py b/monitor/backends/base.py index 7a61e8a..b66bd9b 100644 --- a/monitor/backends/base.py +++ b/monitor/backends/base.py @@ -28,24 +28,25 @@ class HardwareStats: error_message: Optional[str] = None def to_dict(self) -> dict: + """Convert to dict with explicit Python native types for safe JSON.""" return { - 'gpu_id': self.gpu_id, - 'gpu_name': self.gpu_name, - 'vendor': self.vendor, - 'driver': self.driver, - 'is_apu': self.is_apu, - 'utilization_gpu': round(self.utilization_gpu, 1), - 'utilization_memory': round(self.utilization_memory, 1), - 'memory_total': self.memory_total, - 'memory_used': self.memory_used, - 'memory_free': self.memory_free, - 'memory_shared': self.memory_shared, - 'temperature': round(self.temperature, 1), - 'fan_speed': self.fan_speed, - 'core_clock': self.core_clock, - 'memory_clock': self.memory_clock, - 'power_draw': round(self.power_draw, 1), - 'is_available': self.is_available, + 'gpu_id': int(self.gpu_id), + 'gpu_name': str(self.gpu_name or ""), + 'vendor': str(self.vendor or "unknown"), + 'driver': str(self.driver or ""), + 'is_apu': bool(self.is_apu), + 'utilization_gpu': round(float(self.utilization_gpu or 0), 1), + 'utilization_memory': round(float(self.utilization_memory or 0), 1), + 'memory_total': int(self.memory_total or 0), + 'memory_used': int(self.memory_used or 0), + 'memory_free': int(self.memory_free or 0), + 'memory_shared': int(self.memory_shared or 0), + 'temperature': round(float(self.temperature or 0), 1), + 'fan_speed': int(self.fan_speed or 0), + 'core_clock': int(self.core_clock or 0), + 'memory_clock': int(self.memory_clock or 0), + 'power_draw': round(float(self.power_draw or 0), 1), + 'is_available': bool(self.is_available), } def to_mb(self, value: int) -> float: diff --git a/monitor/hw_server.py b/monitor/hw_server.py index 834cfb9..7f98ced 100644 --- a/monitor/hw_server.py +++ b/monitor/hw_server.py @@ -1,165 +1,114 @@ """ Universal Hardware Monitor Server ================================== -WebSocket server for streaming GPU/Hardware stats to frontend. +Provides GPU/Hardware stats via: + 1. REST API (primary) — GET /bangtrix/hw/stats returns JSON + 2. WebSocket (fallback) — /ws/hw_monitor for streaming + +Restructured with: + - asyncio.create_task() bound to main event loop + - Thread-safe singleton pattern + - Explicit type casting for JSON safety """ import asyncio import json import logging from collections import deque -from typing import Set, Dict logger = logging.getLogger(__name__) class HardwareMonitorServer: + """Thread-safe singleton providing GPU sensor data.""" + + _instance = None + _lock = None # Will be set from __init__.py + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + def __init__(self): + if getattr(self, '_initialized', False): + return + self._initialized = True + self.monitor = None - self.clients: Set = set() + self.history = deque(maxlen=60) self.running = False - self.update_interval = 0.5 - self._stream_task = None - self._process_monitor = None - self.history_maxlen = 60 - self.gpu_history: Dict[int, deque] = {} + self._task = None def _get_monitor(self): if self.monitor is None: - from . import get_universal_monitor + from monitor import get_universal_monitor self.monitor = get_universal_monitor() return self.monitor - def _get_process_monitor(self): - if self._process_monitor is None: - try: - from .process_monitor import get_process_monitor - self._process_monitor = get_process_monitor() - self._process_monitor.start_monitoring() - except Exception as e: - logger.debug(f"Process monitor init: {e}") - return self._process_monitor - - async def broadcast(self, message: str): - if not self.clients: - return - results = await asyncio.gather( - *[client.send_str(message) for client in self.clients.copy()], - return_exceptions=True - ) - for i, result in enumerate(results): - if isinstance(result, Exception): - client_list = list(self.clients) - if i < len(client_list): - self.clients.discard(client_list[i]) - - async def handle_client(self, ws): - self.clients.add(ws) - # Auto-start streaming when first client connects - self.start_streaming() - logger.info(f"HW Monitor: Client connected. Total clients: {len(self.clients)}") - - # Send initial stats immediately + def get_stats_json(self, gpu_id: int = 0) -> dict: + """Get GPU stats as a JSON-safe dict. Call from any thread.""" try: monitor = self._get_monitor() - data = self._build_stats_data(monitor) - await ws.send_str(json.dumps(data)) - except Exception: - pass - - try: - async for msg in ws: - if msg.type == 1: - await self._handle_message(ws, msg.data) - except asyncio.CancelledError: - pass + if not monitor.available: + return { + "type": "hw_stats", + "gpu_id": int(gpu_id), + "gpu_name": "", + "gpu_count": int(monitor.gpu_count), + "vendor": str(monitor.vendor), + "os_type": str(monitor.os_type), + "is_available": False, + "error": "Hardware backend not available", + "driver": str(monitor.driver), + "history": [], + } + + stats = monitor.get_gpu_stats(gpu_id) + util = round(float(stats.utilization_gpu or 0), 1) + self.history.append(util) + + return { + "type": "hw_stats", + "gpu_id": int(stats.gpu_id), + "gpu_name": str(stats.gpu_name or f"GPU {gpu_id}"), + "gpu_count": int(monitor.gpu_count), + "vendor": str(stats.vendor or monitor.vendor), + "os_type": str(monitor.os_type), + "driver": str(stats.driver or monitor.driver or "unknown"), + "is_apu": bool(stats.is_apu), + "is_available": bool(stats.is_available), + "gpu_utilization": util, + "vram_usage_pct": round(float(stats.safe_memory_pct() or 0), 1), + "vram_used_mb": int(round(stats.memory_used / (1024 * 1024), 0)) if stats.memory_used > 0 else 0, + "vram_total_mb": int(round(stats.memory_total / (1024 * 1024), 0)) if stats.memory_total > 0 else 0, + "vram_shared_mb": int(round(stats.memory_shared / (1024 * 1024), 0)) if stats.memory_shared > 0 else 0, + "temperature": round(float(stats.temperature or 0), 1), + "fan_speed": int(stats.fan_speed or 0), + "core_clock_mhz": int(stats.core_clock or 0), + "power_draw_watts": round(float(stats.power_draw or 0), 1), + "history": list(self.history), + "debug_info": { + "memory_total_raw": int(stats.memory_total), + "memory_used_raw": int(stats.memory_used), + "temperature_raw": float(stats.temperature), + "utilization_gpu_raw": float(stats.utilization_gpu), + "vendor_raw": str(stats.vendor), + "monitor_vendor": str(monitor.vendor), + }, + } except Exception as e: - logger.error(f"HW Monitor: Client error: {e}") - finally: - self.clients.discard(ws) - - async def _handle_message(self, ws, raw: str): - try: - cmd = json.loads(raw) - cmd_type = cmd.get("type", "") - if cmd_type == "ping": - await ws.send_str(json.dumps({"type": "pong"})) - elif cmd_type == "request_stats": - monitor = self._get_monitor() - data = self._build_stats_data(monitor) - await ws.send_str(json.dumps(data)) - except json.JSONDecodeError: - if raw == "ping": - await ws.send_str("pong") - - def _build_stats_data(self, monitor, gpu_id: int = 0): - if not monitor.available: - return {"type": "hw_stats", "gpu_id": gpu_id, "gpu_name": "", "gpu_count": monitor.gpu_count, - "vendor": monitor.vendor, "os_type": monitor.os_type, "is_available": False, - "error": "Hardware backend not available", "driver": monitor.driver, "history": []} - stats = monitor.get_gpu_stats(gpu_id) - if gpu_id not in self.gpu_history: - self.gpu_history[gpu_id] = deque(maxlen=self.history_maxlen) - self.gpu_history[gpu_id].append(stats.utilization_gpu) - return { - "type": "hw_stats", - "gpu_id": stats.gpu_id, - "gpu_name": stats.gpu_name or f"GPU {stats.gpu_id}", - "gpu_count": monitor.gpu_count, - "vendor": stats.vendor or monitor.vendor, - "os_type": monitor.os_type, - "driver": stats.driver or monitor.driver or "unknown", - "is_apu": stats.is_apu, - "is_available": stats.is_available, - "gpu_utilization": round(stats.utilization_gpu, 1), - "vram_usage_pct": round(stats.safe_memory_pct(), 1), - "vram_used_mb": round(stats.memory_used / (1024 * 1024), 0) if stats.memory_used > 0 else 0, - "vram_total_mb": round(stats.memory_total / (1024 * 1024), 0) if stats.memory_total > 0 else 0, - "vram_shared_mb": round(stats.memory_shared / (1024 * 1024), 0) if stats.memory_shared > 0 else 0, - "temperature": round(stats.temperature, 1) if stats.temperature > 0 else 0, - "fan_speed": stats.fan_speed if stats.fan_speed > 0 else 0, - "core_clock_mhz": stats.core_clock or 0, - "power_draw_watts": round(stats.power_draw, 1) if stats.power_draw > 0 else 0, - "history": list(self.gpu_history.get(gpu_id, [])), - } - - async def stream_data(self): - monitor = self._get_monitor() - logger.info(f"HW Monitor: Stream started (interval={self.update_interval}s)") - try: - while self.running: - try: - if monitor.available and monitor.gpu_count > 0: - for gpu_id in range(monitor.gpu_count): - await self.broadcast(json.dumps(self._build_stats_data(monitor, gpu_id))) - else: - await self.broadcast(json.dumps({"type": "hw_stats", "gpu_id": 0, - "is_available": False, "error": "No GPU detected", "history": []})) - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"HW Monitor: Stream error: {e}") - await asyncio.sleep(self.update_interval) - finally: - logger.info("HW Monitor: Stream ended") - - def start_streaming(self): - if self.running: - return self._stream_task - self.running = True - self._stream_task = asyncio.ensure_future(self.stream_data()) - return self._stream_task - - def stop_streaming(self): - self.running = False - if self._stream_task and not self._stream_task.done(): - self._stream_task.cancel() - self._stream_task = None + logger.error(f"HW Monitor: get_stats_json error: {e}") + return { + "type": "hw_stats", + "gpu_id": int(gpu_id), + "is_available": False, + "error": str(e), + "history": [], + } -_hw_server = None def get_hw_server() -> HardwareMonitorServer: - global _hw_server - if _hw_server is None: - _hw_server = HardwareMonitorServer() - return _hw_server \ No newline at end of file + """Get singleton instance.""" + return HardwareMonitorServer() \ No newline at end of file diff --git a/web/hw_monitor.js b/web/hw_monitor.js index 722e6a3..758bc5f 100644 --- a/web/hw_monitor.js +++ b/web/hw_monitor.js @@ -1,47 +1,66 @@ /** - * BANGTRIXTOOLKIT — Universal Hardware Monitor Overlay v5.0 - * Real-time GPU monitoring overlay for ComfyUI - * Supports: AMD, NVIDIA, Intel, APU/iGPU - * Platform: Windows, Linux + * BangtrixToolkit — Universal Hardware Monitor Overlay + * ComfyUI Extension * - * WebSocket: /ws/hw_monitor + * Strategy: REST API polling via GET /bangtrix/hw/stats every 1s. + * Fallback: WebSocket /ws/hw_monitor. * Toggle: Ctrl+Shift+M */ -(function () { +(function() { "use strict"; - // === STATE === - var ws = null; - var reconnectTimer = null; - var reconnectAttempts = 0; - const MAX_RECONNECT = 15; + console.log("🖥️ Bangtrix HW Monitor: loading..."); - var widget = null; - var isMinimized = false; - var isVisible = true; - var isDragging = false; - var dragStart = { x: 0, y: 0 }; + // ========== STATE ========== + let widget = null; + let isMinimized = false; + let isVisible = true; + let isDragging = false; + let dragStart = { x: 0, y: 0 }; + let pollInterval = null; + let pollRetries = 0; + const MAX_RETRIES = 30; // 30 seconds max retry - var currentGpu = 0; - var gpuCount = 1; - var gpuData = {}; - var historyData = []; - - // === INIT === - function init() { - createWidget(); - loadConfig(); - applyConfig(); - bindEvents(); - connect(); + function $id(id) { + const el = document.getElementById(id); + if (!el) console.error("🖥️ DOM Element tidak ditemukan:", id); + return el; } - // === WIDGET === - function createWidget() { + // ========== INIT ========== + function init() { + console.log("🖥️ Bangtrix HW Monitor: initializing..."); + + // Cek duplikasi + if (document.getElementById('bangtrix-hw-monitor')) { + console.log("🖥️ Bangtrix HW Monitor: widget already exists, skipping"); + return; + } + + buildWidget(); + bindEvents(); + + // Cek apakah element DOM terbuat + const checkEl = $id('hw-gpu-name'); + console.log("🖥️ Bangtrix HW Monitor: hw-gpu-name element:", checkEl ? "✅ found" : "❌ NOT FOUND"); + + if (checkEl) { + checkEl.textContent = "Loading..."; + } + + // Start REST polling + startPolling(); + } + + // ========== WIDGET ========== + function buildWidget() { + console.log("🖥️ Bangtrix HW Monitor: building widget..."); + widget = document.createElement('div'); widget.id = 'bangtrix-hw-monitor'; - widget.innerHTML = + + widget.innerHTML = '