From f1dee05ca83c87fd7ea0b267dc98310a1b9ea385 Mon Sep 17 00:00:00 2001 From: Anonymzx Date: Sat, 16 May 2026 05:41:56 +0700 Subject: [PATCH] ADD AMD MONITOR BETA --- .gitignore | 19 +- README.md | 494 ++++++++------ __init__.py | 56 +- monitor/__init__.py | 23 + monitor/amd_monitor.py | 115 ++++ monitor/backends/__init__.py | 19 + monitor/backends/adl_backend.py | 113 ++++ monitor/backends/base.py | 69 ++ monitor/backends/libre_hardware.py | 175 +++++ monitor/backends/powershell_backend.py | 224 +++++++ monitor/backends/psutil_backend.py | 40 ++ monitor/process_monitor.py | 226 +++++++ nodes/__init__.py | 9 - nodes/amd_monitor.py | 57 -- requirements.txt | 44 +- server/amd_server.py | 282 ++++++-- test_amd_monitor.py | 54 -- utils/__init__.py | 1 + utils/amd_adl.py | 348 ++++++++++ utils/amd_gpu_counter.py | 255 +++++++ utils/amd_process_monitor.py | 337 ++++++++++ utils/amd_utils.py | 613 +++++++++++++---- web/amd_monitor.js | 887 +++++++++++++------------ 23 files changed, 3492 insertions(+), 968 deletions(-) create mode 100644 monitor/__init__.py create mode 100644 monitor/amd_monitor.py create mode 100644 monitor/backends/__init__.py create mode 100644 monitor/backends/adl_backend.py create mode 100644 monitor/backends/base.py create mode 100644 monitor/backends/libre_hardware.py create mode 100644 monitor/backends/powershell_backend.py create mode 100644 monitor/backends/psutil_backend.py create mode 100644 monitor/process_monitor.py delete mode 100644 nodes/amd_monitor.py delete mode 100644 test_amd_monitor.py create mode 100644 utils/__init__.py create mode 100644 utils/amd_adl.py create mode 100644 utils/amd_gpu_counter.py create mode 100644 utils/amd_process_monitor.py diff --git a/.gitignore b/.gitignore index 5320293..41df370 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ *.py[cod] *$py.class +*.pyc # Virtual environment .venv/ @@ -18,8 +19,10 @@ Thumbs.db # IDE .vscode/ .idea/ +*.swp +*.swo -# Python build +# Build build/ dist/ *.egg-info/ @@ -28,12 +31,18 @@ dist/ temp/ tmp/ -# ComfyUI cache +# ComfyUI *.cache - -# Output files output/ input/ # PyInstaller -*.spec \ No newline at end of file +*.spec + +# Bundled LibreHardwareMonitor +monitor/libre_hardware_monitor/ +monitor/*.zip +monitor/*.exe + +# Test files we don't want in repo +_detect_adlx.py \ No newline at end of file diff --git a/README.md b/README.md index 3153b9c..7561ae1 100644 --- a/README.md +++ b/README.md @@ -1,254 +1,334 @@ -# BangtrixToolkit +
-Advanced Custom Node Toolkit for ComfyUI focused on: -- AI Prompt Translation -- AMD ROCm Utilities -- Workflow Enhancements -- Performance Utilities +# ๐Ÿš€ BangtrixToolkit + +**Advanced Custom Node Toolkit for ComfyUI** + +![GitHub](https://img.shields.io/badge/ComfyUI-Custom_Node-FF6B6B?style=flat-square) +![GitHub](https://img.shields.io/badge/Python-3.10%2B-3776AB?style=flat-square&logo=python) +![GitHub](https://img.shields.io/github/license/Anonymzx/BangtrixToolkit?style=flat-square) +![GitHub](https://img.shields.io/github/stars/Anonymzx/BangtrixToolkit?style=flat-square) + +> *Translate your prompts & monitor your AMD GPU โ€” all inside ComfyUI* --- -
- -# ๐ŸŒ Bangtrix Translate Universal +[๐ŸŒ Translate](#-bangtrix-translate-universal) โ€ข +[๐Ÿ–ฅ๏ธ AMD Monitor](#-amd-monitor--real-time-gpu-overlay-) โ€ข +[๐Ÿ“ฆ Installation](#-installation) โ€ข +[๐Ÿš€ Roadmap](#-roadmap) - +--- + +
+ +--- + +
+๐ŸŒ Bangtrix Translate Universal โ€” Click to expand + +
Bangtrix Translate Universal is an advanced translation node for ComfyUI that automatically translates prompts into English and directly outputs them into CLIP conditioning. -Designed for: -- Stable Diffusion -- Flux -- SDXL -- Pony -- Anime Models -- Realistic Models -- Any CLIP-based workflow +Designed for: **Stable Diffusion ยท Flux ยท SDXL ยท Pony ยท Anime Models ยท Realistic Models ยท Any CLIP-based workflow** --- -# โœจ Features +### โœจ Features -## ๐ŸŒ Translation -- Automatic Google Translate integration -- Multi-language support -- Real-time prompt translation -- Offline-safe fallback +| Category | Features | +|----------|----------| +| ๐ŸŒ **Translation** | Google Translate integration ยท Multi-language support ยท Offline-safe fallback | +| ๐Ÿง  **Enhancement** | Quality presets ยท Style presets ยท Prompt cleaning ยท Enhancement system | +| โŒ **Auto Negative** | Anatomy fixes ยท Blur reduction ยท Watermark filtering ยท Artifact reduction ยท Style-based negatives | +| โšก **Performance** | Translation cache ยท Fast repeated translation ยท Lightweight ยท Universal compatibility | --- -## ๐Ÿง  Prompt Enhancement -- Quality presets -- Style presets -- Prompt cleaning -- Prompt enhancement system +### ๐Ÿ“ Supported Languages + +
+ +| Code | Language | Code | Language | +|------|----------|------|----------| +| `auto` | Auto Detect | `en` | English | +| `id` | Indonesian | `ja` | Japanese | +| `ko` | Korean | `zh-CN` | Chinese (Simplified) | +| `zh-TW` | Chinese (Traditional) | `fr` | French | +| `de` | German | `es` | Spanish | +| `pt` | Portuguese | `ru` | Russian | +| `it` | Italian | `ar` | Arabic | +| `tr` | Turkish | `hi` | Hindi | +| `th` | Thai | `vi` | Vietnamese | + +
--- -## โŒ Auto Negative -Automatically generates optimized negative prompts. +### ๐ŸŽจ Presets -Supports: -- anatomy fixes -- blur reduction -- watermark filtering -- artifact reduction -- style-based negatives +
+ +| Quality | Style | Auto Negative | +|---------|-------|---------------| +| `normal` โ€” Standard | `none` โ€” No style | `normal` โ€” Basic | +| `high` โ€” Better details | `anime` ยท `realistic` ยท `cinematic` | `strong` โ€” Reduced artifacts | +| `ultra` โ€” Maximum | `photography` ยท `product` ยท `fantasy` ยท `portrait` | `ultra` โ€” Maximum cleanup | + +
--- -## โšก Performance -- Translation cache system -- Fast repeated translation -- Lightweight architecture -- Universal model compatibility +### โš™๏ธ Workflow Example ---- +
+See node inputs & outputs -# ๐Ÿ“ Supported Languages +
-| Code | Language | -|------|------| -| auto | Auto Detect | -| id | Indonesian | -| en | English | -| ja | Japanese | -| ko | Korean | -| zh-CN | Simplified Chinese | -| zh-TW | Traditional Chinese | -| fr | French | -| de | German | -| es | Spanish | -| pt | Portuguese | -| ru | Russian | -| it | Italian | -| ar | Arabic | -| tr | Turkish | -| hi | Hindi | -| th | Thai | -| vi | Vietnamese | +**Inputs:** `positive_prompt` ยท `negative_prompt` ยท `source_language` ยท `target_language` ยท `quality_preset` ยท `style_preset` ยท `enable_translate` ยท `enable_enhance` ยท `enable_clean` ยท `enable_cache` ยท `enable_auto_negative` ยท `auto_negative_strength` ยท `show_original` ---- - -# ๐ŸŽจ Quality Presets - -| Preset | Description | -|------|------| -| normal | Standard quality enhancement | -| high | Better details and sharpness | -| ultra | Maximum quality enhancement | - ---- - -# ๐ŸŽญ Style Presets - -| Style | Description | -|------|------| -| none | No style enhancement | -| anime | Anime style optimization | -| realistic | Realistic photography enhancement | -| cinematic | Cinematic lighting and shadows | -| photography | Professional photography style | -| product | Product photography optimization | -| fantasy | Fantasy art enhancement | -| portrait | Portrait optimization | - ---- - -# โŒ Auto Negative Strength - -| Strength | Description | -|------|------| -| normal | Basic negative prompt | -| strong | Improved artifact reduction | -| ultra | Maximum cleanup and artifact filtering | - ---- - ---- - -# โš™๏ธ Node Inputs - -| Input | Description | -|------|------| -| positive_prompt | Positive prompt | -| negative_prompt | Negative prompt | -| source_language | Original language | -| target_language | Translation target language | -| quality_preset | Quality enhancement | -| style_preset | Style enhancement | -| enable_translate | Enable translation | -| enable_enhance | Enable prompt enhancement | -| enable_clean | Clean newline formatting | -| enable_cache | Enable translation cache | -| enable_auto_negative | Enable automatic negative prompts | -| auto_negative_strength | Auto negative strength | -| show_original | Show original + translated text | - ---- - -# ๐Ÿ“ค Outputs - -| Output | Description | -|------|------| -| positive_conditioning | CLIP positive conditioning | -| negative_conditioning | CLIP negative conditioning | -| positive_prompt_final | Final translated positive prompt | -| negative_prompt_final | Final translated negative prompt | - ---- - -# ๐Ÿ”ฅ Workflow Example - -image - -image - ---- - -## Indonesian Input - -```text -wanita cantik memakai kimono di malam hari -``` - -## Final English Prompt - -```text -beautiful woman wearing kimono at night, cinematic lighting, high quality, detailed -``` - ---- - -# ๐Ÿ’ก Tips - -- Use `show_original` to debug translations -- Enable `enable_cache` for faster workflows -- Use `ultra` quality for SDXL and Flux -- Use `anime` preset for anime models -- Use `realistic` or `photography` for realism models - ---- +**Outputs:** `positive_conditioning` ยท `negative_conditioning` ยท `positive_prompt_final` ยท `negative_prompt_final`
-# ๐Ÿ“ฆ Installation +
-## Manual Installation +
-Go to your ComfyUI custom_nodes folder: +| Indonesian Input | Final English Prompt | +|-----------------|---------------------| +| `wanita cantik memakai kimono di malam hari` | `beautiful woman wearing kimono at night, cinematic lighting, high quality, detailed` | + +
+ +--- + +
+Workflow +

+Workflow detail +
+ +--- + +### ๐Ÿ’ก Tips + +> ๐Ÿ”น Use `show_original` to debug translations +> ๐Ÿ”น Enable `enable_cache` for faster workflows +> ๐Ÿ”น Use `ultra` quality for SDXL and Flux +> ๐Ÿ”น Use `anime` preset for anime models +> ๐Ÿ”น Use `realistic` or `photography` for realism models + +
+ +--- + +
+๐Ÿ–ฅ๏ธ AMD Monitor โ€” Real-Time GPU Overlay โ€” Click to expand + +
+ +> **Zero configuration. Plug & play.** AMD Monitor auto-loads when ComfyUI starts โ€” no node needed. + +--- + +### โœจ Features + +
+ +| | Feature | Description | +|:---:|---------|-------------| +| ๐Ÿ“Š | **GPU Load** | Real-time utilization % with animated bar | +| ๐Ÿ’พ | **VRAM Usage** | Used / Total in GB + utilization bar | +| ๐ŸŒก๏ธ | **Temperature** | GPU temp in ยฐC/ยฐF *(requires LibreHardwareMonitor)* | +| ๐Ÿ’จ | **Fan Speed** | Fan RPM/percentage *(requires LibreHardwareMonitor)* | +| ๐Ÿท๏ธ | **GPU Name** | Auto-detects your GPU model *(e.g. RX 7800 XT)* | +| ๐Ÿ“ˆ | **Sparkline Chart** | 30-second GPU Load history graph | +| โšก | **Generation Stats** | Duration, RAM Peak, Delta & CPU Peak | +| ๐Ÿ–ฅ๏ธ | **Multi-GPU** | Chip selector for multi-GPU systems | +| โš ๏ธ | **VRAM Alert** | Toast + inline banner on threshold breach | +| ๐Ÿ–ฑ๏ธ | **Draggable** | Click & drag to reposition | +| โž– | **Minimize** | Toggle with `โˆ’` button | +| โŒจ๏ธ | **Hide/Show** | `Ctrl + Shift + M` shortcut | +| โš™๏ธ | **Settings Panel** | Interval, Alert %, Temp unit, Sparkline toggle | +| ๐Ÿ’พ | **Persist Config** | Position & settings saved in localStorage | + +
+ +--- + +### ๐Ÿ“ธ Preview + + + + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ๐Ÿ”ด AMD Monitor โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ AMD Radeon RX 7800 XT โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚GPU โ”‚ VRAM โ”‚ โ”‚ +โ”‚ โ”‚ 1.2% โ”‚1.1/4.0 GBโ”‚ โ”‚ +โ”‚ โ”‚ โ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ”‚โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ”‚ โ”‚ +โ”‚ โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”‚ +โ”‚ โ”‚ Temp โ”‚ Fan โ”‚ โ”‚ +โ”‚ โ”‚ 45ยฐC โ”‚1200 RPM โ”‚ โ”‚ +โ”‚ โ”‚ โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ”‚ โ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ•ฑ GPU Load History โ•ฒ โ”‚ +โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚ +โ”‚ ๐ŸŸข RX 7800 XT โ”‚ [ps] โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +--- + +### โš™๏ธ Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ComfyUI Server โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ on_app_started() โ”‚ โ”‚ +โ”‚ โ”‚ โ”œโ”€โ”€ WS Route โ†’ /ws/amd_monitor โ”‚ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€ Stream loop โ†’ every 1s โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ WebSocket โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Backend Auto-Detect โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ 1. LibreHardwareMonitor โ† WMI โ”‚ โ”‚ +โ”‚ โ”‚ โ†’ Temp, Fan, Clock, Power โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ 2. PowerShell Counters โ˜… ACTIVE โ”‚ โ”‚ +โ”‚ โ”‚ โ†’ GPU Load, VRAM, GPU Name โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ 3. ADL (atiadlxx.dll) โ”‚ โ”‚ +โ”‚ โ”‚ โ†’ Temp, Fan (partial) โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ 4. psutil (system RAM) โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Comfy Process Monitor โ”‚ โ”‚ +โ”‚ โ”‚ Auto-detect generations โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ JSON every 1s +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Browser (ComfyUI) โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Floating Overlay (amd_monitor.js) โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +--- + +### ๐Ÿ—๏ธ Backend Priority + +
+ +| Priority | Backend | Metrics | Platform | +|:--------:|---------|---------|----------| +| ๐Ÿฅ‡ | **LibreHardwareMonitor** | Temp, Fan, Clock, Power | Windows | +| ๐Ÿฅˆ | **PowerShell Counters** | Load, VRAM, Name | Windows | +| ๐Ÿฅ‰ | **ADL** (atiadlxx.dll) | Temp, Fan | Windows | +| 4 | **psutil** | System RAM only | All | + +
+ +--- + +### ๐ŸŒก๏ธ LibreHardwareMonitor Setup + +To enable **Temperature, Fan Speed, Clock speeds & Power Draw**: + +1. **Auto-download** โ€” LHM portable (8.5MB) is downloaded automatically on first ComfyUI start + ``` + ๐Ÿ“ BangtrixToolkit/monitor/libre_hardware_monitor/LibreHardwareMonitor.exe + ``` + +2. **Run as Administrator** (one-time): + ```powershell + .\monitor\libre_hardware_monitor\LibreHardwareMonitor.exe --wmi + ``` + +3. **Restart ComfyUI** โ€” Backend auto-switches to LHM ๐ŸŽฏ + +> โš ๏ธ **Without LHM**, core metrics still work via PowerShell Counters: GPU Load, VRAM, GPU Name. + +--- + +### โŒจ๏ธ Quick Reference + +| Action | Keyboard / Button | +|--------|------------------| +| Toggle overlay | `Ctrl + Shift + M` | +| Minimize | `โˆ’` button | +| Settings | `โš™` button | + +--- + +### ๐Ÿ“‹ Requirements + +| Package | Needed | Purpose | +|---------|:------:|---------| +| `aiohttp` | โœ… **Yes** | WebSocket server | +| `psutil` | โœ… Recommended | Process Monitor | +| `requests` | โŒ Optional | Translate node fallback | + +
+ +--- + +## ๐Ÿ“ฆ Installation + +
+See installation guide + +
+ +### ๐Ÿ”ง Manual ```bash cd "ComfyUI/custom_nodes" -``` - -Clone repository: - -```bash git clone https://github.com/Anonymzx/BangtrixToolkit.git -``` - -Install dependencies: - -```bash pip install -r requirements.txt ``` -Restart ComfyUI. +> **Restart ComfyUI** after installation. + +### ๐Ÿ“ฅ ComfyUI Manager + +1. Open **ComfyUI Manager** +2. Go to **Custom Nodes Manager** +3. Search for **BangtrixToolkit** +4. Click **Install** +5. **Restart** ComfyUI + +
--- -# ๐Ÿงฉ Installation via ComfyUI Manager +## ๐Ÿš€ Roadmap -1. Open ComfyUI -2. Open ComfyUI Manager -3. Open: - - Custom Nodes Manager -4. Search: - - BangtrixToolkit -5. Click: - - Install -6. Restart ComfyUI +
+ +| Version | Features | +|:-------:|----------| +| **v1** โœ… | Bangtrix Translate Universal | +| **v2** โœ… | AMD Monitor ยท ROCm Utilities | +| **v3** ๐Ÿ”œ | Smart Cache ยท Performance Optimizer ยท Benchmark Tools | + +
--- -# ๐Ÿš€ Roadmap +
-## v1 -- Bangtrix Translate Universal +### โค๏ธ Created by [Anonymzx](https://github.com/Anonymzx) -## v2 -- AMD Monitor -- ROCm Utilities - -## v3 -- Smart Cache -- Performance Optimizer -- Benchmark Tools - ---- - -# โค๏ธ Credits - -Created by: -- Anonymzx \ No newline at end of file +
\ No newline at end of file diff --git a/__init__.py b/__init__.py index d5fadce..52f63aa 100644 --- a/__init__.py +++ b/__init__.py @@ -1,24 +1,26 @@ """ BANGTRIXTOOLKIT - ComfyUI Custom Nodes ====================================== -AMD Monitor & Translate Universal +Translate Universal + AMD Monitor Overlay """ -from .nodes.translate_universal import ( - NODE_CLASS_MAPPINGS, - NODE_DISPLAY_NAME_MAPPINGS -) +import importlib -from .nodes.amd_monitor import ( - NODE_CLASS_MAPPINGS as AMD_NODE_CLASS_MAPPINGS, - NODE_DISPLAY_NAME_MAPPINGS as AMD_NODE_DISPLAY_NAME_MAPPINGS -) +# Dynamic import โ€” hanya load node yang benar-benar ada +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} -# Gabungkan semua node mappings -NODE_CLASS_MAPPINGS = {**NODE_CLASS_MAPPINGS, **AMD_NODE_CLASS_MAPPINGS} -NODE_DISPLAY_NAME_MAPPINGS = {**NODE_DISPLAY_NAME_MAPPINGS, **AMD_NODE_DISPLAY_NAME_MAPPINGS} +try: + _mod = importlib.import_module(".nodes.translate_universal", package=__package__) + if hasattr(_mod, "NODE_CLASS_MAPPINGS"): + 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") +except Exception as e: + print(f"โš ๏ธ BANGTRIXTOOLKIT: Skipped translate_universal: {e}") -WEB_DIRECTORY = "./web" +WEB_DIRECTORY = "web" __all__ = [ "NODE_CLASS_MAPPINGS", @@ -28,45 +30,39 @@ __all__ = [ # ============================================================ -# ๐Ÿš€ SERVER EXTENSION - WebSocket untuk Real-Time Monitoring +# ๐Ÿš€ SERVER EXTENSION โ€” AMD Monitor Overlay (WebSocket) # ============================================================ async def on_app_started(app): - """Register WebSocket endpoint for AMD Monitor (FIXED: 1 argument only)""" + """Register WebSocket endpoint for AMD Monitor overlay""" import asyncio from aiohttp import web from .server.amd_server import get_amd_server - + amd_server = get_amd_server() - + async def websocket_handler(request): ws = web.WebSocketResponse() await ws.prepare(request) await amd_server.handle_client(ws) return ws - - # Register route (safe: won't error if already registered) + try: app.router.add_get('/ws/amd_monitor', websocket_handler) print("๐Ÿ”ด BANGTRIXTOOLKIT: AMD Monitor WebSocket at /ws/amd_monitor") except RuntimeError: - pass # Route already exists - - # Start monitoring in background + pass # Route already registered + if not amd_server.running: - amd_server.running = True - asyncio.create_task(amd_server.stream_data()) - print("โœ… BANGTRIXTOOLKIT: AMD Monitor streaming started") + amd_server.start_streaming(asyncio.get_event_loop()) + print("โœ… BANGTRIXTOOLKIT: AMD Monitor overlay streaming started") -# Register with ComfyUI (FIXED: lambda to match signature) +# Register with ComfyUI try: from server import PromptServer prompt_server = PromptServer.instance - - # Append our async handler (ComfyUI only passes 'app') prompt_server.app.on_startup.append(on_app_started) - print("โœ… BANGTRIXTOOLKIT: Server extension registered") - + print("โœ… BANGTRIXTOOLKIT: Server extension registered (AMD Monitor overlay)") except Exception as e: print(f"โš ๏ธ BANGTRIXTOOLKIT: Server extension skipped: {e}") \ No newline at end of file diff --git a/monitor/__init__.py b/monitor/__init__.py new file mode 100644 index 0000000..e4ca51b --- /dev/null +++ b/monitor/__init__.py @@ -0,0 +1,23 @@ +""" +BangtrixToolkit โ€” Hardware Monitoring Package +=============================================== +Auto-detects best GPU monitoring backend. + +Usage: + from monitor import get_amd_monitor + monitor = get_amd_monitor() + stats = monitor.get_gpu_stats(0) + print(stats.gpu_name, stats.temperature) +""" + +from .amd_monitor import get_amd_monitor, AMDMonitor, AMDGPUStats +from .process_monitor import get_process_monitor, ComfyProcessMonitor, GenerationRecord + +__all__ = [ + "get_amd_monitor", + "AMDMonitor", + "AMDGPUStats", + "get_process_monitor", + "ComfyProcessMonitor", + "GenerationRecord", +] \ No newline at end of file diff --git a/monitor/amd_monitor.py b/monitor/amd_monitor.py new file mode 100644 index 0000000..3715c9e --- /dev/null +++ b/monitor/amd_monitor.py @@ -0,0 +1,115 @@ +""" +AMD Monitor - Unified GPU Monitoring +===================================== +Auto-detects best available backend: +1. LibreHardwareMonitor (Temperature, Fan, Load, Clocks, Power) +2. ADL (AMD Display Library) +3. PowerShell Counters (Utilization, VRAM, GPU name) +4. psutil (System RAM fallback) + +Usage: + from monitor import get_amd_monitor + monitor = get_amd_monitor() + stats = monitor.get_gpu_stats(0) + print(stats.gpu_name, stats.utilization_gpu, stats.temperature) +""" + +import logging +from dataclasses import dataclass +from typing import Optional, List + +from .backends import ( + MonitorBackend, AMDGPUStats, + PowerShellBackend, + ADLBackend, + LibreHardwareBackend, + PsutilBackend, +) + +logger = logging.getLogger(__name__) + + +class AMDMonitor: + """Unified monitor that auto-selects best available backend""" + + def __init__(self): + self.available = False + self.method = None + self.gpu_count = 0 + self.gpu_names: List[str] = [] + self._backend: Optional[MonitorBackend] = None + self._selected_backend_name: Optional[str] = None + self._initialize() + + def _initialize(self): + """Try backends in priority order""" + backends = [ + ("libre-hardware-monitor", LibreHardwareBackend), + ("powershell-counters", PowerShellBackend), + ("adl", ADLBackend), + ("psutil-fallback", PsutilBackend), + ] + + for name, cls in backends: + try: + backend = cls() + if backend.initialize(): + self._backend = backend + self._selected_backend_name = name + self.available = True + self.gpu_count = backend.gpu_count + self.gpu_names = backend.gpu_names + self.method = name + logger.info(f"AMD Monitor: using {name} โ€” {self.gpu_count} GPU(s)") + return + except Exception as e: + logger.debug(f"AMD Monitor: {name} failed: {e}") + + logger.error("AMD Monitor: no backend available") + + def get_gpu_stats(self, gpu_id: int = 0) -> AMDGPUStats: + """Get stats for a specific GPU""" + if not self._backend or not self.available: + return AMDGPUStats( + gpu_id=gpu_id, + is_available=False, + error_message="No backend available" + ) + try: + stats = self._backend.get_stats(gpu_id) + stats.gpu_name = self.gpu_names[gpu_id] if gpu_id < len(self.gpu_names) else stats.gpu_name + return stats + except Exception as e: + return AMDGPUStats( + gpu_id=gpu_id, + is_available=False, + error_message=str(e) + ) + + def get_all_gpu_stats(self) -> List[AMDGPUStats]: + """Get stats for all detected GPUs""" + if not self._backend or self.gpu_count == 0: + return [] + return [self.get_gpu_stats(i) for i in range(self.gpu_count)] + + def close(self): + """Cleanup backend resources""" + if self._backend: + self._backend.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + +# Singleton +_amd_monitor = None + + +def get_amd_monitor() -> AMDMonitor: + global _amd_monitor + if _amd_monitor is None: + _amd_monitor = AMDMonitor() + return _amd_monitor \ No newline at end of file diff --git a/monitor/backends/__init__.py b/monitor/backends/__init__.py new file mode 100644 index 0000000..53b9315 --- /dev/null +++ b/monitor/backends/__init__.py @@ -0,0 +1,19 @@ +""" +Backend monitoring modules for AMD GPU stats. +Auto-detects best available backend. +""" + +from .base import MonitorBackend, AMDGPUStats +from .powershell_backend import PowerShellBackend +from .adl_backend import ADLBackend +from .libre_hardware import LibreHardwareBackend +from .psutil_backend import PsutilBackend + +__all__ = [ + "MonitorBackend", + "AMDGPUStats", + "PowerShellBackend", + "ADLBackend", + "LibreHardwareBackend", + "PsutilBackend", +] \ No newline at end of file diff --git a/monitor/backends/adl_backend.py b/monitor/backends/adl_backend.py new file mode 100644 index 0000000..ad65a77 --- /dev/null +++ b/monitor/backends/adl_backend.py @@ -0,0 +1,113 @@ +""" +ADL (AMD Display Library) Backend +================================== +Direct ctypes binding to atiadlxx.dll on Windows. +""" + +import ctypes +import logging +import platform +from ctypes import byref, c_int, c_void_p, c_char, POINTER, Structure + +from .base import MonitorBackend, AMDGPUStats + +logger = logging.getLogger(__name__) + + +class ADLTemperature(Structure): + _fields_ = [("iSize", c_int), ("iTemperature", c_int)] + + +class ADLFanSpeedValue(Structure): + _fields_ = [("iSize", c_int), ("iSpeedType", c_int), ("iFanSpeed", c_int), ("iFlags", c_int)] + + +class ADLPMActivity(Structure): + _fields_ = [ + ("iSize", c_int), ("iEngineClock", c_int), ("iMemoryClock", c_int), + ("iVddc", c_int), ("iActivityPercent", c_int), ("iCurrentPerformanceLevel", c_int), + ("iCurrentBusSpeed", c_int), ("iCurrentBusLanes", c_int), ("iUnknown", c_int * 4), + ] + + +class ADLBackend(MonitorBackend): + name = "adl" + + def __init__(self): + super().__init__() + self._dll = None + self._context = c_int(0) + + def initialize(self) -> bool: + if platform.system() != "Windows": + return False + try: + self._dll = ctypes.windll.LoadLibrary("atiadlxx.dll") + except: + try: + self._dll = ctypes.windll.LoadLibrary("atiadlxy.dll") + except: + logger.debug("ADL Backend: atiadlxx.dll not found") + return False + + # Try to initialize ADL + try: + create = self._dll.ADL_Main_Control_Create + create.restype = c_int + result = create(0) # ADL_Main_Control_Create with default + if result != 0: + logger.debug(f"ADL Backend: init failed: {result}") + return False + self.available = True + self.gpu_count = 1 + self.gpu_names = ["AMD GPU (ADL)"] + logger.info("ADL Backend: initialized") + return True + except Exception as e: + logger.debug(f"ADL Backend: init error: {e}") + return False + + def get_stats(self, gpu_id: int = 0) -> AMDGPUStats: + stats = AMDGPUStats(gpu_id=gpu_id, gpu_name=self.gpu_names[0], is_available=True) + + try: + # Temperature + temp = ADLTemperature() + temp.iSize = ctypes.sizeof(ADLTemperature) + get_temp = self._dll.ADL_Overdrive5_Temperature_Get + get_temp.restype = c_int + if get_temp(gpu_id, 0, byref(temp)) == 0: + stats.temperature = temp.iTemperature / 1000.0 + + # Activity + activity = ADLPMActivity() + activity.iSize = ctypes.sizeof(ADLPMActivity) + get_act = self._dll.ADL_Overdrive5_CurrentActivity_Get + get_act.restype = c_int + if get_act(gpu_id, byref(activity)) == 0: + stats.utilization_gpu = float(activity.iActivityPercent) + stats.core_clock = activity.iEngineClock + stats.memory_clock = activity.iMemoryClock + + # Fan speed + fan = ADLFanSpeedValue() + fan.iSize = ctypes.sizeof(ADLFanSpeedValue) + fan.iSpeedType = 0 + get_fan = self._dll.ADL_Overdrive5_FanSpeed_Get + get_fan.restype = c_int + if get_fan(gpu_id, 0, byref(fan)) == 0: + stats.fan_speed = fan.iFanSpeed + + except Exception as e: + stats.is_available = False + stats.error_message = str(e) + + return stats + + def close(self): + try: + destroy = self._dll.ADL_Main_Control_Destroy + destroy.restype = c_int + destroy() + except: + pass \ No newline at end of file diff --git a/monitor/backends/base.py b/monitor/backends/base.py new file mode 100644 index 0000000..8e8da3e --- /dev/null +++ b/monitor/backends/base.py @@ -0,0 +1,69 @@ +""" +Base classes for monitoring backends. +""" + +from dataclasses import dataclass, field +from typing import Optional, List + + +@dataclass +class AMDGPUStats: + """Normalized GPU statistics from any backend""" + gpu_id: int = 0 + gpu_name: str = "" + utilization_gpu: float = 0.0 # 0-100% + utilization_memory: float = 0.0 # 0-100% + memory_total: int = 0 # Bytes + memory_used: int = 0 # Bytes + memory_free: int = 0 # Bytes + temperature: float = 0.0 # Celsius + fan_speed: int = 0 # 0-100% + core_clock: int = 0 # MHz + memory_clock: int = 0 # MHz + power_draw: float = 0.0 # Watts + is_available: bool = True + error_message: Optional[str] = None + + def to_dict(self) -> dict: + return { + 'gpu_id': self.gpu_id, + 'gpu_name': self.gpu_name, + '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, + '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, + } + + +class MonitorBackend: + """Base class for all monitoring backends""" + + name = "base" + + def __init__(self): + self.available = False + self.gpu_count = 0 + self.gpu_names: List[str] = [] + + def initialize(self) -> bool: + """Detect hardware and prepare backend. Returns True if available.""" + raise NotImplementedError + + def get_stats(self, gpu_id: int = 0) -> AMDGPUStats: + """Get current stats for a GPU.""" + raise NotImplementedError + + def get_all_stats(self) -> List[AMDGPUStats]: + """Get stats for all GPUs.""" + return [self.get_stats(i) for i in range(self.gpu_count)] + + def close(self): + """Cleanup resources.""" + pass \ No newline at end of file diff --git a/monitor/backends/libre_hardware.py b/monitor/backends/libre_hardware.py new file mode 100644 index 0000000..a705f5e --- /dev/null +++ b/monitor/backends/libre_hardware.py @@ -0,0 +1,175 @@ +""" +LibreHardwareMonitor Backend +============================ +Integrates LibreHardwareMonitor (LHM) for full GPU telemetry. +- Auto-detects if LHM is running via WMI +- Auto-downloads LHM portable if not present +- Does NOT auto-run (requires admin) โ€” user runs installer once +- Reads: Temperature, Fan Speed, GPU Load, VRAM, Clocks, Power +""" + +import logging +import subprocess +import platform +import os +import json +import urllib.request +import zipfile + +from .base import MonitorBackend, AMDGPUStats + +logger = logging.getLogger(__name__) + +LHM_DOWNLOAD_URL = "https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases/download/v0.9.6/LibreHardwareMonitor.NET.10.zip" + + +class LibreHardwareBackend(MonitorBackend): + name = "libre-hardware-monitor" + + def __init__(self): + super().__init__() + self._lhm_exe = None + + def initialize(self) -> bool: + if platform.system() != "Windows": + return False + + # Check if LHM/OHM sensors are accessible via WMI + if self._detect_wmi_sensors(): + logger.info("LHM Backend: detected running instance via WMI") + self.available = True + self.gpu_count = 1 + self.gpu_names = ["AMD GPU (LHM)"] + return True + + # Download LHM if missing (user just needs to run it) + self._ensure_downloaded() + + # Check if exe exists (downloaded but not running) + if self._lhm_exe and os.path.exists(self._lhm_exe): + logger.info( + "LHM Backend: downloaded but not running. " + "Run as Admin: 'monitor\\libre_hardware_monitor\\LibreHardwareMonitor.exe --wmi'" + ) + + return False + + def _get_lhm_dir(self) -> str: + """LHM storage directory""" + base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return os.path.join(base, "libre_hardware_monitor") + + def _detect_wmi_sensors(self) -> bool: + """Check via PowerShell if LHM/OHM WMI sensors exist""" + namespaces = ['root/LibreHardwareMonitor', 'root/OpenHardwareMonitor'] + for ns in namespaces: + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + f"Get-WmiObject -Namespace '{ns}' -Class Sensor " + f"2>$null | Select-Object -First 1 | ConvertTo-Json" + ] + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=3, + creationflags=subprocess.CREATE_NO_WINDOW + ) + if result.returncode == 0 and result.stdout.strip() and 'null' not in result.stdout: + return True + except: + pass + return False + + def _ensure_downloaded(self): + """Download LHM portable if not already present""" + lhm_dir = self._get_lhm_dir() + os.makedirs(lhm_dir, exist_ok=True) + + # Find existing exe + for root, dirs, files in os.walk(lhm_dir): + for f in files: + if f.lower() == "librehardwaremonitor.exe": + self._lhm_exe = os.path.join(root, f) + return + + # Download + zip_path = os.path.join(lhm_dir, "lhm_download.zip") + logger.info("LHM: downloading portable (8.5MB)...") + + try: + req = urllib.request.Request( + LHM_DOWNLOAD_URL, + headers={'User-Agent': 'BangtrixToolkit/1.0'} + ) + with urllib.request.urlopen(req, timeout=120) as resp: + with open(zip_path, 'wb') as f: + f.write(resp.read()) + + if os.path.getsize(zip_path) < 500000: + logger.error("LHM: download failed (too small)") + os.remove(zip_path) + return + + logger.info("LHM: extracting...") + with zipfile.ZipFile(zip_path, 'r') as zf: + zf.extractall(lhm_dir) + os.remove(zip_path) + + # Find exe location after extract + for root, dirs, files in os.walk(lhm_dir): + for f in files: + if f.lower() == "librehardwaremonitor.exe": + self._lhm_exe = os.path.join(root, f) + logger.info(f"LHM: downloaded to {self._lhm_exe}") + return + + except Exception as e: + logger.warning(f"LHM download error: {e}") + if os.path.exists(zip_path): + os.remove(zip_path) + + def get_stats(self, gpu_id: int = 0) -> AMDGPUStats: + """Get GPU stats from LHM/OHM via WMI""" + name = self.gpu_names[gpu_id] if gpu_id < len(self.gpu_names) else f"AMD GPU {gpu_id}" + stats = AMDGPUStats(gpu_id=gpu_id, gpu_name=name, is_available=True) + + # Only works if LHM is running + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + "$sensors = Get-WmiObject -Namespace 'root/LibreHardwareMonitor' -Class Sensor " + "2>$null; if (-not $sensors) { $sensors = Get-WmiObject -Namespace " + "'root/OpenHardwareMonitor' -Class Sensor 2>$null }; " + "$sensors | Where-Object { $_.Value -gt 0 } | " + "Select-Object SensorType, Name, Value | ConvertTo-Json" + ] + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=5, + creationflags=subprocess.CREATE_NO_WINDOW + ) + + if result.returncode == 0 and result.stdout.strip(): + data = json.loads(result.stdout.strip()) + if isinstance(data, dict): + data = [data] + for item in data: + stype = item.get('SensorType', '') + sname = item.get('Name', '') + svalue = float(item.get('Value', 0)) + if stype == 'Temperature' and 'GPU' in sname: + stats.temperature = svalue + elif stype == 'Fan' and 'GPU' in sname: + stats.fan_speed = int(svalue) + elif stype == 'Load' and 'GPU' in sname: + if 'Memory' not in sname: + stats.utilization_gpu = svalue + else: + stats.utilization_memory = svalue + elif stype == 'Clock' and 'GPU' in sname: + if 'Memory' in sname: + stats.memory_clock = int(svalue) + elif 'Core' in sname: + stats.core_clock = int(svalue) + except Exception as e: + logger.warning(f"LHM sensor error: {e}") + + return stats \ No newline at end of file diff --git a/monitor/backends/powershell_backend.py b/monitor/backends/powershell_backend.py new file mode 100644 index 0000000..2ac6864 --- /dev/null +++ b/monitor/backends/powershell_backend.py @@ -0,0 +1,224 @@ +""" +PowerShell Counter Backend +========================== +Reads GPU stats via Windows Performance Counters + WMI. +No extra dependencies (uses built-in PowerShell). +""" + +import logging +import subprocess +import platform +import json +import re + +from .base import MonitorBackend, AMDGPUStats + +logger = logging.getLogger(__name__) + + +class PowerShellBackend(MonitorBackend): + name = "powershell-counters" + + def initialize(self) -> bool: + if platform.system() != "Windows": + logger.debug("PS Backend: Windows only") + return False + return self._detect_gpus() + + def _detect_gpus(self) -> bool: + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + "Get-WmiObject Win32_VideoController | " + "Where-Object { $_.AdapterRAM -gt 0 } | " + "Select-Object Name, AdapterRAM | ConvertTo-Json" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=5) + if result.returncode == 0 and result.stdout.strip(): + data = json.loads(result.stdout.strip()) + if isinstance(data, dict): + data = [data] + for gpu in data: + name = gpu.get('Name', 'Unknown GPU').strip() + self.gpu_names.append(name) + self.gpu_count = len(self.gpu_names) + self.available = self.gpu_count > 0 + if self.available: + logger.info(f"PS Backend: {self.gpu_count} GPU(s): {self.gpu_names}") + return self.available + except Exception as e: + logger.debug(f"PS Backend detect error: {e}") + return False + + def get_stats(self, gpu_id: int = 0) -> AMDGPUStats: + name = self.gpu_names[gpu_id] if gpu_id < len(self.gpu_names) else f"AMD GPU {gpu_id}" + + gpu_util = self._get_gpu_utilization() + temp = self._get_gpu_temperature() + fan = self._get_gpu_fan_speed() + + # Get VRAM โ€” try multiple methods + mem_total, mem_used = self._get_vram_info() + + # Only fallback to system RAM if absolutely no GPU info + if mem_total == 0: + try: + import psutil + svmem = psutil.virtual_memory() + mem_total = svmem.total + mem_used = svmem.used + except: + pass + + mem_util = (mem_used / mem_total * 100) if mem_total > 0 else 0 + + return AMDGPUStats( + gpu_id=gpu_id, + gpu_name=name, + utilization_gpu=gpu_util, + utilization_memory=mem_util, + memory_total=mem_total, + memory_used=mem_used, + memory_free=mem_total - mem_used, + temperature=temp, + fan_speed=fan, + is_available=True, + ) + + def _get_vram_info(self) -> tuple: + """ + Get VRAM total & used in bytes. + Tries multiple methods due to WMI AdapterRAM 4GB limit bug. + """ + # Method 1: Direct registry query for accurate VRAM + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + "$gpu = Get-WmiObject Win32_VideoController | Select-Object -First 1; " + "[PSCustomObject]@{" + " Total = if ($gpu.AdapterRAM -and $gpu.AdapterRAM -gt 4GB) { $gpu.AdapterRAM } else { " + " # Try registry for accurate VRAM > 4GB\n" + " $paths = Get-ChildItem 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}\\*' " + "-ErrorAction SilentlyContinue | Where-Object { $_.GetValue('HardwareInformation.AdapterString') -match 'Radeon|AMD' }; " + " foreach ($p in $paths) { " + " $vram = $p.GetValue('HardwareInformation.qwMemorySize'); " + " if ($vram -and $vram -gt 0) { $vram; break } " + " } " + " }; " + " Used = 0 " + "} | ConvertTo-Json" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=5, + creationflags=subprocess.CREATE_NO_WINDOW) + if result.returncode == 0 and result.stdout.strip(): + data = json.loads(result.stdout.strip()) + total = int(data.get('Total', 0)) + used = int(data.get('Used', 0)) + if total > 0: + return total, used + except: + pass + + # Method 2: Try reading registry directly via PowerShell + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + "$paths = Get-ChildItem 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\" + "{4d36e968-e325-11ce-bfc1-08002be10318}\\*' -ErrorAction SilentlyContinue; " + "foreach ($p in $paths) { " + " $str = $p.GetValue('HardwareInformation.AdapterString'); " + " if ($str -match 'Radeon|AMD|RDNA') { " + " $mem = $p.GetValue('HardwareInformation.qwMemorySize'); " + " if ($mem) { $mem; break } " + " } " + "}" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=5, + creationflags=subprocess.CREATE_NO_WINDOW) + if result.returncode == 0 and result.stdout.strip(): + total = int(result.stdout.strip()) + if total > 0: + return total, 0 + except: + pass + + # Method 3: AdapterRAM from WMI (may be capped at 4GB for some drivers) + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + "(Get-WmiObject Win32_VideoController | Select-Object -First 1).AdapterRAM" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3, + creationflags=subprocess.CREATE_NO_WINDOW) + if result.returncode == 0 and result.stdout.strip(): + total = int(result.stdout.strip()) + if total > 0: + return total, 0 + except: + pass + + return 0, 0 + + def _get_gpu_utilization(self) -> float: + try: + # Only query 3D engine type to avoid noise from other engines + ps_cmd = ( + "@(Get-Counter '\\GPU Engine(*engtype_3D)\\*' -ErrorAction SilentlyContinue " + "| Select-Object -ExpandProperty CounterSamples " + "| Where-Object { $_.CookedValue -gt 0 }).CookedValue " + "| Measure-Object -Average | Select-Object -ExpandProperty Average" + ) + result = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, text=True, timeout=5, + creationflags=subprocess.CREATE_NO_WINDOW + ) + if result.returncode == 0 and result.stdout.strip(): + val = float(result.stdout.strip()) + # Counter returns nanoseconds per sample + # Normalize: typical 60fps = 16.67ms per frame = 16,670,000 ns + # 100% utilization on a single engine โ‰ˆ 16,670,000 ns + # Multiple GPUs/engines sum โ€” cap at 100 + pct = (val / 16670000.0) * 100.0 + return min(100.0, max(0.0, pct)) + except: + pass + return 0.0 + + def _get_gpu_temperature(self) -> float: + for ns in ['root/LibreHardwareMonitor', 'root/OpenHardwareMonitor']: + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + f"Get-WmiObject -Namespace '{ns}' -Class Sensor 2>$null | " + f"Where-Object {{ $_.SensorType -eq 'Temperature' -and " + f"$_.Name -match 'GPU' }} | Select-Object -First 1 -ExpandProperty Value" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3, + creationflags=subprocess.CREATE_NO_WINDOW) + if result.returncode == 0 and result.stdout.strip(): + val = float(result.stdout.strip()) + if val > 0: + return val + except: + pass + return 0.0 + + def _get_gpu_fan_speed(self) -> int: + for ns in ['root/LibreHardwareMonitor', 'root/OpenHardwareMonitor']: + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + f"Get-WmiObject -Namespace '{ns}' -Class Sensor 2>$null | " + f"Where-Object {{ $_.SensorType -eq 'Fan' -and " + f"$_.Name -match 'GPU' }} | Select-Object -First 1 -ExpandProperty Value" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3, + creationflags=subprocess.CREATE_NO_WINDOW) + if result.returncode == 0 and result.stdout.strip(): + val = float(result.stdout.strip()) + if val > 0: + return int(val) + except: + pass + return 0 \ No newline at end of file diff --git a/monitor/backends/psutil_backend.py b/monitor/backends/psutil_backend.py new file mode 100644 index 0000000..f6b3d04 --- /dev/null +++ b/monitor/backends/psutil_backend.py @@ -0,0 +1,40 @@ +""" +Psutil Fallback Backend +======================= +Uses psutil for basic system memory info (last resort). +""" + +import logging +from .base import MonitorBackend, AMDGPUStats + +logger = logging.getLogger(__name__) + + +class PsutilBackend(MonitorBackend): + name = "psutil-fallback" + + def initialize(self) -> bool: + try: + import psutil + self.available = True + self.gpu_count = 1 + self.gpu_names = ["System RAM (no GPU backend)"] + logger.info("Psutil Backend: fallback active") + return True + except ImportError: + logger.debug("Psutil Backend: psutil not installed") + return False + + def get_stats(self, gpu_id: int = 0) -> AMDGPUStats: + import psutil + svmem = psutil.virtual_memory() + return AMDGPUStats( + gpu_id=gpu_id, + gpu_name=self.gpu_names[0], + utilization_gpu=0.0, + utilization_memory=(svmem.used / svmem.total * 100) if svmem.total > 0 else 0, + memory_total=svmem.total, + memory_used=svmem.used, + memory_free=svmem.available, + is_available=True, + ) \ No newline at end of file diff --git a/monitor/process_monitor.py b/monitor/process_monitor.py new file mode 100644 index 0000000..9eee99b --- /dev/null +++ b/monitor/process_monitor.py @@ -0,0 +1,226 @@ +""" +ComfyUI Process Monitor +======================= +Monitors ComfyUI process during image generation. +Auto-detects generation start/end and records peak memory/CPU. + +Usage: + from monitor import get_process_monitor + monitor = get_process_monitor() + monitor.start_monitoring() + # ... generate images ... + print(monitor.get_summary()) +""" + +import logging +import time +import threading +from dataclasses import dataclass +from typing import Optional, List + +logger = logging.getLogger(__name__) + + +@dataclass +class GenerationRecord: + """Stats for a single generation run""" + timestamp: float = 0.0 + duration: float = 0.0 + vram_peak_mb: float = 0.0 + vram_start_mb: float = 0.0 + vram_end_mb: float = 0.0 + vram_delta_mb: float = 0.0 + ram_peak_mb: float = 0.0 + ram_start_mb: float = 0.0 + ram_end_mb: float = 0.0 + cpu_peak: float = 0.0 + error: Optional[str] = None + + +@dataclass +class ProcessSnapshot: + """Point-in-time process metrics""" + timestamp: float + memory_rss_mb: float + memory_vms_mb: float + cpu_percent: float + num_threads: int + + +class ComfyProcessMonitor: + """Monitors ComfyUI process for generation metrics""" + + def __init__(self, poll_interval: float = 0.5): + self.poll_interval = poll_interval + self._process = None + self._process_pid = None + self._running = False + self._thread = None + self._is_generating = False + self._gen_start_time = 0.0 + self._current_record: Optional[GenerationRecord] = None + self._prev_memory_mb = 0.0 + self.history: List[GenerationRecord] = [] + self.current: Optional[GenerationRecord] = None + self._snapshots: List[ProcessSnapshot] = [] + self._lock = threading.Lock() + + # Detection thresholds (tuned) + self._MEMORY_START_THRESHOLD_MB = 50 + self._CPU_START_THRESHOLD = 15 + self._MEMORY_END_THRESHOLD_MB = -30 + self._CPU_END_THRESHOLD = 8 + + def find_comfy_process(self) -> bool: + """Auto-detect ComfyUI Python process""" + try: + import psutil + pid = None + current = psutil.Process() + cmdline = ' '.join(current.cmdline()).lower() + + # Check if WE are the ComfyUI process + if 'comfy' in cmdline or 'main.py' in cmdline: + pid = current.pid + + # Search by name + if not pid: + for proc in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + name = (proc.info['name'] or '').lower() + cl = ' '.join(proc.info['cmdline'] or []).lower() + if ('python' in name or 'python' in cl) and ('comfy' in cl or 'main.py' in cl): + pid = proc.info['pid'] + break + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + if pid: + self._process_pid = pid + self._process = psutil.Process(pid) + logger.info(f"Process Monitor: found PID {pid}") + return True + + logger.warning("Process Monitor: ComfyUI not found") + return False + except Exception as e: + logger.warning(f"Process Monitor: find error: {e}") + return False + + def start_monitoring(self): + """Begin background monitoring thread""" + if self._running: + return + if not self.find_comfy_process(): + return + self._running = True + self._thread = threading.Thread(target=self._poll_loop, daemon=True) + self._thread.start() + logger.info("Process Monitor: started") + + def stop_monitoring(self): + """Stop background monitoring""" + self._running = False + if self._thread: + self._thread.join(timeout=2) + + def _poll_loop(self): + try: + import psutil + except ImportError: + self._running = False + return + + while self._running: + try: + if not self._process or not self._process.is_running(): + if not self.find_comfy_process(): + time.sleep(self.poll_interval * 10) + continue + + mem = self._process.memory_info() + cpu = self._process.cpu_percent(interval=0) + rss_mb = mem.rss / (1024 * 1024) + delta = rss_mb - self._prev_memory_mb + snap = ProcessSnapshot(time.time(), rss_mb, mem.vms / (1024*1024), cpu, self._process.num_threads()) + + with self._lock: + if not self._is_generating: + if delta > self._MEMORY_START_THRESHOLD_MB or cpu > self._CPU_START_THRESHOLD: + self._is_generating = True + self._gen_start_time = time.time() + self._current_record = GenerationRecord( + timestamp=self._gen_start_time, duration=0, + vram_start_mb=rss_mb, vram_peak_mb=rss_mb, + ram_start_mb=rss_mb, ram_peak_mb=rss_mb, + ) + self._snapshots = [snap] + logger.info(f"Process Monitor: START (RAM: {rss_mb:.0f}MB)") + else: + if self._current_record: + self._current_record.vram_peak_mb = max(self._current_record.vram_peak_mb, rss_mb) + self._current_record.ram_peak_mb = max(self._current_record.ram_peak_mb, rss_mb) + self._current_record.cpu_peak = max(self._current_record.cpu_peak, cpu) + self._snapshots.append(snap) + + if delta < self._MEMORY_END_THRESHOLD_MB and cpu < self._CPU_END_THRESHOLD: + self._finalize(rss_mb) + logger.info(f"Process Monitor: END ({self.current.duration:.1f}s)") + + self._prev_memory_mb = rss_mb + except Exception as e: + logger.debug(f"Process Monitor: {e}") + time.sleep(self.poll_interval) + + def _finalize(self, end_mb: float): + if not self._current_record: + return + self._current_record.duration = time.time() - self._gen_start_time + self._current_record.vram_end_mb = end_mb + self._current_record.ram_end_mb = end_mb + self._current_record.vram_delta_mb = self._current_record.vram_peak_mb - self._current_record.vram_start_mb + self.current = self._current_record + self.history.append(self._current_record) + if len(self.history) > 50: + self.history = self.history[-50:] + self._is_generating = False + self._current_record = None + self._snapshots = [] + + @property + def is_generating(self) -> bool: + return self._is_generating + + def get_current_generation(self) -> GenerationRecord: + with self._lock: + if self._is_generating and self._current_record: + return self._current_record + return GenerationRecord() + + def get_last_generation(self) -> Optional[GenerationRecord]: + if self.history: + return self.history[-1] + return None + + def get_summary(self) -> str: + if not self.history: + return "No generations recorded." + lines = ["=== ComfyUI Process Monitor ==="] + total_time = peak_ram = 0 + for gen in self.history[-5:]: + total_time += gen.duration + peak_ram = max(peak_ram, gen.ram_peak_mb) + lines.append(f" #{len(lines)}: {gen.duration:.1f}s | RAM: {gen.ram_start_mb:.0f}โ†’{gen.ram_peak_mb:.0f}MB") + lines.append(f" --- Total: {total_time:.1f}s | Peak RAM: {peak_ram:.0f}MB") + return "\n".join(lines) + + +# Singleton +_process_monitor = None + + +def get_process_monitor() -> ComfyProcessMonitor: + global _process_monitor + if _process_monitor is None: + _process_monitor = ComfyProcessMonitor() + return _process_monitor \ No newline at end of file diff --git a/nodes/__init__.py b/nodes/__init__.py index e4337b0..dc8c771 100644 --- a/nodes/__init__.py +++ b/nodes/__init__.py @@ -3,15 +3,6 @@ from .translate_universal import ( NODE_DISPLAY_NAME_MAPPINGS ) -from .amd_monitor import ( - NODE_CLASS_MAPPINGS as AMD_CLASS, - NODE_DISPLAY_NAME_MAPPINGS as AMD_DISPLAY -) - -# Gabungkan mappings -NODE_CLASS_MAPPINGS = {**NODE_CLASS_MAPPINGS, **AMD_CLASS} -NODE_DISPLAY_NAME_MAPPINGS = {**NODE_DISPLAY_NAME_MAPPINGS, **AMD_DISPLAY} - __all__ = [ "NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS" diff --git a/nodes/amd_monitor.py b/nodes/amd_monitor.py deleted file mode 100644 index 9d80652..0000000 --- a/nodes/amd_monitor.py +++ /dev/null @@ -1,57 +0,0 @@ -from ..utils.amd_utils import get_amd_monitor - - -class AMDMonitorNode: - """ - A node to monitor AMD GPU statistics. - Outputs: GPU Usage (%), VRAM Used (MB), VRAM Total (MB), Temperature (C) - """ - - @classmethod - def INPUT_TYPES(s): - return { - "required": { - "gpu_index": ("INT", {"default": 0, "min": 0, "max": 10, "step": 1}), - } - } - - RETURN_TYPES = ("FLOAT", "FLOAT", "FLOAT", "INT", "INT", "STRING") - RETURN_NAMES = ("gpu_utilization", "vram_usage_pct", "temperature", "vram_used_mb", "vram_total_mb", "status_msg") - FUNCTION = "monitor" - CATEGORY = "BANGTRIXTOOLKIT/System" - - def monitor(self, gpu_index): - monitor = get_amd_monitor() - - if not monitor.available: - return (0.0, 0.0, 0.0, 0, 0, "Error: AMD Backend not found") - - stats = monitor.get_gpu_stats(gpu_index) - - if not stats.is_available: - return (0.0, 0.0, 0.0, 0, 0, f"Error: {stats.error_message}") - - # Convert bytes to MB for easier reading - vram_used_mb = stats.memory_used / (1024 * 1024) - vram_total_mb = stats.memory_total / (1024 * 1024) - - status = "OK" - - return ( - float(stats.utilization_gpu), - float(stats.utilization_memory), - float(stats.temperature), - int(vram_used_mb), - int(vram_total_mb), - status - ) - - -# Registry -NODE_CLASS_MAPPINGS = { - "BANGTRIX_AMD_Monitor": AMDMonitorNode -} - -NODE_DISPLAY_NAME_MAPPINGS = { - "BANGTRIX_AMD_Monitor": "AMD Monitor (BANGTRIX)" -} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 68220a3..5a9948d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,47 @@ -# HTTP & WebSocket Server (Required for AMD Monitor) +# ============================================ +# BANGTRIXTOOLKIT - Dependencies +# ============================================ +# Install with: pip install -r requirements.txt + +# ============================================ +# ๐Ÿ“ก WebSocket & HTTP Server (WAJIB) +# ============================================ +# Diperlukan untuk AMD Monitor WebSocket + streaming aiohttp>=3.8.0 async-timeout>=4.0.0 -# HTTP Requests (Optional - for translate_universal) +# ============================================ +# ๐ŸŒ HTTP Client (Opsional - translate node) +# ============================================ +# Google Translate API + GitHub API (LHM download) +# NOTE: Kalau tidak diinstall, Python urllib akan dipakai requests>=2.31.0 -# System Monitoring +# ============================================ +# ๐Ÿ–ฅ๏ธ System Monitoring (DIREKOMENDASIKAN) +# ============================================ +# Diperlukan untuk: +# - Process Monitor (deteksi generasi ComfyUI) +# - VRAM/memory fallback +# - CPU utilization tracking psutil>=5.9.0 -# AMD GPU Monitoring - Linux (ROCm) +# ============================================ +# ๐Ÿง AMD GPU - Linux (ROCm) +# ============================================ +# Hanya untuk Linux dengan driver ROCm terinstall pyrsmi>=0.2.0; sys_platform == "linux" -# AMD GPU Monitoring - Windows (ADL) -pyadl>=1.0.0; sys_platform == "win32" \ No newline at end of file +# ============================================ +# ๐ŸชŸ AMD GPU - Windows (Legacy ADL) +# ============================================ +# Opsional: untuk GPU temperature/fan via ADL +# NOTE: Sebagian driver AMD terbaru mungkin tidak kompatibel +pyadl>=1.0.0; sys_platform == "win32" + +# ============================================ +# ๐ŸŒก๏ธ LibreHardwareMonitor (Windows) +# ============================================ +# TIDAK PERLU install! Auto-download dari GitHub saat runtime. +# Baca: Temperature, Fan, Clock, Power, Load via WMI. +# Minimal: Python 3.8+ (built-in: subprocess, wmi, powershell) \ No newline at end of file diff --git a/server/amd_server.py b/server/amd_server.py index fffeb7a..ea2d037 100644 --- a/server/amd_server.py +++ b/server/amd_server.py @@ -1,13 +1,16 @@ """ AMD Monitor Server ================== -WebSocket server for streaming AMD GPU stats to frontend +WebSocket server for streaming AMD GPU stats to frontend. +Supports multi-GPU, commands, history, and ComfyUI process monitoring. +Default interval: 5s """ import asyncio import json import logging -from typing import Set +from collections import deque +from typing import Set, Dict logger = logging.getLogger(__name__) @@ -17,89 +20,252 @@ class AMDMonitorServer: self.monitor = None # Lazy load self.clients: Set = set() self.running = False - self.update_interval = 1.0 # seconds + self.update_interval = 1.0 # seconds โ€” real-time + self._stream_task = None + + # Process monitor + self._process_monitor = None + + # Multi-GPU history + self.history_maxlen = 30 + self.gpu_history: Dict[int, deque] = {} def _get_monitor(self): - """Lazy load monitor to avoid import issues at startup""" if self.monitor is None: - from ..utils.amd_utils import get_amd_monitor - self.monitor = get_amd_monitor() + try: + from ..monitor import get_amd_monitor + self.monitor = get_amd_monitor() + except ImportError: + from ..utils.amd_utils import get_amd_monitor as _legacy_monitor + self.monitor = _legacy_monitor() return self.monitor + def _get_process_monitor(self): + if self._process_monitor is None: + try: + from ..monitor.process_monitor import get_process_monitor + self._process_monitor = get_process_monitor() + self._process_monitor.start_monitoring() + except ImportError: + from ..utils.amd_process_monitor import get_process_monitor as _legacy_pm + self._process_monitor = _legacy_pm() + 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): - """Send data to all connected clients""" - if self.clients: - await asyncio.gather( - *[client.send_str(message) for client in self.clients], - return_exceptions=True - ) + 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): + logger.debug(f"AMD Monitor: Removing dead client: {result}") + self.clients.discard(client_list[i]) async def handle_client(self, ws): - """Handle WebSocket client connection""" self.clients.add(ws) logger.info(f"AMD Monitor: Client connected. Total clients: {len(self.clients)}") - + try: async for msg in ws: - if msg.type == 1: # Text message - if msg.data == "ping": - await ws.send_str("pong") + if msg.type == 1: + await self._handle_message(ws, msg.data) + except asyncio.CancelledError: + pass except Exception as e: logger.error(f"AMD Monitor: Client error: {e}") finally: self.clients.discard(ws) logger.info(f"AMD Monitor: Client disconnected. Total clients: {len(self.clients)}") - async def stream_data(self): - """Stream GPU stats periodically""" - monitor = self._get_monitor() - - while self.running: - try: - if monitor.available: - stats = monitor.get_gpu_stats(0) # GPU 0 - - data = { - "type": "amd_stats", - "gpu_id": stats.gpu_id, - "gpu_utilization": stats.utilization_gpu, - "vram_usage_pct": stats.utilization_memory, - "vram_used_mb": stats.memory_used / (1024 * 1024), - "vram_total_mb": stats.memory_total / (1024 * 1024), - "temperature": stats.temperature, - "fan_speed": stats.fan_speed, - "is_available": stats.is_available, - "method": monitor.method, - "gpu_count": monitor.gpu_count - } - else: - data = { - "type": "amd_stats", - "is_available": False, - "error": "AMD backend not available", - "method": None, - "gpu_count": 0 - } - - await self.broadcast(json.dumps(data)) - - except Exception as e: - logger.error(f"AMD Monitor: Stream error: {e}") - - await asyncio.sleep(self.update_interval) + async def _handle_message(self, ws, raw: str): + try: + cmd = json.loads(raw) + cmd_type = cmd.get("type", "") - def start(self, loop=None): - """Start the monitoring loop safely""" + if cmd_type == "ping": + await ws.send_str(json.dumps({"type": "pong"})) + + elif cmd_type == "set_interval": + interval = float(cmd.get("interval", 5.0)) + self.update_interval = max(0.5, min(30.0, interval)) + await ws.send_str(json.dumps({ + "type": "config_updated", + "update_interval": self.update_interval + })) + + elif cmd_type == "get_history": + gpu_id = cmd.get("gpu_id", 0) + history = list(self.gpu_history.get(gpu_id, [])) + await ws.send_str(json.dumps({ + "type": "history", + "gpu_id": gpu_id, + "values": history + })) + + elif cmd_type == "request_stats": + monitor = self._get_monitor() + data = self._build_stats_data(monitor) + process = self._get_process_monitor() + if process: + data["process"] = self._build_process_data(process) + await ws.send_str(json.dumps(data)) + + else: + await ws.send_str(json.dumps({ + "type": "error", + "message": f"Unknown command: {cmd_type}" + })) + + except json.JSONDecodeError: + if raw == "ping": + await ws.send_str("pong") + + def _build_process_data(self, process): + """Build process monitoring payload""" + data = { + "is_generating": process.is_generating, + } + + if process.is_generating: + gen = process.get_current_generation() + data["generation"] = { + "duration": round(gen.duration, 1), + "vram_peak_mb": round(gen.vram_peak_mb, 0), + "ram_start_mb": round(gen.ram_start_mb, 0), + "ram_peak_mb": round(gen.ram_peak_mb, 0), + "cpu_peak": round(gen.cpu_peak, 1), + } + else: + last = process.get_last_generation() + if last: + data["last_generation"] = { + "duration": round(last.duration, 1), + "vram_peak_mb": round(last.vram_peak_mb, 0), + "vram_delta_mb": round(last.vram_delta_mb, 0), + "ram_start_mb": round(last.ram_start_mb, 0), + "ram_peak_mb": round(last.ram_peak_mb, 0), + "ram_end_mb": round(last.ram_end_mb, 0), + "cpu_peak": round(last.cpu_peak, 1), + } + + # Generation count + data["generation_count"] = len(process.history) if process.history else 0 + + return data + + def _build_stats_data(self, monitor, gpu_id: int = 0): + if not monitor.available: + return { + "type": "amd_stats", + "gpu_id": gpu_id, + "gpu_name": "", + "gpu_count": monitor.gpu_count, + "is_available": False, + "error": "AMD backend not available", + "method": monitor.method, + "history": [] + } + + stats = monitor.get_gpu_stats(gpu_id) + + # Update history + 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) + + data = { + "type": "amd_stats", + "gpu_id": stats.gpu_id, + "gpu_name": stats.gpu_name or f"AMD GPU {stats.gpu_id}", + "gpu_count": monitor.gpu_count, + "method": monitor.method or "unknown", + "is_available": stats.is_available, + "gpu_utilization": round(stats.utilization_gpu, 1), + "vram_usage_pct": round(stats.utilization_memory, 1), + "vram_used_mb": round(stats.memory_used / (1024 * 1024), 0), + "vram_total_mb": round(stats.memory_total / (1024 * 1024), 0), + "temperature": round(stats.temperature, 1), + "fan_speed": stats.fan_speed, + "core_clock_mhz": stats.core_clock, + "memory_clock_mhz": stats.memory_clock, + "power_draw_watts": round(stats.power_draw, 1), + "history": list(self.gpu_history.get(gpu_id, [])), + } + + if not stats.is_available and stats.error_message: + data["error"] = stats.error_message + + return data + + async def stream_data(self): + monitor = self._get_monitor() + process_mon = self._get_process_monitor() + logger.info(f"AMD Monitor: Stream data loop 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): + data = self._build_stats_data(monitor, gpu_id) + + # Attach process monitoring data + if process_mon: + data["process"] = self._build_process_data(process_mon) + + await self.broadcast(json.dumps(data)) + else: + data = { + "type": "amd_stats", + "gpu_id": 0, + "gpu_name": "", + "gpu_count": monitor.gpu_count, + "is_available": False, + "error": "AMD backend not available", + "method": monitor.method, + "history": [] + } + await self.broadcast(json.dumps(data)) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"AMD Monitor: Stream error: {e}") + + await asyncio.sleep(self.update_interval) + finally: + logger.info("AMD Monitor: Stream data loop ended") + + def start_streaming(self, loop=None): if self.running: - return - + return self._stream_task + self.running = True logger.info("AMD Monitor: Starting data stream") + loop = loop or asyncio.get_event_loop() + self._stream_task = asyncio.ensure_future(self.stream_data(), loop=loop) + 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.info("AMD Monitor: Stream stopped") + # Singleton instance _amd_server = None + def get_amd_server() -> AMDMonitorServer: global _amd_server if _amd_server is None: diff --git a/test_amd_monitor.py b/test_amd_monitor.py deleted file mode 100644 index fcaface..0000000 --- a/test_amd_monitor.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Quick test script for AMD Monitor backend -Run: python_embeded\python.exe test_amd_monitor.py -""" - -import sys -import os - -# Add project to path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -print("=== BANGTRIXTOOLKIT AMD Monitor Test ===\n") - -# Test 1: Import utils -print("1. Testing amd_utils import...") -try: - from utils.amd_utils import get_amd_monitor, AMDGPUStats - print(" โœ… amd_utils imported") -except Exception as e: - print(f" โŒ amd_utils import failed: {e}") - sys.exit(1) - -# Test 2: Initialize monitor -print("\n2. Initializing AMD monitor...") -monitor = get_amd_monitor() -print(f" Available: {monitor.available}") -print(f" Method: {monitor.method}") -print(f" GPU Count: {monitor.gpu_count}") - -# Test 3: Get stats -if monitor.available: - print("\n3. Fetching GPU stats...") - stats = monitor.get_gpu_stats(0) - print(f" GPU ID: {stats.gpu_id}") - print(f" Utilization: {stats.utilization_gpu}%") - print(f" VRAM: {stats.memory_used / 1024 / 1024:.0f} MB / {stats.memory_total / 1024 / 1024:.0f} MB") - print(f" Temperature: {stats.temperature}ยฐC") - print(f" Fan: {stats.fan_speed}%") - print(f" Status: {'โœ… OK' if stats.is_available else 'โŒ Error: ' + str(stats.error_message)}") -else: - print("\nโš ๏ธ AMD backend not available - check requirements & drivers") - -# Test 4: Check dependencies -print("\n4. Dependency check:") -deps = ['aiohttp', 'psutil', 'pyadl', 'pyrsmi'] -for dep in deps: - try: - __import__(dep) - ver = getattr(__import__(dep), '__version__', 'unknown') - print(f" โœ… {dep}: {ver}") - except ImportError: - print(f" โšช {dep}: not installed") - -print("\n=== Test Complete ===") \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ + diff --git a/utils/amd_adl.py b/utils/amd_adl.py new file mode 100644 index 0000000..9d8da85 --- /dev/null +++ b/utils/amd_adl.py @@ -0,0 +1,348 @@ +""" +AMD ADL (Display Library) Direct Binding +========================================= +Low-level ctypes binding to atiadlxx.dll for GPU monitoring on Windows. +No dependencies required (uses ctypes which is built-in Python). + +Provides: GPU Utilization, Temperature, Fan Speed, GPU Name, Core/Memory Clocks +""" + +import ctypes +import ctypes.util +import logging +import platform +from ctypes import byref, c_int, c_void_p, c_char_p, c_char, POINTER, Structure + +logger = logging.getLogger(__name__) + +# ============================================= +# DLL LOADING +# ============================================= + +_adl_dll = None +_adl_initialized = False + + +def _load_adl(): + global _adl_dll + if _adl_dll is not None: + return True + + system = platform.system() + if system != "Windows": + return False + + try: + _adl_dll = ctypes.windll.LoadLibrary("atiadlxx.dll") + logger.debug("ADL: loaded atiadlxx.dll from System32") + return True + except: + try: + _adl_dll = ctypes.windll.LoadLibrary("atiadlxy.dll") + logger.debug("ADL: loaded atiadlxy.dll (fallback)") + return True + except: + logger.debug("ADL: cannot load atiadlxx.dll / atiadlxy.dll") + return False + + +# ============================================= +# ADL TYPES +# ============================================= + +class ADLAdapterInfo(Structure): + _fields_ = [ + ("strAdapterName", c_char * 256), + ("iAdapterIndex", c_int), + ("iBusNumber", c_int), + ("iDeviceNumber", c_int), + ("iFunctionNumber", c_int), + ("iVendorID", c_int), + ("strAdapterName_256", c_char * 256), + ("isPresent", c_int), + ("iDisplayType", c_int * 4), + ("iFormFactor", c_int), + ("iClusterInfo", c_int), + ("strBusString", c_char * 64), + ("strBusNumber", c_char * 64), + ("iFunction", c_int), + ("iRevisionID", c_int), + ("iExtendedRevisionID", c_int), + ("iMemorySizeMB", c_int), + ] + + +class ADLTemperature(Structure): + _fields_ = [ + ("iSize", c_int), + ("iTemperature", c_int), + ] + + +class ADLFanSpeedValue(Structure): + _fields_ = [ + ("iSize", c_int), + ("iSpeedType", c_int), + ("iFanSpeed", c_int), + ("iFlags", c_int), + ] + + +class ADLPMActivity(Structure): + _fields_ = [ + ("iSize", c_int), + ("iEngineClock", c_int), + ("iMemoryClock", c_int), + ("iVddc", c_int), + ("iActivityPercent", c_int), + ("iCurrentPerformanceLevel", c_int), + ("iCurrentBusSpeed", c_int), + ("iCurrentBusLanes", c_int), + ("iUnknown", c_int * 4), + ] + + +class ADLODParameters(Structure): + _fields_ = [ + ("iSize", c_int), + ("iEngineClock", c_int), + ("iMemoryClock", c_int), + ("iVddc", c_int), + ("iActivityPercent", c_int), + ("iCurrentPerformanceLevel", c_int), + ("iCurrentMinEngineClock", c_int), + ("iCurrentMinMemoryClock", c_int), + ("iCurrentMaxEngineClock", c_int), + ("iCurrentMaxMemoryClock", c_int), + ] + + +# ============================================= +# ADL CONTEXT +# ============================================= + +class ADLContext: + """Manages ADL lifecycle and GPU adapter info""" + + def __init__(self): + self.available = False + self.num_adapters = 0 + self.adapters = [] + self.adapter_names = [] + self._context = None + + def initialize(self) -> bool: + if not _load_adl(): + return False + + try: + # ADL2_Main_Control_Create(callback, enumConnectedAdapters) + ADL2_Main_Control_Create = _adl_dll.ADL2_Main_Control_Create + ADL2_Main_Control_Create.argtypes = [c_void_p, c_int] + ADL2_Main_Control_Create.restype = c_int + + context = c_int(0) + result = ADL2_Main_Control_Create(None, byref(context)) + if result != 0: + logger.warning(f"ADL: Main_Control_Create failed: {result}") + return False + + self._context = context + + # Get adapter count + ADL2_Adapter_NumberOfAdapters_Get = _adl_dll.ADL2_Adapter_NumberOfAdapters_Get + ADL2_Adapter_NumberOfAdapters_Get.argtypes = [c_int, POINTER(c_int)] + ADL2_Adapter_NumberOfAdapters_Get.restype = c_int + + num = c_int(0) + result = ADL2_Adapter_NumberOfAdapters_Get(context.value, byref(num)) + if result != 0: + logger.warning(f"ADL: NumberOfAdapters_Get failed: {result}") + self._destroy() + return False + + self.num_adapters = num.value + + # Query adapter info for all adapters + if self.num_adapters > 0: + self._query_adapters() + + self.available = True + logger.info(f"ADL: Initialized โ€” {len(self.adapters)} adapter(s)") + return True + + except Exception as e: + logger.warning(f"ADL: Init error: {e}") + return False + + def _query_adapters(self): + """Get adapter info for all adapters""" + try: + ADL2_Adapter_AdapterInfo_Get = _adl_dll.ADL2_Adapter_AdapterInfo_Get + ADL2_Adapter_AdapterInfo_Get.argtypes = [c_int, c_void_p, POINTER(c_int)] + ADL2_Adapter_AdapterInfo_Get.restype = c_int + + # Allocate memory via ADL + ADL2_Main_Memory_Alloc = _adl_dll.ADL2_Main_Memory_Alloc + ADL2_Main_Memory_Alloc.argtypes = [c_int, c_int, c_void_p] + ADL2_Main_Memory_Alloc.restype = c_int + + buf_size = c_int(ctypes.sizeof(ADLAdapterInfo) * self.num_adapters) + buffer = ctypes.create_string_buffer(buf_size.value) + + # Use our own buffer, ADL will read present adapters + result = ADL2_Adapter_AdapterInfo_Get( + self._context.value, + buffer, + byref(buf_size) + ) + + if result == 0: + info_array = (ADLAdapterInfo * self.num_adapters).from_buffer(buffer) + total = min(self.num_adapters, buf_size.value // ctypes.sizeof(ADLAdapterInfo)) + for i in range(total): + info = info_array[i] + if info.isPresent or info.iAdapterIndex >= 0: + name = info.strAdapterName.decode('utf-8', errors='replace').strip() + if not name: + name = info.strAdapterName_256.decode('utf-8', errors='replace').strip() + self.adapters.append({ + 'index': info.iAdapterIndex, + 'name': name, + }) + self.adapter_names.append(name) + + # Fallback: if no adapters found, try ADL2_Adapter_AdapterInfo_Get_All + if len(self.adapters) == 0: + self._query_adapters_fallback() + + except Exception as e: + logger.warning(f"ADL: AdapterInfo error: {e}") + self.adapters = [{'index': 0, 'name': 'AMD GPU 0'}] + self.adapter_names = ['AMD GPU 0'] + + def _query_adapters_fallback(self): + """Fallback: just create virtual adapters based on count""" + for i in range(self.num_adapters): + self.adapters.append({'index': i, 'name': f'AMD GPU {i}'}) + self.adapter_names.append(f'AMD GPU {i}') + + def get_temperature(self, adapter_index: int) -> float: + """Get GPU temperature in Celsius""" + try: + ADL2_Overdrive6_Temperature_Get = _adl_dll.ADL2_Overdrive6_Temperature_Get + ADL2_Overdrive6_Temperature_Get.argtypes = [c_int, c_int, POINTER(ADLTemperature)] + ADL2_Overdrive6_Temperature_Get.restype = c_int + + temp = ADLTemperature() + temp.iSize = ctypes.sizeof(ADLTemperature) + result = ADL2_Overdrive6_Temperature_Get(self._context.value, adapter_index, byref(temp)) + if result == 0: + return temp.iTemperature / 1000.0 + except: + pass + return 0.0 + + def get_fan_speed(self, adapter_index: int) -> int: + """Get GPU fan speed in percent""" + try: + ADL2_Overdrive6_FanSpeed_Get = _adl_dll.ADL2_Overdrive6_FanSpeed_Get + ADL2_Overdrive6_FanSpeed_Get.argtypes = [c_int, c_int, POINTER(ADLFanSpeedValue)] + ADL2_Overdrive6_FanSpeed_Get.restype = c_int + + fan = ADLFanSpeedValue() + fan.iSize = ctypes.sizeof(ADLFanSpeedValue) + fan.iSpeedType = 0 # ADL_DL_FANCTRL_SPEED_TYPE_PERCENT + result = ADL2_Overdrive6_FanSpeed_Get(self._context.value, adapter_index, byref(fan)) + if result == 0: + return fan.iFanSpeed + except: + pass + return 0 + + def get_activity(self, adapter_index: int): + """Get GPU activity: utilization, engine clock, memory clock""" + try: + ADL2_Overdrive6_CurrentActivity_Get = _adl_dll.ADL2_Overdrive6_CurrentActivity_Get + ADL2_Overdrive6_CurrentActivity_Get.argtypes = [c_int, c_int, POINTER(ADLPMActivity)] + ADL2_Overdrive6_CurrentActivity_Get.restype = c_int + + activity = ADLPMActivity() + activity.iSize = ctypes.sizeof(ADLPMActivity) + result = ADL2_Overdrive6_CurrentActivity_Get(self._context.value, adapter_index, byref(activity)) + if result == 0: + return { + 'utilization': activity.iActivityPercent, + 'engine_clock': activity.iEngineClock, + 'memory_clock': activity.iMemoryClock, + } + except: + pass + return {'utilization': 0, 'engine_clock': 0, 'memory_clock': 0} + + def get_memory_info(self, adapter_index: int): + """Get VRAM size in MB""" + # Memory info from adapter info + for adapter in self.adapters: + if adapter['index'] == adapter_index: + # We stored the adapter index, but not memory size directly + pass + return 0 + + def _destroy(self): + """Clean up ADL context""" + try: + if self._context is not None: + ADL2_Main_Control_Destroy = _adl_dll.ADL2_Main_Control_Destroy + ADL2_Main_Control_Destroy.argtypes = [c_int] + ADL2_Main_Control_Destroy.restype = c_int + ADL2_Main_Control_Destroy(self._context.value) + self._context = None + except: + pass + + def close(self): + self._destroy() + + +# ============================================= +# GLOBAL ADL INSTANCE +# ============================================= + +_adl_context = None + + +def get_adl_context() -> ADLContext: + global _adl_context + if _adl_context is None: + ctx = ADLContext() + if ctx.initialize(): + _adl_context = ctx + else: + _adl_context = ctx # Return anyway, .available will be False + return _adl_context + + +# ============================================= +# TEST +# ============================================= + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + ctx = get_adl_context() + if ctx.available: + print(f"ADL: {len(ctx.adapters)} adapter(s)") + for i, adapter in enumerate(ctx.adapters): + print(f" GPU {i}: {adapter['name']} (index={adapter['index']})") + + temp = ctx.get_temperature(adapter['index']) + fan = ctx.get_fan_speed(adapter['index']) + activity = ctx.get_activity(adapter['index']) + + print(f" Temp: {temp:.1f}ยฐC") + print(f" Fan: {fan}%") + print(f" Utilization: {activity['utilization']}%") + print(f" Engine Clock: {activity['engine_clock']} MHz") + print(f" Memory Clock: {activity['memory_clock']} MHz") + else: + print("ADL: Not available") \ No newline at end of file diff --git a/utils/amd_gpu_counter.py b/utils/amd_gpu_counter.py new file mode 100644 index 0000000..98339ef --- /dev/null +++ b/utils/amd_gpu_counter.py @@ -0,0 +1,255 @@ +""" +GPU Performance Counters via PowerShell +======================================== +Windows-only: reads GPU utilization, VRAM usage, and temperature +via built-in performance counters and WMI. +No dependencies required beyond built-in Python + PowerShell. +""" + +import subprocess +import logging +import platform +import json +import re + +logger = logging.getLogger(__name__) + + +class GPUCounterMonitor: + """Monitor GPU metrics via PowerShell performance counters and WMI""" + + def __init__(self): + self.available = False + self.gpu_count = 0 + self.gpu_names = [] + self.method = "powershell-counters" + self._detect() + + def _detect(self): + """Detect GPU availability via WMI""" + if platform.system() != "Windows": + return + + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + "Get-WmiObject Win32_VideoController | " + "Where-Object { $_.AdapterRAM -gt 0 } | " + "Select-Object Name, AdapterRAM | ConvertTo-Json" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=5) + if result.returncode == 0 and result.stdout.strip(): + data = json.loads(result.stdout.strip()) + if isinstance(data, dict): + data = [data] + for gpu in data: + name = gpu.get('Name', 'Unknown GPU').strip() + self.gpu_names.append(name) + self.gpu_count = len(self.gpu_names) + if self.gpu_count > 0: + self.available = True + logger.info(f"GPU Counters: {self.gpu_count} GPU(s): {self.gpu_names}") + return + except Exception as e: + logger.debug(f"GPU detection error: {e}") + + def get_gpu_utilization(self, gpu_id: int = 0) -> float: + """Get GPU utilization % via performance counters""" + try: + # Get average 3D engine usage + ps_cmd = ( + "Get-Counter '\\GPU Engine(*engtype_3D)\\*' -SampleInterval 0 -MaxSamples 1 " + "2>$null | Select-Object -ExpandProperty CounterSamples | " + "Measure-Object -Property CookedValue -Average | " + "Select-Object -ExpandProperty Average" + ) + result = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, text=True, timeout=3 + ) + if result.returncode == 0 and result.stdout.strip(): + val = float(result.stdout.strip()) + if val > 0: + return val + except Exception as e: + logger.debug(f"GPU utilization error: {e}") + return 0.0 + + def _detect_hardware_monitors(self): + """Detect which hardware monitoring tools are installed""" + monitors = [] + + # LibreHardwareMonitor / OpenHardwareMonitor WMI namespace + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + "Get-WmiObject -Namespace 'root/LibreHardwareMonitor' -Class Sensor 2>$null | " + "Select-Object -First 1 | ConvertTo-Json" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3) + if result.returncode == 0 and result.stdout.strip() and result.stdout.strip() != 'null': + monitors.append("LibreHardwareMonitor") + except: + pass + + if 'LibreHardwareMonitor' not in monitors: + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + "Get-WmiObject -Namespace 'root/OpenHardwareMonitor' -Class Sensor 2>$null | " + "Select-Object -First 1 | ConvertTo-Json" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3) + if result.returncode == 0 and result.stdout.strip() and result.stdout.strip() != 'null': + monitors.append("OpenHardwareMonitor") + except: + pass + + # MSI Afterburner shared memory + try: + import ctypes + try: + # Try to open the shared memory file + import ctypes.wintypes + handle = ctypes.windll.kernel32.OpenFileMappingW( + 0x0001, False, "MSE_Afterburner_SharedMemory" + ) + if handle: + monitors.append("MSIAfterburner") + ctypes.windll.kernel32.CloseHandle(handle) + except: + pass + except: + pass + + self._hw_monitors = monitors + if monitors: + logger.info(f"GPU Counters: Detected HW monitors: {monitors}") + return monitors + + def get_gpu_temperature(self, gpu_id: int = 0) -> float: + """Get GPU temperature via LibreHardwareMonitor, OpenHardwareMonitor, or other tools""" + # Try LibreHardwareMonitor first (if installed) + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + "Get-WmiObject -Namespace 'root/LibreHardwareMonitor' -Class Sensor " + "2>$null | Where-Object { $_.SensorType -eq 'Temperature' -and " + "$_.Name -match 'GPU' } | Select-Object -First 1 -ExpandProperty Value" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3, creationflags=subprocess.CREATE_NO_WINDOW) + if result.returncode == 0 and result.stdout.strip(): + val = float(result.stdout.strip()) + if val > 0: + return val + except: + pass + + # Try OpenHardwareMonitor + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + "Get-WmiObject -Namespace 'root/OpenHardwareMonitor' -Class Sensor " + "2>$null | Where-Object { $_.SensorType -eq 'Temperature' -and " + "$_.Name -match 'GPU' } | Select-Object -First 1 -ExpandProperty Value" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3, creationflags=subprocess.CREATE_NO_WINDOW) + if result.returncode == 0 and result.stdout.strip(): + val = float(result.stdout.strip()) + if val > 0: + return val + except: + pass + + return 0.0 + + def get_fan_speed(self, gpu_id: int = 0) -> int: + """Get fan speed from hardware monitors""" + # LibreHardwareMonitor + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + "Get-WmiObject -Namespace 'root/LibreHardwareMonitor' -Class Sensor " + "2>$null | Where-Object { $_.SensorType -eq 'Fan' -and " + "$_.Name -match 'GPU' } | Select-Object -First 1 -ExpandProperty Value" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3, creationflags=subprocess.CREATE_NO_WINDOW) + if result.returncode == 0 and result.stdout.strip(): + val = float(result.stdout.strip()) + if val > 0: + return int(val) + except: + pass + + # OpenHardwareMonitor + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + "Get-WmiObject -Namespace 'root/OpenHardwareMonitor' -Class Sensor " + "2>$null | Where-Object { $_.SensorType -eq 'Fan' -and " + "$_.Name -match 'GPU' } | Select-Object -First 1 -ExpandProperty Value" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3, creationflags=subprocess.CREATE_NO_WINDOW) + if result.returncode == 0 and result.stdout.strip(): + val = float(result.stdout.strip()) + if val > 0: + return int(val) + except: + pass + + return 0 + + def get_gpu_vram_mb(self, gpu_id: int = 0) -> dict: + """Get VRAM info from WMI""" + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + "(Get-WmiObject Win32_VideoController | Select-Object -First 1).AdapterRAM" + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3) + if result.returncode == 0 and result.stdout.strip(): + total_bytes = int(result.stdout.strip()) + total_mb = total_bytes / (1024 * 1024) + # We can't get actual VRAM usage without ADLX/ADL + # Estimate via process memory if needed + return {'total_mb': total_mb, 'used_mb': 0, 'free_mb': total_mb} + except: + pass + return {'total_mb': 0, 'used_mb': 0, 'free_mb': 0} + + def get_all_metrics(self, gpu_id: int = 0) -> dict: + """Get all metrics for a GPU in one call""" + return { + 'utilization': self.get_gpu_utilization(gpu_id), + 'temperature': self.get_gpu_temperature(gpu_id), + 'vram': self.get_gpu_vram_mb(gpu_id), + 'fan_speed': self.get_fan_speed(gpu_id), + 'gpu_name': self.gpu_names[gpu_id] if gpu_id < len(self.gpu_names) else f"GPU {gpu_id}", + } + + +# Singleton +_gpu_counter = None + + +def get_gpu_counter() -> GPUCounterMonitor: + global _gpu_counter + if _gpu_counter is None: + _gpu_counter = GPUCounterMonitor() + return _gpu_counter + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + mon = get_gpu_counter() + if mon.available: + print(f"GPU Counters: {mon.gpu_count} GPU(s)") + for i, name in enumerate(mon.gpu_names): + print(f"\n GPU {i}: {name}") + metrics = mon.get_all_metrics(i) + print(f" Utilization: {metrics['utilization']:.1f}%") + print(f" Temperature: {metrics['temperature']:.1f}ยฐC") + print(f" VRAM: {metrics['vram']['total_mb']:.0f} MB total") + print(f" Fan: {metrics['fan_speed']}%") + else: + print("GPU Counters: Not available") \ No newline at end of file diff --git a/utils/amd_process_monitor.py b/utils/amd_process_monitor.py new file mode 100644 index 0000000..cdea3b1 --- /dev/null +++ b/utils/amd_process_monitor.py @@ -0,0 +1,337 @@ +""" +AMD Process Monitor +=================== +Monitors ComfyUI Python process memory/GPU usage during generations. +Auto-detects when generation starts/ends and records peak metrics. +""" + +import logging +import time +import threading +from dataclasses import dataclass, field +from typing import Optional, List + +logger = logging.getLogger(__name__) + + +@dataclass +class GenerationRecord: + """Stats for a single generation run""" + timestamp: float + duration: float # seconds + vram_peak_mb: float = 0 + vram_start_mb: float = 0 + vram_end_mb: float = 0 + vram_delta_mb: float = 0 # peak - start + ram_peak_mb: float = 0 + ram_start_mb: float = 0 + ram_end_mb: float = 0 + cpu_peak: float = 0 # CPU utilization peak during generation + error: Optional[str] = None + + +@dataclass +class ProcessSnapshot: + """Snapshot of a process at a point in time""" + timestamp: float + memory_rss_mb: float + memory_vms_mb: float + cpu_percent: float + num_threads: int + + +class ComfyProcessMonitor: + """Monitors the ComfyUI main process for generation metrics""" + + def __init__(self, poll_interval: float = 0.5): + self.poll_interval = poll_interval + self._process = None + self._process_pid = None + self._running = False + self._thread = None + + # Generation detection + self._is_generating = False + self._gen_start_time = 0 + self._current_record = None + self._prev_memory_mb = 0 + + # History + self.history: List[GenerationRecord] = [] + self.current: Optional[GenerationRecord] = None + + # Snapshots during current generation (for graph) + self._snapshots: List[ProcessSnapshot] = [] + + # Mutex for thread safety + self._lock = threading.Lock() + + # Threshold tuning โ€” lebih sensitif + self._MEMORY_START_THRESHOLD_MB = 50 # sebelumnya 100 + self._CPU_START_THRESHOLD = 15 # sebelumnya 20 + self._MEMORY_END_THRESHOLD_MB = -30 # sebelumnya -50 + self._CPU_END_THRESHOLD = 8 # sebelumnya 10 + + def find_comfy_process(self) -> bool: + """Find the ComfyUI main process""" + try: + import psutil + current_pid = None + + # Method 1: Current process could be the ComfyUI worker + proc = psutil.Process() + proc_name = proc.name().lower() + + # Check if we're running inside ComfyUI + cmdline = proc.cmdline() + cmdline_str = ' '.join(cmdline).lower() + if 'comfyui' in cmdline_str or 'main.py' in cmdline_str or 'comfy' in cmdline_str: + current_pid = proc.pid + + # Method 2: Find by name + if not current_pid: + for p in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + name = (p.info['name'] or '').lower() + cl = ' '.join(p.info['cmdline'] or []).lower() + if ('python' in name or 'python' in cl) and ('comfy' in cl or 'main.py' in cl): + current_pid = p.info['pid'] + break + # Also catch 'nodes' or 'comfy' in path + if ('python' in name) and ('comfy' in cl): + current_pid = p.info['pid'] + break + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + if current_pid: + self._process_pid = current_pid + self._process = psutil.Process(current_pid) + logger.info(f"Process Monitor: Found ComfyUI PID={current_pid}") + return True + + logger.warning("Process Monitor: Could not find ComfyUI process") + return False + + except ImportError: + logger.warning("Process Monitor: psutil not installed") + return False + except Exception as e: + logger.warning(f"Process Monitor: Find error: {e}") + return False + + def start_monitoring(self): + """Start background polling thread""" + if self._running: + return + if not self.find_comfy_process(): + return + + self._running = True + self._thread = threading.Thread(target=self._poll_loop, daemon=True) + self._thread.start() + logger.info("Process Monitor: Started monitoring") + + def stop_monitoring(self): + """Stop background polling""" + self._running = False + if self._thread: + self._thread.join(timeout=2) + self._thread = None + + def _poll_loop(self): + """Main polling loop""" + try: + import psutil + except ImportError: + self._running = False + return + + while self._running: + try: + if not self._process or not self._process.is_running(): + logger.warning("Process Monitor: Process died, restarting...") + if not self.find_comfy_process(): + time.sleep(self.poll_interval * 10) + continue + + # Get memory info + mem_info = self._process.memory_info() + cpu_percent = self._process.cpu_percent(interval=0) + rss_mb = mem_info.rss / (1024 * 1024) + vms_mb = mem_info.vms / (1024 * 1024) + num_threads = self._process.num_threads() + + snapshot = ProcessSnapshot( + timestamp=time.time(), + memory_rss_mb=rss_mb, + memory_vms_mb=vms_mb, + cpu_percent=cpu_percent, + num_threads=num_threads, + ) + + # Detect generation: significant & sustained memory increase + mem_delta = rss_mb - self._prev_memory_mb + + with self._lock: + if not self._is_generating: + # Start condition: tuned thresholds + if mem_delta > self._MEMORY_START_THRESHOLD_MB or cpu_percent > self._CPU_START_THRESHOLD: + self._is_generating = True + self._gen_start_time = time.time() + self._current_record = GenerationRecord( + timestamp=self._gen_start_time, + duration=0, + vram_start_mb=rss_mb, + vram_peak_mb=rss_mb, + ram_start_mb=rss_mb, + ram_peak_mb=rss_mb, + ) + self._snapshots = [snapshot] + logger.info( + f"Process Monitor: Generation START " + f"(RAM: {rss_mb:.0f}MB, CPU: {cpu_percent:.1f}%)" + ) + else: + # In middle of generation โ€” track peak + if self._current_record: + self._current_record.vram_peak_mb = max( + self._current_record.vram_peak_mb, rss_mb + ) + self._current_record.ram_peak_mb = max( + self._current_record.ram_peak_mb, rss_mb + ) + self._current_record.cpu_peak = max( + self._current_record.cpu_peak, cpu_percent + ) + self._snapshots.append(snapshot) + + # End condition: tuned thresholds + if mem_delta < self._MEMORY_END_THRESHOLD_MB and cpu_percent < self._CPU_END_THRESHOLD: + self._finalize_generation(rss_mb) + logger.info( + f"Process Monitor: Generation END " + f"(duration={self.current.duration:.1f}s, " + f"peak RAM={self.current.ram_peak_mb:.0f}MB)" + ) + + self._prev_memory_mb = rss_mb + + except (psutil.NoSuchProcess, psutil.AccessDenied): + logger.warning("Process Monitor: Process access error") + time.sleep(self.poll_interval * 5) + except Exception as e: + logger.debug(f"Process Monitor: Poll error: {e}") + + time.sleep(self.poll_interval) + + def _finalize_generation(self, end_memory_mb: float): + """Complete a generation record""" + if not self._current_record: + return + + self._current_record.duration = time.time() - self._gen_start_time + self._current_record.vram_end_mb = end_memory_mb + self._current_record.ram_end_mb = end_memory_mb + self._current_record.vram_delta_mb = ( + self._current_record.vram_peak_mb - self._current_record.vram_start_mb + ) + + self.current = self._current_record + self.history.append(self._current_record) + + # Keep last 50 records + if len(self.history) > 50: + self.history = self.history[-50:] + + # Reset + self._is_generating = False + self._current_record = None + self._snapshots = [] + + def get_current_generation(self) -> GenerationRecord: + """Get current in-progress generation record""" + with self._lock: + if self._is_generating and self._current_record: + return self._current_record + return GenerationRecord(timestamp=0, duration=0) + + def get_last_generation(self) -> Optional[GenerationRecord]: + """Get the most recent finished generation""" + if self.history: + return self.history[-1] + return None + + def get_summary(self) -> str: + """Get a text summary of all generations""" + if not self.history: + return "No generations recorded yet." + + lines = [f"=== ComfyUI Process Monitor ==="] + total_time = 0 + peak_vram = 0 + peak_ram = 0 + + for i, gen in enumerate(self.history[-5:]): # Last 5 + total_time += gen.duration + peak_vram = max(peak_vram, gen.vram_peak_mb) + peak_ram = max(peak_ram, gen.ram_peak_mb) + + lines.append( + f" #{i + 1}: {gen.duration:.1f}s " + f"| RAM: {gen.ram_start_mb:.0f}โ†’{gen.ram_peak_mb:.0f}โ†’{gen.ram_end_mb:.0f}MB" + ) + if gen.vram_delta_mb > 0: + lines[-1] += f" (+{gen.vram_delta_mb:.0f}MB peak)" + + lines.append( + f" --- Total: {total_time:.1f}s | " + f"Peak RAM: {peak_ram:.0f}MB | " + f"Peak VRAM: {peak_vram:.0f}MB" + ) + + return "\n".join(lines) + + @property + def is_generating(self) -> bool: + return self._is_generating + + +# Singleton +_process_monitor = None + + +def get_process_monitor() -> ComfyProcessMonitor: + global _process_monitor + if _process_monitor is None: + _process_monitor = ComfyProcessMonitor() + return _process_monitor + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + mon = get_process_monitor() + mon.start_monitoring() + + print("Monitoring ComfyUI process...") + print("Run a generation in ComfyUI to see stats.") + print("Press Ctrl+C to stop and see summary.\n") + + try: + while True: + time.sleep(5) + if mon.is_generating: + gen = mon.get_current_generation() + print(f" โšก Generating... {gen.duration:.1f}s | " + f"Peak: {gen.vram_peak_mb:.0f}MB") + else: + last = mon.get_last_generation() + if last: + print(f" Last gen: {last.duration:.1f}s | " + f"Peak: {last.vram_peak_mb:.0f}MB | " + f"Delta: {last.vram_delta_mb:.0f}MB") + except KeyboardInterrupt: + pass + finally: + mon.stop_monitoring() + print("\n" + mon.get_summary()) \ No newline at end of file diff --git a/utils/amd_utils.py b/utils/amd_utils.py index e61f5f8..beb349c 100644 --- a/utils/amd_utils.py +++ b/utils/amd_utils.py @@ -1,7 +1,16 @@ +""" +AMD Utils - GPU Monitoring Backend +=================================== +Multi-platform AMD GPU monitoring. +Supports: Linux (ROCm/pyrsmi), Windows (ADL/ADLX, WMI), Fallback (psutil) +""" + import platform import logging +import subprocess +import re from dataclasses import dataclass -from typing import Optional +from typing import Optional, List logger = logging.getLogger(__name__) @@ -9,14 +18,18 @@ logger = logging.getLogger(__name__) @dataclass class AMDGPUStats: gpu_id: int - utilization_gpu: float - utilization_memory: float - memory_total: int # Bytes - memory_used: int # Bytes - memory_free: int # Bytes - temperature: float - fan_speed: int - is_available: bool + gpu_name: str = "" + utilization_gpu: float = 0.0 + utilization_memory: float = 0.0 + memory_total: int = 0 # Bytes + memory_used: int = 0 # Bytes + memory_free: int = 0 # Bytes + temperature: float = 0.0 # Celsius + fan_speed: int = 0 # Percent + core_clock: int = 0 # MHz + memory_clock: int = 0 # MHz + power_draw: float = 0.0 # Watts + is_available: bool = True error_message: Optional[str] = None @@ -25,13 +38,14 @@ class AMDMonitor: self.available = False self.method = None self.gpu_count = 0 + self.gpu_names: List[str] = [] self._backend = None self._initialize() def _initialize(self): system = platform.system() - - # --- LINUX IMPLEMENTATION (ROCm) --- + + # --- LINUX: ROCm / pyrsmi --- if system == "Linux": try: import pyrsmi @@ -40,86 +54,240 @@ class AMDMonitor: self.method = "pyrsmi" self.available = True self.gpu_count = pyrsmi.rocm_smi_get_num_devices() - logger.info(f"AMD Monitor: Linux detected, using pyrsmi. Found {self.gpu_count} GPUs.") + self.gpu_names = [f"AMD GPU {i}" for i in range(self.gpu_count)] + logger.info(f"AMD Monitor: ROCm/pyrsmi โ€” {self.gpu_count} GPU(s)") return except ImportError: - logger.warning("Linux detected but 'pyrsmi' not installed.") + logger.debug("Linux: pyrsmi not installed") except Exception as e: - logger.warning(f"Linux pyrsmi init failed: {e}") + logger.warning(f"Linux pyrsmi: {e}") - # --- WINDOWS IMPLEMENTATION (ADL) --- + # --- WINDOWS --- elif system == "Windows": - try: - import pyadl - self._backend = pyadl - - # Try init without custom allocator (safer) - try: - res = pyadl.ADL2_Main_Control_Create(0) # Use default allocator - except: - res = 0 # Assume success if function doesn't exist - - if res == 0: - adapters = pyadl.ADL2_Adapter_AdapterInfo_Get_All() - self.gpu_count = len([a for a in adapters if getattr(a, 'is_present', True)]) - self.method = "pyadl" - self.available = True - logger.info(f"AMD Monitor: Windows + pyadl. Found {self.gpu_count} GPUs.") - return - except ImportError: - logger.warning("Windows: pyadl not installed") - except Exception as e: - logger.warning(f"Windows pyadl error: {e}") - - # Fallback: Try psutil for basic system info - try: - import psutil - self.method = "psutil-fallback" - self.available = True # Partial availability - self.gpu_count = 1 # Assume 1 GPU for fallback - logger.info("AMD Monitor: Using psutil fallback (limited AMD info)") + # Try ADLX + if self._try_adlx(): return - except ImportError: - pass + # Try pyadl + if self._try_pyadl(): + return + # WMI-based detection + monitoring (best fallback on Windows) + if self._try_wmi(): + return + # Last resort: psutil + self._try_psutil() - logger.error("No AMD monitoring backend available. Check requirements.") + if self.available: + return + + if not self.available: + logger.error(f"AMD Monitor: No backend available on {system}") + + # ============================================== + # BACKEND DETECTION + # ============================================== + + def _try_adlx(self): + try: + import ctypes + paths = [ + "C:/Program Files/AMD/ADLX/lib/ADLX.dll", + "C:/Program Files/AMD/ADLX/bin/ADLX.dll", + ] + for path in paths: + try: + self._adlx_dll = ctypes.CDLL(path) + self.method = "adlx" + self.available = True + self._detect_gpu_names_wmi() or self.gpu_names.__setitem__(slice(None), ["AMD GPU 0"]) + self.gpu_count = max(len(self.gpu_names), 1) + logger.info(f"AMD Monitor: ADLX at {path}") + return True + except: + continue + except: + pass + return False + + def _try_pyadl(self): + try: + import pyadl + self._backend = pyadl + try: + pyadl.ADL2_Main_Control_Create(0) + except: + pass + adapters = pyadl.ADL2_Adapter_AdapterInfo_Get_All() + present = [a for a in adapters if getattr(a, 'is_present', True)] + self.gpu_count = len(present) + self.gpu_names = [getattr(a, 'strAdapterName', f'AMD GPU {i}') for i, a in enumerate(present)] + self.method = "pyadl" + self.available = True + logger.info(f"AMD Monitor: pyadl โ€” {self.gpu_count} GPU(s): {self.gpu_names}") + return True + except ImportError: + pass + except AttributeError as e: + logger.debug(f"pyadl API mismatch: {e}") + except Exception as e: + logger.warning(f"pyadl error: {e}") + return False + + def _try_wmi(self): + """WMI-based detection + monitoring on Windows""" + # Import the GPU counter monitor module + success = self._detect_gpu_names_wmi() + if not success or self.gpu_count == 0: + return False + + # Try to use the PowerShell counter backend for real utilization data + try: + from .amd_gpu_counter import get_gpu_counter + counter_mon = get_gpu_counter() + if counter_mon.available: + self._counter_mon = counter_mon + self.method = "powershell-counters" + self.available = True + # Use names from counter monitor (more accurate) + if counter_mon.gpu_names: + self.gpu_names = counter_mon.gpu_names + self.gpu_count = len(self.gpu_names) + logger.info(f"AMD Monitor: PowerShell counters โ€” {self.gpu_count} GPU(s): {self.gpu_names}") + return True + except Exception as e: + logger.debug(f"GPU counter import failed: {e}") + + # Fallback: basic WMI with no real-time metrics + self.method = "wmi-basic" + self.available = True + logger.info(f"AMD Monitor: WMI basic โ€” {self.gpu_count} GPU(s): {self.gpu_names}") + return True + + def _try_psutil(self): + try: + import psutil + self.method = "psutil-fallback" + self.available = True + self.gpu_count = 1 + self.gpu_names = ["System RAM (no GPU backend)"] + logger.info("AMD Monitor: psutil-fallback") + except ImportError: + pass + + def _detect_gpu_names_wmi(self) -> bool: + """Use PowerShell to get AMD GPU names via WMI""" + try: + cmd = [ + "powershell", "-NoProfile", "-Command", + "Get-WmiObject Win32_VideoController | " + "Where-Object { $_.Name -match 'AMD|Radeon|RDNA' } | " + "Select-Object -ExpandProperty Name" + ] + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0 and result.stdout.strip(): + names = [n.strip() for n in result.stdout.strip().split('\n') if n.strip()] + if names: + self.gpu_names = names + self.gpu_count = len(names) + return True + + # Fallback: get ALL video controllers + cmd2 = [ + "powershell", "-NoProfile", "-Command", + "Get-WmiObject Win32_VideoController | Select-Object -ExpandProperty Name" + ] + result2 = subprocess.run( + cmd2, capture_output=True, text=True, timeout=5 + ) + if result2.returncode == 0 and result2.stdout.strip(): + names = [n.strip() for n in result2.stdout.strip().split('\n') if n.strip()] + if names: + self.gpu_names = names + self.gpu_count = len(names) + return True + except Exception as e: + logger.debug(f"WMI detection error: {e}") + return False + + # ============================================== + # GET STATS + # ============================================== def get_gpu_stats(self, gpu_id: int = 0) -> AMDGPUStats: if not self.available: - return AMDGPUStats(gpu_id, 0, 0, 0, 0, 0, 0, 0, False, "Backend not available") + return AMDGPUStats( + gpu_id=gpu_id, + gpu_name="", + is_available=False, + error_message="Backend not available" + ) try: if self.method == "pyrsmi": return self._get_stats_linux(gpu_id) + elif self.method == "adlx": + return self._get_stats_adlx(gpu_id) elif self.method == "pyadl": - return self._get_stats_windows(gpu_id) + return self._get_stats_pyadl(gpu_id) + elif self.method in ("wmi", "wmi-basic"): + return self._get_stats_wmi(gpu_id) + elif self.method == "powershell-counters": + return self._get_stats_powershell(gpu_id) elif self.method == "psutil-fallback": - return self._get_stats_windows_fallback(gpu_id) + return self._get_stats_psutil(gpu_id) except Exception as e: - return AMDGPUStats(gpu_id, 0, 0, 0, 0, 0, 0, 0, False, str(e)) - - def _get_stats_linux(self, gpu_id: int) -> AMDGPUStats: - rsmi = self._backend - # Utilization - util = rsmi.rocm_smi_get_gpu_utilization(gpu_id) - - # Memory (ROCm returns KB, convert to Bytes) - mem_total_kb = rsmi.rocm_smi_get_gpu_memory_total(gpu_id) - mem_used_kb = rsmi.rocm_smi_get_gpu_memory_used(gpu_id) - - mem_total = mem_total_kb * 1024 - mem_used = mem_used_kb * 1024 - mem_free = mem_total - mem_used - mem_util = (mem_used / mem_total) * 100 if mem_total > 0 else 0 - - # Temp (ROCm returns mC, convert to C) - temp = rsmi.rocm_smi_get_temp(gpu_id, 0) / 1000.0 - - # Fan - fan = rsmi.rocm_smi_get_fan_speed(gpu_id, 0) + return AMDGPUStats( + gpu_id=gpu_id, + gpu_name=self.gpu_names[gpu_id] if gpu_id < len(self.gpu_names) else "", + is_available=False, + error_message=str(e) + ) return AMDGPUStats( gpu_id=gpu_id, + gpu_name=self.gpu_names[gpu_id] if gpu_id < len(self.gpu_names) else "", + is_available=False, + error_message="No matching backend handler" + ) + + def get_all_gpu_stats(self) -> List[AMDGPUStats]: + if not self.available or self.gpu_count == 0: + return [] + return [self.get_gpu_stats(i) for i in range(self.gpu_count)] + + # ============================================== + # BACKEND: Linux / ROCm + # ============================================== + + def _get_stats_linux(self, gpu_id: int) -> AMDGPUStats: + rsmi = self._backend + + util = float(rsmi.rocm_smi_get_gpu_utilization(gpu_id)) + mem_total_kb = float(rsmi.rocm_smi_get_gpu_memory_total(gpu_id)) + mem_used_kb = float(rsmi.rocm_smi_get_gpu_memory_used(gpu_id)) + mem_total = int(mem_total_kb * 1024) + mem_used = int(mem_used_kb * 1024) + mem_free = mem_total - mem_used + mem_util = (mem_used / mem_total * 100) if mem_total > 0 else 0 + temp = float(rsmi.rocm_smi_get_temp(gpu_id, 0)) / 1000.0 + fan = int(rsmi.rocm_smi_get_fan_speed(gpu_id, 0)) + + core_clk = 0 + mem_clk = 0 + power = 0.0 + try: + core_clk = int(rsmi.rocm_smi_get_gpu_clk_freq(gpu_id, 0)) + mem_clk = int(rsmi.rocm_smi_get_gpu_clk_freq(gpu_id, 1)) + power = float(rsmi.rocm_smi_get_power_avg(gpu_id)) / 1000.0 + except: + pass + + name = self.gpu_names[gpu_id] if gpu_id < len(self.gpu_names) else f"AMD GPU {gpu_id}" + + return AMDGPUStats( + gpu_id=gpu_id, + gpu_name=name, utilization_gpu=util, utilization_memory=mem_util, memory_total=mem_total, @@ -127,84 +295,293 @@ class AMDMonitor: memory_free=mem_free, temperature=temp, fan_speed=fan, + core_clock=core_clk, + memory_clock=mem_clk, + power_draw=power, is_available=True ) - def _get_stats_windows(self, gpu_id: int) -> AMDGPUStats: - adl = self._backend - adapters = adl.ADL2_Adapter_AdapterInfo_Get_All() - active_adapters = [a for a in adapters if getattr(a, 'is_present', True)] - - if gpu_id >= len(active_adapters): - return AMDGPUStats(gpu_id, 0, 0, 0, 0, 0, 0, 0, False, "GPU Index out of range") - - adapter = active_adapters[gpu_id] - adapter_index = adapter.adapter_index + # ============================================== + # BACKEND: Windows / WMI + # ============================================== - # Utilization (Overdrive6 is for GCN and newer) - gpu_util = 0 - try: - usage = adl.ADL2_Overdrive6_CurrentUsage_Get(adapter_index, 0) - gpu_util = usage.iEngineClock - except: - pass + def _get_stats_wmi(self, gpu_id: int) -> AMDGPUStats: + """Get GPU stats via PowerShell WMI queries""" + name = self.gpu_names[gpu_id] if gpu_id < len(self.gpu_names) else f"AMD GPU {gpu_id}" - # Memory (VRAM) - ADL doesn't expose this easily + # Try to get GPU utilization via performance counters + gpu_util = 0.0 + temp = 0.0 + fan = 0 mem_used = 0 mem_total = 0 - mem_util = 0 - - # Temperature - temp = 0 + mem_free = 0 + mem_util = 0.0 + try: - temp_data = adl.ADL2_Overdrive6_Temperature_Get(adapter_index, 0) - temp = temp_data.iTemperature / 1000.0 # ADL returns in milli-Celsius - except: - pass - - # Fan Speed - fan = 0 - try: - fan_data = adl.ADL2_Overdrive6_FanSpeed_Get(adapter_index, 0) - fan = fan_data.iFanSpeedPercent - except: - pass - + # GPU Utilization via PowerShell Performance Counters + ps_cmd = ( + "Get-Counter '\\GPU Engine(*engtype_3D)\\*' -SampleInterval 0 -MaxSamples 1 " + "2>$null | Select-Object -ExpandProperty CounterSamples | " + "Measure-Object -Property CookedValue -Average | " + "Select-Object -ExpandProperty Average" + ) + result = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, text=True, timeout=3 + ) + if result.returncode == 0 and result.stdout.strip(): + val = float(result.stdout.strip()) + if val > 0: + gpu_util = val + + # GPU Temperature via WMI (AMD-specific) + temp_cmd = ( + "Get-WmiObject -Namespace 'root/OpenHardwareMonitor' -Class Sensor " + "2>$null | Where-Object { $_.SensorType -eq 'Temperature' -and " + "$_.Name -match 'GPU' -and $_.Value -gt 0 } | " + "Select-Object -First 1 -ExpandProperty Value" + ) + result_temp = subprocess.run( + ["powershell", "-NoProfile", "-Command", temp_cmd], + capture_output=True, text=True, timeout=3 + ) + if result_temp.returncode == 0 and result_temp.stdout.strip(): + temp = float(result_temp.stdout.strip()) + + # Fallback temperature: read from GPU registry via AMDPowerProfiling + if temp == 0: + reg_cmd = ( + "Get-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\" + "{4d36e968-e325-11ce-bfc1-08002be10318}\\*' -Name 'AdpaterDesc' -ErrorAction SilentlyContinue " + "2>$null | Select-Object -ExpandProperty AdpaterDesc -First 1" + ) + + # Memory (VRAM) via WMI + vram_cmd = ( + "Get-WmiObject Win32_VideoController | " + "Select-Object -First 1 -Property AdapterRAM, CurrentHorizontalResolution" + ) + result_vram = subprocess.run( + ["powershell", "-NoProfile", "-Command", + "(Get-WmiObject Win32_VideoController | Select-Object -First 1).AdapterRAM"], + capture_output=True, text=True, timeout=3 + ) + if result_vram.returncode == 0 and result_vram.stdout.strip(): + mem_total = int(result_vram.stdout.strip()) + # We can't get precise VRAM usage without ADLX, but estimate via process + if mem_total > 0: + mem_util = psutil_vram_estimate() + mem_used = int(mem_total * mem_util / 100) + mem_free = mem_total - mem_used + + except Exception as e: + logger.debug(f"WMI stats error: {e}") + + # Fallback to system RAM estimation for VRAM + if mem_total == 0: + try: + import psutil + svmem = psutil.virtual_memory() + mem_total = svmem.total + mem_used = svmem.used + mem_free = svmem.available + mem_util = (mem_used / mem_total * 100) if mem_total > 0 else 0 + except: + pass + return AMDGPUStats( gpu_id=gpu_id, - utilization_gpu=gpu_util, + gpu_name=name, + utilization_gpu=gpu_util, utilization_memory=mem_util, memory_total=mem_total, memory_used=mem_used, - memory_free=0, + memory_free=mem_free, temperature=temp, fan_speed=fan, + core_clock=0, + memory_clock=0, + power_draw=0, is_available=True ) - def _get_stats_windows_fallback(self, gpu_id: int) -> AMDGPUStats: - """Fallback using psutil when ADL fails""" - import psutil - - # Basic system memory (not GPU-specific, but better than nothing) - svmem = psutil.virtual_memory() - + # ============================================== + # BACKEND: Windows / pyadl + # ============================================== + + def _get_stats_pyadl(self, gpu_id: int) -> AMDGPUStats: + adl = self._backend + name = self.gpu_names[gpu_id] if gpu_id < len(self.gpu_names) else f"AMD GPU {gpu_id}" + + try: + adapters = adl.ADL2_Adapter_AdapterInfo_Get_All() + active = [a for a in adapters if getattr(a, 'is_present', True)] + if gpu_id >= len(active): + return AMDGPUStats(gpu_id=gpu_id, gpu_name=name, is_available=False, error_message="GPU out of range") + + idx = active[gpu_id].adapter_index + + gpu_util = 0.0 + try: + usage = adl.ADL2_Overdrive6_CurrentUsage_Get(idx, 0) + gpu_util = float(usage.iEngineClock) / 100.0 + except: + pass + + temp = 0.0 + try: + td = adl.ADL2_Overdrive6_Temperature_Get(idx, 0) + temp = float(td.iTemperature) / 1000.0 + except: + pass + + fan = 0 + try: + fd = adl.ADL2_Overdrive6_FanSpeed_Get(idx, 0) + fan = int(fd.iFanSpeedPercent) + except: + pass + + return AMDGPUStats( + gpu_id=gpu_id, + gpu_name=name, + utilization_gpu=gpu_util, + temperature=temp, + fan_speed=fan, + is_available=True + ) + except Exception as e: + return AMDGPUStats(gpu_id=gpu_id, gpu_name=name, is_available=False, error_message=str(e)) + + # ============================================== + # BACKEND: Windows / ADLX (placeholder) + # ============================================== + + def _get_stats_adlx(self, gpu_id: int) -> AMDGPUStats: + name = self.gpu_names[gpu_id] if gpu_id < len(self.gpu_names) else f"AMD GPU {gpu_id}" return AMDGPUStats( gpu_id=gpu_id, - utilization_gpu=0, - utilization_memory=(svmem.used / svmem.total * 100), + gpu_name=name, + is_available=False, + error_message="ADLX full binding not yet implemented" + ) + + # ============================================== + # BACKEND: Windows / PowerShell Counters + # ============================================== + + def _get_stats_powershell(self, gpu_id: int) -> AMDGPUStats: + """Get GPU stats via PowerShell performance counters""" + try: + from .amd_gpu_counter import get_gpu_counter + except ImportError: + return self._get_stats_psutil(gpu_id) + + try: + counter = get_gpu_counter() + metrics = counter.get_all_metrics(gpu_id) + + name = metrics['gpu_name'] or (self.gpu_names[gpu_id] if gpu_id < len(self.gpu_names) else f"AMD GPU {gpu_id}") + + # VRAM + vram = metrics['vram'] + mem_total = int(vram['total_mb'] * 1024 * 1024) if vram['total_mb'] > 0 else 0 + mem_used = int(vram['used_mb'] * 1024 * 1024) if vram['used_mb'] > 0 else 0 + mem_free = int(vram['free_mb'] * 1024 * 1024) if vram['free_mb'] > 0 else mem_total + + # If no VRAM used data, estimate from psutil process memory + if mem_used == 0 and mem_total > 0: + mem_util = psutil_vram_estimate() + mem_used = int(mem_total * mem_util / 100) + mem_free = mem_total - mem_used + elif mem_total > 0: + mem_util = (mem_used / mem_total * 100) if mem_total > 0 else 0 + else: + # No VRAM data at all โ€” use system RAM as fallback + try: + import psutil + svmem = psutil.virtual_memory() + mem_total = svmem.total + mem_used = svmem.used + mem_free = svmem.available + mem_util = (mem_used / mem_total * 100) if mem_total > 0 else 0 + if not name: + name = "System RAM (GPU VRAM unavailable)" + except: + mem_util = 0 + + return AMDGPUStats( + gpu_id=gpu_id, + gpu_name=name, + utilization_gpu=metrics['utilization'], + utilization_memory=mem_util, + memory_total=mem_total, + memory_used=mem_used, + memory_free=mem_free, + temperature=metrics['temperature'], + fan_speed=metrics['fan_speed'], + core_clock=0, + memory_clock=0, + power_draw=0, + is_available=True + ) + except Exception as e: + logger.warning(f"PowerShell counter error: {e}") + return self._get_stats_psutil(gpu_id) + + # ============================================== + # BACKEND: psutil fallback + # ============================================== + + def _get_stats_psutil(self, gpu_id: int) -> AMDGPUStats: + import psutil + svmem = psutil.virtual_memory() + name = self.gpu_names[gpu_id] if gpu_id < len(self.gpu_names) else "System RAM" + + return AMDGPUStats( + gpu_id=gpu_id, + gpu_name=name, + utilization_gpu=0.0, + utilization_memory=(svmem.used / svmem.total * 100) if svmem.total > 0 else 0, memory_total=svmem.total, memory_used=svmem.used, memory_free=svmem.available, - temperature=0, + temperature=0.0, fan_speed=0, is_available=True ) +# ============================================== +# HELPERS +# ============================================== + +def psutil_vram_estimate() -> float: + """Rough VRAM usage estimate from process memory""" + try: + import psutil + total = 0 + for proc in psutil.process_iter(['memory_info', 'name']): + try: + info = proc.info + if info.get('memory_info'): + total += info['memory_info'].rss + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + svmem = psutil.virtual_memory() + return (total / svmem.total * 100) if svmem.total > 0 else 0 + except: + return 0 + + +# ============================================== # Singleton +# ============================================== + _amd_monitor = None + def get_amd_monitor(): global _amd_monitor if _amd_monitor is None: diff --git a/web/amd_monitor.js b/web/amd_monitor.js index 9248b9a..8048d0e 100644 --- a/web/amd_monitor.js +++ b/web/amd_monitor.js @@ -1,44 +1,44 @@ /** - * BANGTRIXTOOLKIT - AMD Monitor Floating Widget + * BANGTRIXTOOLKIT - AMD Monitor Overlay v3.2 * Real-time AMD GPU monitoring overlay for ComfyUI - * Cross-platform: Windows (ADL) / Linux (ROCm) + * Features: GPU name, sparkline, generation stats, settings, multi-GPU */ -(function() { +(function () { "use strict"; - // ===== CONFIGURATION ===== - const CONFIG = { - wsUrl: `ws://${window.location.host}/ws/amd_monitor`, - updateInterval: 1000, + var CONFIG = { storageKey: 'bangtrix_amd_monitor_config', - defaultPosition: { top: '60px', right: '20px' } }; - // ===== STATE ===== - let ws = null; - let reconnectTimer = null; - let reconnectAttempts = 0; - const MAX_RECONNECT = 5; - - // Widget state - let widget = null; - let isMinimized = false; - let isVisible = true; - let isDragging = false; - let dragOffset = { x: 0, y: 0 }; + var ws = null; + var reconnectTimer = null; + var reconnectAttempts = 0; + var MAX_RECONNECT = 15; - // ===== INITIALIZATION ===== + var widget = null; + var isMinimized = false; + var isVisible = true; + var isDragging = false; + var dragOffset = { x: 0, y: 0 }; + + var gpuDataMap = {}; + var selectedGpuId = 0; + var gpuCount = 1; + var historyData = []; + + var savedConfig = {}; + + // ===== UTILITY ===== + function $(id) { return document.getElementById(id); } + + // ===== INIT ===== function init() { - if (document.getElementById('bangtrix-amd-monitor')) { - console.log('AMD Monitor: Already initialized'); - return; - } - - console.log('AMD Monitor: Initializing floating widget...'); - createWidget(); - setupEventListeners(); + console.log('[AMD Monitor] Initializing v3.2...'); loadConfig(); + createWidget(); + applyConfigToWidget(); + setupEventListeners(); connectWebSocket(); } @@ -47,235 +47,151 @@ widget = document.createElement('div'); widget.id = 'bangtrix-amd-monitor'; widget.className = 'amd-monitor-widget'; - - widget.innerHTML = ` -
-
- ๐Ÿ”ด - AMD Monitor -
-
- - - -
-
- -
-
-
- GPU Load - -- -
-
-
- VRAM - -- / -- GB -
-
-
- Temperature - --ยฐC -
-
-
- Fan Speed - --% -
-
-
- - -
- `; - injectStyles(); + widget.innerHTML = + '
' + + '
' + + '๐Ÿ”ด' + + 'AMD Monitor' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '
AMD GPU
' + + '
' + + '
GPU Load--
' + + '
VRAM-- / -- GB
' + + '
TempN/A
' + + '
FanN/A
' + + '
' + + '' + + '
' + + '
' + + 'Generation Stats' + + 'โ— idle' + + '
' + + '
' + + '
Duration--
' + + '
RAM Peak--
' + + '
RAM Delta--
' + + '
CPU Peak--
' + + '
' + + '
' + + '' + + '' + + '' + + '
'; + + var style = document.createElement('style'); + style.id = 'bangtrix-amd-monitor-styles'; + style.textContent = + '#bangtrix-amd-monitor{position:fixed;top:60px;right:20px;width:260px;' + + 'background:rgba(18,18,24,0.92);border:1px solid rgba(196,48,43,0.3);border-radius:10px;' + + 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:11px;' + + 'color:#e0e0e0;z-index:9999;box-shadow:0 8px 32px rgba(0,0,0,0.4);' + + 'backdrop-filter:blur(8px);user-select:none;}' + + '#bangtrix-amd-monitor.hidden{display:none}' + + '#bangtrix-amd-monitor.minimized .amd-body{display:none}' + + '.amd-header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;' + + 'background:linear-gradient(135deg,rgba(196,48,43,0.2)0%,rgba(139,32,29,0.1)100%);' + + 'border-radius:10px 10px 0 0;cursor:move;border-bottom:1px solid rgba(255,255,255,0.05)}' + + '.amd-title{display:flex;align-items:center;gap:8px;font-weight:600;font-size:12px;color:#fff}' + + '.amd-icon{font-size:14px;animation:pulse 2s infinite}' + + '@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.6}}' + + '.amd-controls{display:flex;gap:4px}' + + '.amd-btn{background:rgba(255,255,255,0.1);border:none;color:#ccc;width:22px;height:22px;border-radius:4px;cursor:pointer;font-size:12px}' + + '.amd-btn:hover{background:rgba(255,255,255,0.2);color:#fff}' + + '.amd-btn-close:hover{background:rgba(255,80,80,0.3)}' + + '.amd-body{padding:12px 14px}' + + '.amd-gpu-name{text-align:center;font-size:11px;font-weight:600;color:#ff6b6b;margin-bottom:8px;' + + 'padding:4px 8px;border-radius:4px;background:rgba(255,255,255,0.04);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' + + '.amd-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px 12px}' + + '.amd-stat{display:flex;flex-direction:column;gap:2px}' + + '.amd-label{color:#888;font-size:10px;text-transform:uppercase}' + + '.amd-label-small{color:#888;font-size:10px}' + + '.amd-value{font-weight:600;font-size:13px;color:#00e676}' + + '.amd-value.na{color:#666}' + + '.amd-value.warning{color:#ffaa00}' + + '.amd-value.critical{color:#ff4444}' + + '.amd-bar{height:4px;background:rgba(255,255,255,0.1);border-radius:2px;overflow:hidden;margin-top:2px}' + + '.amd-fill{height:100%;border-radius:2px;transition:width 0.3s ease;width:0%;background:linear-gradient(90deg,#00e676,#00c853)}' + + '.amd-fill.amd-temp{background:linear-gradient(90deg,#00e676,#ffaa00,#ff4444)}' + + '.amd-sparkline-container{margin-top:8px}' + + '.amd-sparkline{width:100%;height:40px;border-radius:4px;background:rgba(0,0,0,0.3)}' + + '.amd-process{margin-top:8px;padding:6px 8px;background:rgba(0,0,0,0.2);border-radius:6px;border:1px solid rgba(255,255,255,0.06)}' + + '.amd-process-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}' + + '.amd-process-status{font-size:10px}' + + '.amd-process-status.generating{color:#00e676}' + + '.amd-process-status.idle{color:#888}' + + '.amd-process-body{display:grid;grid-template-columns:1fr 1fr;gap:2px 8px}' + + '.amd-stat-row{display:flex;justify-content:space-between}' + + '.amd-label-xs{color:#666;font-size:9px}' + + '.amd-value-sm{color:#ccc;font-size:10px}' + + '.amd-alert{display:flex;align-items:center;gap:6px;padding:6px 10px;margin-top:8px;' + + 'background:rgba(255,80,80,0.15);border:1px solid rgba(255,80,80,0.3);border-radius:6px;font-size:11px;color:#ff6b6b}' + + '.amd-settings{margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.05)}' + + '.amd-settings-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}' + + '.amd-select,.amd-input{background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);color:#e0e0e0;padding:2px 6px;border-radius:4px;font-size:10px;width:70px}' + + '.amd-input{width:50px}' + + '.amd-toggle{position:relative;width:32px;height:16px;cursor:pointer}' + + '.amd-toggle input{display:none}' + + '.amd-toggle-slider{position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(255,255,255,0.15);border-radius:8px;transition:0.2s}' + + '.amd-toggle-slider:before{content:"";position:absolute;width:12px;height:12px;left:2px;bottom:2px;background:#ccc;border-radius:50%;transition:0.2s}' + + '.amd-toggle input:checked+.amd-toggle-slider{background:rgba(196,48,43,0.6)}' + + '.amd-toggle input:checked+.amd-toggle-slider:before{transform:translateX(16px);background:#fff}' + + '.amd-footer{display:flex;justify-content:space-between;margin-top:10px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.05);font-size:10px;color:#666}' + + '.amd-status{transition:color 0.2s}' + + '.amd-status.connected{color:#00e676}' + + '.amd-status.disconnected{color:#ff4444}' + + '.amd-status.warning{color:#ffaa00}' + + '.amd-method{color:#888;font-family:monospace;font-size:9px}'; + document.head.appendChild(style); + document.body.appendChild(widget); setupDrag(); - - return widget; } - function injectStyles() { - const style = document.createElement('style'); - style.id = 'bangtrix-amd-monitor-styles'; - style.textContent = ` - #bangtrix-amd-monitor { - position: fixed; - top: 60px; - right: 20px; - width: 240px; - background: rgba(18, 18, 24, 0.92); - border: 1px solid rgba(196, 48, 43, 0.3); - border-radius: 10px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - font-size: 11px; - color: #e0e0e0; - z-index: 9999; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - backdrop-filter: blur(8px); - user-select: none; - transition: opacity 0.2s, transform 0.2s; - } - - #bangtrix-amd-monitor.hidden { display: none; } - #bangtrix-amd-monitor.minimized .amd-body { display: none; } - - .amd-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 14px; - background: linear-gradient(135deg, rgba(196, 48, 43, 0.2) 0%, rgba(139, 32, 29, 0.1) 100%); - border-radius: 10px 10px 0 0; - cursor: move; - border-bottom: 1px solid rgba(255,255,255,0.05); - } - - .amd-title { - display: flex; - align-items: center; - gap: 8px; - font-weight: 600; - font-size: 12px; - color: #fff; - } - - .amd-icon { font-size: 14px; animation: pulse 2s infinite; } - - @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.6; } - } - - .amd-controls { display: flex; gap: 4px; } - - .amd-btn { - background: rgba(255,255,255,0.1); - border: none; - color: #ccc; - width: 22px; - height: 22px; - border-radius: 4px; - cursor: pointer; - font-size: 12px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.15s; - } - - .amd-btn:hover { background: rgba(255,255,255,0.2); color: #fff; } - .amd-btn-close:hover { background: rgba(255, 80, 80, 0.3); color: #fff; } - - .amd-body { padding: 12px 14px; } - .amd-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; } - - .amd-stat { - display: flex; - flex-direction: column; - gap: 4px; - } - - .amd-label { - color: #888; - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.3px; - } - - .amd-value { - font-weight: 600; - font-size: 13px; - color: #00e676; - transition: color 0.2s; - } - - .amd-value.warning { color: #ffaa00; } - .amd-value.critical { color: #ff4444; } - - .amd-bar { - height: 4px; - background: rgba(255,255,255,0.1); - border-radius: 2px; - overflow: hidden; - margin-top: 2px; - } - - .amd-fill { - height: 100%; - background: linear-gradient(90deg, #00e676, #00c853); - border-radius: 2px; - transition: width 0.3s ease; - width: 0%; - } - - .amd-fill.amd-temp { - background: linear-gradient(90deg, #00e676, #ffaa00, #ff4444); - } - - .amd-footer { - display: flex; - justify-content: space-between; - margin-top: 14px; - padding-top: 10px; - border-top: 1px solid rgba(255,255,255,0.05); - font-size: 10px; - color: #666; - } - - .amd-status { transition: color 0.2s; } - .amd-status.connected { color: #00e676; } - .amd-status.disconnected { color: #ff4444; } - .amd-status.warning { color: #ffaa00; } - - .amd-method { color: #888; font-family: monospace; } - .amd-header:active { cursor: grabbing; } - - @media (max-width: 768px) { - #bangtrix-amd-monitor { width: 200px; font-size: 10px; } - .amd-grid { grid-template-columns: 1fr; } - } - `; - document.head.appendChild(style); - } - - // ===== DRAG FUNCTIONALITY ===== + // ===== DRAG ===== function setupDrag() { - const header = widget.querySelector('.amd-header'); - - header.addEventListener('mousedown', (e) => { + var header = widget.querySelector('.amd-header'); + if (!header) return; + header.addEventListener('mousedown', function(e) { if (e.target.closest('.amd-btn')) return; - isDragging = true; - const rect = widget.getBoundingClientRect(); + var rect = widget.getBoundingClientRect(); dragOffset.x = e.clientX - rect.left; dragOffset.y = e.clientY - rect.top; - widget.style.transition = 'none'; document.body.style.cursor = 'grabbing'; - document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag); }); - function onDrag(e) { if (!isDragging) return; - - const x = e.clientX - dragOffset.x; - const y = e.clientY - dragOffset.y; - - const maxX = window.innerWidth - widget.offsetWidth + 20; - const maxY = window.innerHeight - widget.offsetHeight + 20; - - widget.style.left = Math.max(10, Math.min(x, maxX)) + 'px'; - widget.style.top = Math.max(10, Math.min(y, maxY)) + 'px'; + var maxX = window.innerWidth - widget.offsetWidth + 20; + var maxY = window.innerHeight - widget.offsetHeight + 20; + widget.style.left = Math.max(10, Math.min(e.clientX - dragOffset.x, maxX)) + 'px'; + widget.style.top = Math.max(10, Math.min(e.clientY - dragOffset.y, maxY)) + 'px'; widget.style.right = 'auto'; } - function stopDrag() { isDragging = false; widget.style.transition = ''; @@ -288,253 +204,376 @@ // ===== EVENT LISTENERS ===== function setupEventListeners() { - widget.querySelector('.amd-btn-minimize').addEventListener('click', () => { + var minBtn = widget.querySelector('.amd-btn-minimize'); + if (minBtn) minBtn.addEventListener('click', function() { isMinimized = !isMinimized; widget.classList.toggle('minimized', isMinimized); - widget.querySelector('.amd-btn-minimize').textContent = isMinimized ? '+' : 'โˆ’'; + minBtn.textContent = isMinimized ? '+' : 'โˆ’'; saveConfig(); }); - - widget.querySelector('.amd-btn-close').addEventListener('click', () => { - isVisible = !isVisible; - widget.classList.toggle('hidden', !isVisible); + + var closeBtn = widget.querySelector('.amd-btn-close'); + if (closeBtn) closeBtn.addEventListener('click', function() { + isVisible = false; + widget.classList.add('hidden'); + saveConfig(); + showToast('Hidden. Ctrl+Shift+M to show.'); + }); + + var settingsBtn = widget.querySelector('.amd-btn-settings'); + var settingsPanel = $('amd-settings'); + if (settingsBtn && settingsPanel) settingsBtn.addEventListener('click', function() { + var isOpen = settingsPanel.style.display !== 'none'; + settingsPanel.style.display = isOpen ? 'none' : 'block'; + settingsBtn.classList.toggle('active', !isOpen); + }); + + var intervalSelect = $('amd-settings-interval'); + if (intervalSelect) intervalSelect.addEventListener('change', function(e) { + sendCommand({type:'set_interval',interval:parseFloat(e.target.value)}); saveConfig(); }); - - widget.querySelector('.amd-btn-settings').addEventListener('click', () => { - showToast('Settings coming soon!'); + + var vramInput = $('amd-settings-vram-threshold'); + if (vramInput) vramInput.addEventListener('change', function(e) { + savedConfig.vramThreshold = parseInt(e.target.value) || 90; + saveConfig(); + }); + + var tempSelect = $('amd-settings-temp-unit'); + if (tempSelect) tempSelect.addEventListener('change', function(e) { + savedConfig.tempUnit = e.target.value; + saveConfig(); + if (gpuDataMap[selectedGpuId]) refreshDisplay(selectedGpuId); + }); + + var sparkCheck = $('amd-settings-sparkline'); + if (sparkCheck) sparkCheck.addEventListener('change', function(e) { + var container = $('amd-sparkline-container'); + if (container) container.style.display = e.target.checked ? 'block' : 'none'; + saveConfig(); + }); + + document.addEventListener('keydown', function(e) { + if (e.ctrlKey && e.shiftKey && e.key === 'M') { + e.preventDefault(); + isVisible = !isVisible; + widget.classList.toggle('hidden', !isVisible); + if (isVisible) connectWebSocket(); + saveConfig(); + } }); } - // ===== CONFIGURATION PERSISTENCE ===== + // ===== CONFIG ===== function loadConfig() { - try { - const saved = localStorage.getItem(CONFIG.storageKey); - if (saved) { - const config = JSON.parse(saved); - if (config.position) { - if (config.position.left) { - widget.style.left = config.position.left; - widget.style.right = 'auto'; - } - if (config.position.top) { - widget.style.top = config.position.top; - } - } - if (config.minimized !== undefined) { - isMinimized = config.minimized; - widget.classList.toggle('minimized', isMinimized); - widget.querySelector('.amd-btn-minimize').textContent = isMinimized ? '+' : 'โˆ’'; - } - if (config.visible !== undefined) { - isVisible = config.visible; - widget.classList.toggle('hidden', !isVisible); - } - } - } catch (e) { - console.warn('AMD Monitor: Failed to load config:', e); + try { var saved = localStorage.getItem(CONFIG.storageKey); if (saved) savedConfig = JSON.parse(saved); } catch(e) {} + } + + function applyConfigToWidget() { + if (!widget) return; + var c = savedConfig; + if (c.position) { + if (c.position.left) { widget.style.left = c.position.left; widget.style.right = 'auto'; } + if (c.position.top) widget.style.top = c.position.top; } + if (c.minimized !== undefined) { + isMinimized = c.minimized; + widget.classList.toggle('minimized', isMinimized); + var btn = widget.querySelector('.amd-btn-minimize'); + if (btn) btn.textContent = isMinimized ? '+' : 'โˆ’'; + } + if (c.visible !== undefined) { isVisible = c.visible; widget.classList.toggle('hidden', !isVisible); } + var sel = $('amd-settings-interval'); if (sel) sel.value = String(c.updateInterval || 5); + var inp = $('amd-settings-vram-threshold'); if (inp) inp.value = c.vramThreshold || 90; + var sel2 = $('amd-settings-temp-unit'); if (sel2) sel2.value = c.tempUnit || 'celsius'; + var chk = $('amd-settings-sparkline'); + if (chk) { chk.checked = c.showSparkline !== false; var cont = $('amd-sparkline-container'); if (cont) cont.style.display = chk.checked ? 'block' : 'none'; } } function saveConfig() { try { - const config = { - position: { - top: widget.style.top, - left: widget.style.left - }, - minimized: isMinimized, - visible: isVisible + var config = { + position: { top: widget.style.top, left: widget.style.left }, + minimized: isMinimized, visible: isVisible, + updateInterval: parseFloat($('amd-settings-interval')?.value) || 5, + vramThreshold: parseInt($('amd-settings-vram-threshold')?.value) || 90, + tempUnit: $('amd-settings-temp-unit')?.value || 'celsius', + showSparkline: $('amd-settings-sparkline')?.checked !== false }; localStorage.setItem(CONFIG.storageKey, JSON.stringify(config)); - } catch (e) { - console.warn('AMD Monitor: Failed to save config:', e); - } + } catch(e) {} } - // ===== WEBSOCKET CONNECTION ===== + // ===== WEBSOCKET ===== function connectWebSocket() { - if (ws?.readyState === WebSocket.OPEN) return; - - console.log('AMD Monitor: Connecting to', CONFIG.wsUrl); + if (ws && ws.readyState === WebSocket.OPEN) return; + if (!isVisible) return; + var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + var url = protocol + '//' + window.location.host + '/ws/amd_monitor'; + console.log('[AMD Monitor] Connecting...'); updateStatus('Connecting...', 'warning'); - - ws = new WebSocket(CONFIG.wsUrl); - - ws.onopen = () => { - console.log('AMD Monitor: Connected'); + ws = new WebSocket(url); + ws.onopen = function() { + console.log('[AMD Monitor] Connected'); updateStatus('Connected', 'connected'); updateMethod('Live'); reconnectAttempts = 0; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } }; - - ws.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - if (data.type === 'amd_stats') { - updateDisplay(data); - } - } catch (e) { - console.error('AMD Monitor: Parse error:', e); - } + ws.onmessage = function(event) { + try { var data = JSON.parse(event.data); if (data.type === 'amd_stats') handleStatsData(data); } catch(e) {} }; - - ws.onclose = () => { - console.log('AMD Monitor: Disconnected'); + ws.onclose = function() { updateStatus('Disconnected', 'disconnected'); updateMethod('--'); - if (reconnectAttempts < MAX_RECONNECT) { reconnectAttempts++; - const delay = Math.min(1000 * Math.pow(1.5, reconnectAttempts), 8000); - console.log(`AMD Monitor: Reconnecting in ${delay}ms (attempt ${reconnectAttempts})`); + var delay = Math.min(1000 * Math.pow(1.5, reconnectAttempts), 10000); reconnectTimer = setTimeout(connectWebSocket, delay); } }; - - ws.onerror = (error) => { - console.error('AMD Monitor: WebSocket error:', error); - updateStatus('Connection error', 'disconnected'); - }; + ws.onerror = function() {}; } - // ===== DISPLAY UPDATES ===== - function updateDisplay(data) { + function sendCommand(cmd) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(cmd)); } + + // ===== DATA HANDLING ===== + function handleStatsData(data) { + gpuDataMap[data.gpu_id] = data; + if (data.gpu_count && gpuCount !== data.gpu_count) { gpuCount = data.gpu_count; updateGpuSelector(); } + if (data.gpu_id === selectedGpuId) refreshDisplay(selectedGpuId); + } + + function refreshDisplay(gpuId) { + var data = gpuDataMap[gpuId]; + if (!data) return; + + var nameEl = $('amd-gpu-name'); + if (nameEl) nameEl.textContent = data.gpu_name || ('AMD GPU ' + gpuId); + if (!data.is_available) { - setStat('amd-gpu-util', 'N/A'); - setStat('amd-vram', 'N/A'); - setStat('amd-temp', 'N/A'); - setStat('amd-fan', 'N/A'); - setBar('amd-gpu-bar', 0); - setBar('amd-vram-bar', 0); - setBar('amd-temp-bar', 0); - setBar('amd-fan-bar', 0); - updateStatus(data.error || 'AMD backend unavailable', 'warning'); + ['amd-gpu-util','amd-vram','amd-temp','amd-fan'].forEach(function(id) { setStat(id, 'N/A'); }); + ['amd-gpu-bar','amd-vram-bar','amd-temp-bar','amd-fan-bar'].forEach(function(id) { setBar(id, 0); }); + updateStatus(data.error || 'Unavailable', 'warning'); updateMethod(data.method || '--'); return; } - const gpuUtil = data.gpu_utilization || 0; - setStat('amd-gpu-util', `${gpuUtil.toFixed(1)}%`, gpuUtil); + // GPU Load + var gpuUtil = data.gpu_utilization !== undefined ? data.gpu_utilization : 0; + setStat('amd-gpu-util', gpuUtil.toFixed(1) + '%', gpuUtil); setBar('amd-gpu-bar', gpuUtil); - - const vramUsedGB = (data.vram_used_mb || 0) / 1024; - const vramTotalGB = (data.vram_total_mb || 0) / 1024; - const vramPct = data.vram_usage_pct || (vramTotalGB > 0 ? (vramUsedGB / vramTotalGB * 100) : 0); - setStat('amd-vram', `${vramUsedGB.toFixed(1)} / ${vramTotalGB.toFixed(1)} GB`, vramPct); - setBar('amd-vram-bar', vramPct); - - const temp = data.temperature || 0; - setStat('amd-temp', `${temp.toFixed(1)}ยฐC`, temp); - setBar('amd-temp-bar', Math.min(temp, 100)); - - const fan = data.fan_speed || 0; - setStat('amd-fan', `${fan}%`, fan); - setBar('amd-fan-bar', fan); - - updateStatus(`GPU ${data.gpu_id}`, 'connected'); - updateMethod(data.method || 'unknown'); + + // VRAM + var vramUsedMB = data.vram_used_mb || 0; + var vramTotalMB = data.vram_total_mb || 0; + var vramPct = data.vram_usage_pct || (vramTotalMB > 0 ? (vramUsedMB / vramTotalMB * 100) : 0); + if (vramTotalMB > 0) { + setStat('amd-vram', (vramUsedMB / 1024).toFixed(1) + ' / ' + (vramTotalMB / 1024).toFixed(1) + ' GB', vramPct); + setBar('amd-vram-bar', vramPct); + } else { setStat('amd-vram', 'N/A'); setBar('amd-vram-bar', 0); } + + // Temperature + var temp = data.temperature || 0; + if (temp > 0) { + var tUnit = savedConfig.tempUnit || 'celsius'; + var tempDisplay = temp; + var tempUnitStr = 'ยฐC'; + if (tUnit === 'fahrenheit') { tempDisplay = temp * 9/5 + 32; tempUnitStr = 'ยฐF'; } + setStat('amd-temp', tempDisplay.toFixed(1) + tempUnitStr, temp); + setBar('amd-temp-bar', Math.min(temp, 100)); + } else { setStat('amd-temp', 'N/A'); setBar('amd-temp-bar', 0); } + + // Fan + var fan = data.fan_speed || 0; + if (fan > 0) { setStat('amd-fan', fan + '%', fan); setBar('amd-fan-bar', fan); } + else { setStat('amd-fan', 'N/A'); setBar('amd-fan-bar', 0); } + + // Status + var methodInfo = data.method || 'unknown'; + if (data.core_clock_mhz > 0) methodInfo += ' | ' + data.core_clock_mhz + 'MHz'; + if (data.power_draw_watts > 0) methodInfo += ' | ' + data.power_draw_watts + 'W'; + updateMethod(methodInfo); + + var statusText = data.gpu_name || ('GPU ' + gpuId); + if (gpuCount > 1) statusText += ' (' + (gpuId + 1) + '/' + gpuCount + ')'; + updateStatus(statusText, 'connected'); + + // Sparkline + if (data.history && data.history.length > 0) { + historyData = data.history; + drawSparkline(historyData); + var sparkContainer = $('amd-sparkline-container'); + if (sparkContainer) { + var chk = $('amd-settings-sparkline'); + sparkContainer.style.display = (chk && chk.checked) ? 'block' : 'none'; + } + } + + // Process stats + updateProcessDisplay(data); + checkVramAlert(vramPct); } - function setStat(elementId, value, numericValue = null) { - const el = document.getElementById(elementId); + function updateProcessDisplay(data) { + var process = data.process; + if (!process) return; + var statusEl = $('amd-process-status'); + var durEl = $('amd-gen-duration'); + var ramPeakEl = $('amd-gen-ram-peak'); + var ramDeltaEl = $('amd-gen-ram-delta'); + var cpuPeakEl = $('amd-gen-cpu-peak'); + if (!statusEl) return; + + if (process.is_generating && process.generation) { + var gen = process.generation; + statusEl.textContent = 'โ— generating'; + statusEl.className = 'amd-process-status generating'; + if (durEl) durEl.textContent = gen.duration + 's'; + if (ramPeakEl) ramPeakEl.textContent = gen.ram_peak_mb + ' MB'; + if (ramDeltaEl) ramDeltaEl.textContent = '+' + (gen.ram_peak_mb - gen.ram_start_mb) + ' MB'; + if (cpuPeakEl) cpuPeakEl.textContent = gen.cpu_peak + '%'; + } else if (process.last_generation) { + var last = process.last_generation; + statusEl.textContent = 'โ— idle (' + (process.generation_count || 0) + ' gens)'; + statusEl.className = 'amd-process-status idle'; + if (durEl) durEl.textContent = last.duration + 's'; + if (ramPeakEl) ramPeakEl.textContent = last.ram_peak_mb + ' MB'; + if (ramDeltaEl) ramDeltaEl.textContent = '+' + last.vram_delta_mb + ' MB'; + if (cpuPeakEl) cpuPeakEl.textContent = last.cpu_peak + '%'; + } else { + statusEl.textContent = 'โ— idle'; + statusEl.className = 'amd-process-status idle'; + if (durEl) durEl.textContent = '--'; if (ramPeakEl) ramPeakEl.textContent = '--'; + if (ramDeltaEl) ramDeltaEl.textContent = '--'; if (cpuPeakEl) cpuPeakEl.textContent = '--'; + } + } + + function updateGpuSelector() { + var selector = $('amd-gpu-selector'); + var chips = $('amd-gpu-chips'); + if (!selector || !chips) return; + if (gpuCount <= 1) { selector.style.display = 'none'; return; } + selector.style.display = 'block'; + chips.innerHTML = ''; + for (var i = 0; i < gpuCount; i++) (function(idx) { + var chip = document.createElement('span'); + chip.className = 'amd-chip' + (idx === selectedGpuId ? ' active' : ''); + chip.textContent = 'GPU ' + idx; + chip.addEventListener('click', function() { + selectedGpuId = idx; + chips.querySelectorAll('.amd-chip').forEach(function(c) { c.classList.remove('active'); }); + chip.classList.add('active'); + refreshDisplay(selectedGpuId); + }); + chips.appendChild(chip); + })(i); + } + + // ===== SPARKLINE ===== + function drawSparkline(values) { + var canvas = $('amd-sparkline'); + if (!canvas) return; + var ctx = canvas.getContext('2d'); + var w = canvas.width, h = canvas.height, padding = 2; + ctx.clearRect(0, 0, w, h); + if (values.length < 2 || values.every(function(v) { return v === 0; })) { + ctx.fillStyle = '#555'; ctx.font = '10px monospace'; ctx.textAlign = 'center'; + ctx.fillText('awaiting data...', w / 2, h / 2 + 3); return; + } + var max = Math.max.apply(null, values); if (max < 1) max = 1; + var stepX = (w - padding * 2) / (values.length - 1); + ctx.beginPath(); ctx.strokeStyle = '#00e676'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; + for (var i = 0; i < values.length; i++) { + var x = padding + i * stepX; + var y = h - padding - (values[i] / max) * (h - padding * 2); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.lineTo(padding + (values.length - 1) * stepX, h - padding); + ctx.lineTo(padding, h - padding); ctx.closePath(); + ctx.fillStyle = 'rgba(0,230,118,0.1)'; ctx.fill(); + var lastVal = values[values.length - 1]; + ctx.fillStyle = '#00e676'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'right'; + ctx.fillText(lastVal.toFixed(0) + '%', w - padding, 10); + } + + // ===== ALERTS ===== + var lastAlertTime = 0; + function checkVramAlert(vramPct) { + var threshold = savedConfig.vramThreshold || 90; + if (vramPct >= threshold && Date.now() - lastAlertTime > 10000) { + lastAlertTime = Date.now(); + var alertEl = $('amd-alert'); + var alertText = $('amd-alert-text'); + if (alertEl && alertText) { + alertText.textContent = 'VRAM ' + vramPct.toFixed(0) + '% >= ' + threshold + '%'; + alertEl.style.display = 'flex'; + setTimeout(function() { if (alertEl) alertEl.style.display = 'none'; }, 5000); + } + } + } + + // ===== UI HELPERS ===== + function setStat(elementId, value, numericValue) { + var el = $(elementId); if (!el) return; el.textContent = value; - - el.classList.remove('warning', 'critical'); - if (numericValue !== null) { - if (elementId === 'amd-temp' && numericValue > 80) { - el.classList.add('critical'); - } else if (elementId === 'amd-temp' && numericValue > 65) { - el.classList.add('warning'); - } else if ((elementId === 'amd-gpu-util' || elementId === 'amd-vram') && numericValue > 90) { - el.classList.add('critical'); - } else if ((elementId === 'amd-gpu-util' || elementId === 'amd-vram') && numericValue > 75) { - el.classList.add('warning'); - } + el.classList.remove('warning', 'critical', 'na'); + if (value === 'N/A') { el.classList.add('na'); } + else if (numericValue !== null && numericValue !== undefined) { + if ((elementId === 'amd-temp') && numericValue > 80) el.classList.add('critical'); + else if ((elementId === 'amd-temp') && numericValue > 65) el.classList.add('warning'); + else if ((elementId === 'amd-gpu-util' || elementId.indexOf('vram') >= 0) && numericValue > 90) el.classList.add('critical'); + else if ((elementId === 'amd-gpu-util' || elementId.indexOf('vram') >= 0) && numericValue > 75) el.classList.add('warning'); } } function setBar(elementId, percent) { - const el = document.getElementById(elementId); - if (!el) return; - el.style.width = `${Math.min(100, Math.max(0, percent))}%`; + var el = $(elementId); if (!el) return; + el.style.width = Math.min(100, Math.max(0, percent)) + '%'; } function updateStatus(message, className) { - const el = document.getElementById('amd-status'); - if (!el) return; - el.textContent = message; - el.className = `amd-status ${className}`; - - const icon = widget.querySelector('.amd-icon'); - if (icon) { - icon.textContent = className === 'connected' ? '๐ŸŸข' : - className === 'warning' ? '๐ŸŸก' : '๐Ÿ”ด'; - } + var el = $('amd-status'); if (!el) return; + el.textContent = message; el.className = 'amd-status ' + className; + var icon = widget.querySelector('.amd-icon'); + if (icon) icon.textContent = className === 'connected' ? '๐ŸŸข' : className === 'warning' ? '๐ŸŸก' : '๐Ÿ”ด'; } - function updateMethod(method) { - const el = document.getElementById('amd-method'); - if (!el) return; - el.textContent = method ? `[${method}]` : ''; - } + function updateMethod(method) { var el = $('amd-method'); if (el) el.textContent = method || ''; } - // ===== UTILITIES ===== - function showToast(message, duration = 2000) { - const toast = document.createElement('div'); + function showToast(message, duration) { + duration = duration || 2000; + var old = document.querySelector('.bangtrix-toast'); if (old) old.remove(); + var toast = document.createElement('div'); + toast.className = 'bangtrix-toast'; toast.textContent = message; - toast.style.cssText = ` - position: fixed; - bottom: 20px; - right: 20px; - background: rgba(30, 30, 40, 0.95); - color: #fff; - padding: 10px 16px; - border-radius: 6px; - font-size: 12px; - z-index: 10001; - border: 1px solid rgba(196, 48, 43, 0.5); - animation: slideIn 0.2s ease; - `; + toast.style.cssText = 'position:fixed;bottom:20px;right:20px;background:rgba(30,30,40,0.95);color:#fff;padding:10px 16px;border-radius:6px;font-size:12px;z-index:10001;border:1px solid rgba(196,48,43,0.5);animation:slideIn 0.2s ease;max-width:300px;'; document.body.appendChild(toast); - - setTimeout(() => { - toast.style.animation = 'slideOut 0.2s ease'; - setTimeout(() => toast.remove(), 200); - }, duration); + setTimeout(function() { toast.style.animation = 'slideOut 0.2s ease'; setTimeout(function() { toast.remove(); }, 200); }, duration); } + // ===== ANIMATIONS ===== if (!document.getElementById('amd-monitor-animations')) { - const animStyle = document.createElement('style'); - animStyle.id = 'amd-monitor-animations'; - animStyle.textContent = ` - @keyframes slideIn { - from { transform: translateX(100%); opacity: 0; } - to { transform: translateX(0); opacity: 1; } - } - @keyframes slideOut { - from { transform: translateX(0); opacity: 1; } - to { transform: translateX(100%); opacity: 0; } - } - `; - document.head.appendChild(animStyle); + var s = document.createElement('style'); + s.id = 'amd-monitor-animations'; + s.textContent = '@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}@keyframes slideOut{from{transform:translateX(0);opacity:1}to{transform:translateX(100%);opacity:0}}'; + document.head.appendChild(s); + } + + // ===== AMD CHIP SELECTOR ===== + if (!document.getElementById('amd-chip-styles')) { + var cs = document.createElement('style'); + cs.id = 'amd-chip-styles'; + cs.textContent = '.amd-chip{padding:2px 8px;border-radius:10px;font-size:10px;background:rgba(255,255,255,0.08);cursor:pointer;transition:all 0.15s;border:1px solid transparent}.amd-chip:hover{background:rgba(255,255,255,0.15)}.amd-chip.active{background:rgba(196,48,43,0.3);border-color:rgba(196,48,43,0.5);color:#fff}.amd-gpu-selector{margin-bottom:6px}.amd-chip-group{display:flex;gap:4px;flex-wrap:wrap}'; + document.head.appendChild(cs); } // ===== START ===== - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } - - window.addEventListener('beforeunload', () => { - if (ws) ws.close(); - if (reconnectTimer) clearTimeout(reconnectTimer); - }); + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); + window.addEventListener('beforeunload', function() { if (ws) ws.close(); if (reconnectTimer) clearTimeout(reconnectTimer); }); })(); \ No newline at end of file