diff --git a/LibreHardwareMonitorLib.dll b/LibreHardwareMonitorLib.dll new file mode 100644 index 0000000..b9145b8 Binary files /dev/null and b/LibreHardwareMonitorLib.dll differ diff --git a/__init__.py b/__init__.py index 8277e0e..5c60d34 100644 --- a/__init__.py +++ b/__init__.py @@ -56,6 +56,7 @@ try: from .nodes_blockswap_sage import XB_Sage_BlockSwap from .nodes_wiring import XB_DynamicBus, XB_UNetNameBroadcaster, XB_CLIPNameBroadcaster from .nodes_dashboard import XB_Dashboard_Zen + from .nodes_memory_viz import XB_MemoryVisualization from .nodes_tile import XB_SamplerChunkMaster from .nodes_wan_vae import (XB_WanImageToVideo, XB_WanFirstLastFrameToVideo, XB_WanSoundImageToVideo, XB_WanFunControlToVideo, XB_WanVaceToVideo, XB_Wan22FunControlToVideo, XB_WanInfiniteTalkToVideo, XB_WanInfiniteTalkToVideo_Single, XB_WanInfiniteTalkToVideo_Dual, XB_WanVAEDecodeTiled, XB_WanFunInpaintToVideo, XB_WanCameraImageToVideo, XB_WanPhantomSubjectToVideo, XB_WanHuMoImageToVideo, XB_Wan22ImageToVideoLatent, XB_WanSoundImageToVideoExtend, XB_WanSCAILToVideo, XB_WanSCAILToVideoPro, XB_WanDancerVideo, XB_WanDanceSwitcher, XB_WanDancerCombo, XB_BerniniConditioning) from .nodes_batch import XB_BatchFolderLoader @@ -135,6 +136,7 @@ try: "XB_UNetNameBroadcaster": XB_UNetNameBroadcaster, "XB_CLIPNameBroadcaster": XB_CLIPNameBroadcaster, "XB_Dashboard_Zen": XB_Dashboard_Zen, + "XB_MemoryVisualization": XB_MemoryVisualization, "XB_SamplerChunkMaster": XB_SamplerChunkMaster, "XB_WanImageToVideo": XB_WanImageToVideo, "XB_WanFirstLastFrameToVideo": XB_WanFirstLastFrameToVideo, @@ -275,6 +277,7 @@ try: "XB_UNetNameBroadcaster": "XB-BOX - UNet Name Broadcaster", "XB_CLIPNameBroadcaster": "XB-BOX - CLIP Name Broadcaster", "XB_Dashboard_Zen": "XB-BOX - Dashboard Zen", + "XB_MemoryVisualization": "XB 📊 硬件监控面板 (Memory Visualization)", "XB_SamplerChunkMaster": "XB-BOX - Sampler Chunk Master", "XB_WanImageToVideo": "XB-BOX - Wan Image2Video", "XB_WanFirstLastFrameToVideo": "XB-BOX - Wan First/Last Frame2Video", diff --git a/docs/XB_MemoryVisualization_manual.md b/docs/XB_MemoryVisualization_manual.md new file mode 100644 index 0000000..2e958fb --- /dev/null +++ b/docs/XB_MemoryVisualization_manual.md @@ -0,0 +1,440 @@ +# XB 📊 硬件监控面板 — 使用说明书 + +> **节点名称**: `XB_MemoryVisualization` +> **显示名称**: `XB 📊 硬件监控面板 (Memory Visualization)` +> **分类**: `XB_ToolBox/Dashboard` +> **基于**: [ComfyUI-MemoryVisualization](https://github.com/kijai/ComfyUI-MemoryVisualization) +> **适配**: 小白工具箱 (XB_ToolBox) / NVIDIA + AMD 双卡支持 + +--- + +## 目录 + +1. [功能概览](#1-功能概览) +2. [安装与依赖](#2-安装与依赖) +3. [快速上手](#3-快速上手) +4. [面板布局详解](#4-面板布局详解) +5. [右键菜单](#5-右键菜单) +6. [迷你栏 (Mini Bar)](#6-迷你栏-mini-bar) +7. [模型列表](#7-模型列表) +8. [历史图表](#8-历史图表) +9. [主题切换](#9-主题切换) +10. [AMD GPU 用户须知](#10-amd-gpu-用户须知) +11. [常见问题](#11-常见问题) + +--- + +## 1. 功能概览 + +硬件监控面板是一个**实时、可拖拽、可缩放**的系统资源监控窗口,在 ComfyUI 界面右下角自动显示。 + +### 核心功能 + +| 功能 | 说明 | +|------|------| +| **VRAM 显存监控** | 实时显示 GPU 显存使用量、分段占比(模型/Torch/缓存/其他) | +| **RAM 内存监控** | 系统内存、进程内存、固定内存、Python 占用 | +| **GPU 状态** | 利用率、温度、功耗(NVIDIA 全支持 / AMD 需 LibreHardwareMonitor) | +| **CPU 状态** | CPU 使用率、型号识别 | +| **磁盘监控** | 磁盘读写速率、各分区剩余空间 | +| **页面文件** | 虚拟内存使用情况 | +| **模型详情** | 已加载模型列表、每个模型的 VRAM/内存分段占用 | +| **历史图表** | VRAM 使用量趋势图(面积图/柱状图/K线图/点阵图) | +| **快捷操作** | 一键卸载模型、清除缓存、执行/中断工作流 | + +### 界面截图说明 + +``` +┌─────────────────────────────────────────┐ +│ ≡ 硬件监控 [▶] [卸载▾] [⤤] [−] │ ← 标题栏(可拖拽) +├─────────────────────────────────────────┤ +│ 内存 32.5GB|64.0GB │ +│ ████████░░░░░░░░░░░░░░ 固定/已加载/... │ ← 内容区 +│ 显存 9.0GB|15.9GB │ +│ ██████████████░░░░░░░░ 模型/Torch/... │ +│ 峰值: 12.3GB 缓存: 1.2GB GPU 45% 55°C │ +│ 页面文件 2.1GB|16.0GB │ +├─────────────────────────────────────────┤ +│ 14:30 14:32 · 9.0GB 实时 │ ← 图表时间轴 +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ ← 历史图表 +│ ─────── ⬌ 拖拽调整图表高度 ─────── │ +├─────────────────────────────────────────┤ +│ ▸ AutoencodingEngine 320 MB │ ← 模型列表 +│ ▸ Lumina2 5.7 GB │ +│ ▸ ZImageT2IModel 4.1 GB │ +└─────────────────────────────────────────┘ +``` + +--- + +## 2. 安装与依赖 + +### 自动安装 + +面板**无需放置节点即可自动加载**。ComfyUI 启动后,监控窗口自动出现在界面右下角。 + +### 必备依赖 + +```bash +pip install psutil pythonnet +``` + +| 依赖 | 用途 | 必需 | +|------|------|------| +| `psutil` | CPU/内存/磁盘/进程信息 | ✅ 是 | +| `pythonnet` | .NET 桥接,驱动 LibreHardwareMonitor | ✅ 是 | +| `LibreHardwareMonitorLib.dll` | GPU 传感器读取(已内置) | ✅ 是 | +| `nvidia-ml-py` | NVIDIA GPU 精准显存数据 | ⚠️ 可选 | + +> `LibreHardwareMonitorLib.dll` 已内置在 `XB_ToolBox\` 目录中,无需额外下载。 + +### NVIDIA 用户(可选) + +```bash +pip install nvidia-ml-py +``` + +安装后可获得设备级显存数据(而非进程级),Windows WDDM 下尤其有用。 + +--- + +## 3. 快速上手 + +### 3.1 启动面板 + +1. 启动 ComfyUI +2. 面板**自动出现**在界面右下角 +3. 如果没有看到,检查 ComfyUI 控制台是否有报错 + +### 3.2 面板操作 + +| 操作 | 方法 | +|------|------| +| **移动面板** | 拖拽标题栏 | +| **调整大小** | 拖拽面板左/右/下边缘或右下角 | +| **折叠/展开** | 点击标题栏 `−` / `+` 按钮 | +| **弹出窗口** | 点击 `⤤` 按钮(画中画模式) | +| **运行工作流** | 点击 `▶` 按钮 | +| **取消运行** | 运行中点击 `■` 按钮 | +| **打开菜单** | 右键点击面板任意位置 | + +### 3.3 工作流节点(可选) + +你也可以将 `XB 📊 硬件监控面板` 节点拖入工作流: +- 节点本身**不产生任何输出** +- 执行节点时会在控制台打印当前 VRAM 状态 +- 主要用作工作流中的"书签"标记 + +--- + +## 4. 面板布局详解 + +### 4.1 标题栏 + +``` +≡ 硬件监控 [▶] [卸载▾] [⤤] [−] +``` + +| 元素 | 功能 | +|------|------| +| `≡` | 拖拽手柄(停靠模式下可见) | +| `硬件监控` | 面板标题 | +| `▶` / `■` | 执行/中断工作流按钮 | +| `卸载▾` | 卸载模型菜单 | +| `⤤` | 弹出画中画窗口 | +| `−` / `+` | 折叠/展开面板 | + +### 4.2 内容区 + +#### 内存行 +``` +内存 32.5GB|64.0GB +████████░░░░░░░░░░░░░░░░░░░░ +■ 固定 9.9GB ■ 已加载 0KB ■ Python 11.3GB ■ 其他 11.3GB +``` + +- **固定内存 (Pinned)**:用于 GPU 快速传输的锁页内存 +- **已加载 (Loaded)**:已从磁盘加载到 RAM 的模型数据 +- **Python**:Python 进程自身占用 +- **其他**:系统及其他进程占用 + +#### 显存行 +``` +显存 (AMD Radeon RX 7900 XTX) 9.0GB|15.9GB +████████████████░░░░░░░░░░░░░░░░ +■ 模型 5.7GB ■ Torch 2.1GB ■ 缓存 0.5GB ■ 其他 0.7GB +峰值: 12.3GB 缓存: 1.2GB GPU 45% 55°C 9W ● 运行中 +``` + +- **模型 (Models)**:已加载模型占用的 VRAM +- **Torch**:PyTorch 活跃分配 +- **缓存 (Cache)**:PyTorch 缓存池(可释放) +- **其他 (Other)**:驱动及其他进程占用 + +底部信息行: +| 项目 | 说明 | +|------|------| +| `峰值` | 自面板启动以来的 VRAM 最大使用量 | +| `缓存` | PyTorch 缓存池大小 | +| `GPU XX%` | GPU 核心利用率(点击可切换图表上的 GPU 曲线) | +| `XX°C` | GPU 温度 | +| `XX W` | GPU 功耗 | +| `● 运行中` / `● 空闲` | 工作流执行状态 | + +#### 页面文件行 +``` +页面文件 2.1GB|16.0GB +████████░░░░░░░░░░░░░░░░░░░░░░ +``` +仅当系统启用了页面文件时显示。 + +--- + +## 5. 右键菜单 + +右键点击面板任意位置打开配置菜单: + +``` +┌─────────────────────┐ +│ 缩放 ▸ │ 面板整体缩放 (50%~200%) +│ 字号 ▸ │ 字体大小 (10px~20px) +│ 轮询间隔 ▸ │ 数据刷新频率 (100ms~5s) +│ 显示 ▸ │ 显示选项 +│ 迷你视图 ▸ │ 折叠状态下的迷你栏选项 +│ 图表 ▸ │ 图表样式和颜色 +│ 主题 ▸ │ 配色方案 +│ 停靠宽度 ▸ │ 停靠到顶栏时的宽度 +│ ✓ 停靠到顶栏 │ 切换浮动/停靠模式 +│ 重置历史 │ 清除图表历史数据 +└─────────────────────┘ +``` + +### 5.1 缩放子菜单 + +| 选项 | 效果 | +|------|------| +| 50% ~ 200% | 面板整体缩放,包括所有文字和图表 | + +### 5.2 字号子菜单 + +| 选项 | 适用场景 | +|------|----------| +| 10px ~ 12px | 小屏幕 / 紧凑布局 | +| 13px ~ 14px | 正常阅读 | +| 16px ~ 20px | 大屏 / 远距离观看 | + +> 字号与缩放独立:字号只改变文字大小,缩放改变整个面板尺寸。 + +### 5.3 轮询间隔子菜单 + +| 选项 | 说明 | +|------|------| +| 100ms ~ 250ms | 极快刷新,CPU 开销较大 | +| **500ms** | 默认,平衡刷新率和性能 | +| 1s ~ 5s | 低频刷新,适合长时间监控 | + +### 5.4 显示子菜单 + +| 选项 | 默认 | 说明 | +|------|------|------| +| 模型条着色 | ☐ | 按模型类型着色进度条 | +| 模型边框着色 | ☑ | 按模型类型着色边框 | +| 模型名着色 | ☑ | 按模型类型着色名称 | +| 显示图例 | ☑ | 显示分段图例文字 | +| 显示标题 | ☑ | 显示面板标题 | +| 执行按钮 | ☐ | 显示播放/停止按钮 | + +### 5.5 迷你视图子菜单 + +控制面板折叠后迷你栏中显示哪些内容: + +| 选项 | 说明 | +|------|------| +| RAM | 内存进度条 | +| VRAM | 显存进度条 | +| CPU | CPU 使用率 | +| 页面文件 | 虚拟内存 | +| GPU ▸ | GPU 子菜单(利用率/温度/功耗) | +| 磁盘 ▸ | 磁盘子菜单(I/O + 各分区空间) | +| 设备名称 | 显示 GPU/CPU 型号 | +| 类型标签 | 显示 RAM/VRAM/CPU 标签 | +| 数值显示 | 显示具体数值 | +| 单位显示 | 显示 GB/MB/°C/W 单位 | +| 分隔线 | 显示分组分隔线 | + +### 5.6 图表子菜单 + +| 选项 | 说明 | +|------|------| +| 面积图 (堆叠) | 默认,aimdo+Torch+其他分层显示 | +| 柱状图 | 每3个采样点聚合为一个柱子 | +| K线图 | 类似股票K线,显示开盘/收盘/最高/最低 | +| 点阵 (渐变) | 由上到下渐变的点阵密度图 | +| 显存颜色 | 自定义 VRAM 区域颜色 | +| 总量颜色 | 自定义总量曲线颜色 | +| 平滑度 | 总量曲线的移动平均窗口大小 (0~20) | + +### 5.7 主题子菜单 + +| 主题 | 风格 | +|------|------| +| **default** | 深色主题,橙/绿/蓝配色 | +| comfy | 跟随 ComfyUI 自身主题 | +| light | 亮色主题 | +| sepia | 复古暖色调 | +| fallout | 哔哔小子辐射风(绿色 CRT) | +| pink | 粉色少女风 | +| lucifer | 黑底红字暗黑风 | + +--- + +## 6. 迷你栏 (Mini Bar) + +面板折叠后显示的精简状态栏: + +``` +RAM ████████░░░░ 32.5|64.0GB │ VRAM ██████░░░░ 9.0|15.9GB │ CPU ████░░ 45% +``` + +迷你栏中的 GPU 区域支持**多行模式**(同时显示利用率/温度/功耗时自动启用更紧凑的排版)。 + +--- + +## 7. 模型列表 + +展开面板后,底部显示所有已加载模型的详细信息: + +``` +▸ AutoencodingEngine 320 MB [×] + ████████████████████████████████████░░░░░░░░ + ■ VRAM 320MB ■ pinned 0KB ■ loaded 0KB ■ RAM 0KB + +▾ Lumina2 5.7 GB [×] + ██████████████████████████████████░░░░░░░░░░ + ■ VRAM 5.6GB ■ pinned 103MB ■ loaded 0KB ■ unloaded 4KB + + ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░ ← aimdo 页级驻留(需 aimdo) + ■ VRAM 5.6GB ■ unloaded 4KB +``` + +| 元素 | 说明 | +|------|------| +| `▸` / `▾` | 点击模型名展开/折叠详情 | +| `[×]` | 卸载该模型 | +| 彩色进度条 | VRAM(橙) / 固定内存(蓝) / 已加载(深蓝) / 未加载(灰) | +| 小方块网格 | aimdo 页面驻留可视化(需安装 comfy_aimdo) | +| `(static)` | 标记为非动态加载的模型 | + +--- + +## 8. 历史图表 + +图表区域显示 VRAM 使用量随时间变化的趋势: + +``` +14:30 14:32 · 9.0GB 实时 +▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ +─────────────────────── ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← 虚线 = 总显存上限 +────────────────⬌ 拖拽调整图表高度 ──────────────── +``` + +| 操作 | 方法 | +|------|------| +| 查看历史 | 在图表上**左右拖拽**浏览历史数据 | +| 回到实时 | 拖到最右侧自动恢复实时模式 | +| 悬停详情 | 鼠标悬停在图表上查看具体时间/数值 | +| 调整高度 | 拖拽图表下方的分隔条 | +| 执行标记 | 绿色竖线 = 开始执行,红色竖线 = 执行结束 | + +### 图表样式 + +| 样式 | 特点 | +|------|------| +| **面积图** | 堆叠显示 aimdo+Torch+其他,直观 | +| 柱状图 | 每3秒聚合,干净简洁 | +| K线图 | 精确显示波动范围 | +| 点阵图 | 由上到下密度渐变,艺术感强 | + +--- + +## 9. AMD GPU 用户须知 + +### 支持的 GPU + +| GPU 系列 | 架构 | 利用率 | 温度 | 功耗 | VRAM | +|----------|------|--------|------|------|------| +| RX 7000 系列 | RDNA 3 | ✅ | ✅ | ✅ | ✅ | +| RX 6000 系列 | RDNA 2 | ✅ | ✅ | ✅ | ✅ | +| RX 5000 系列 | RDNA 1 | ✅ | ✅ | ✅ | ✅ | +| Vega / Radeon VII | GCN | ✅ | ✅ | ✅ | ✅ | + +### 监控原理 + +AMD GPU 监控通过 **LibreHardwareMonitor** 实现(一个跨厂商的开源硬件监控库)。原理是直接读取 GPU 的 SMU(System Management Unit)传感器数据,与 AMD Adrenalin 驱动面板使用相同的底层接口。 + +### 如果数据不显示 + +1. 确认 `XB_ToolBox\LibreHardwareMonitorLib.dll` 存在 +2. 确认已安装 `pythonnet`:`pip install pythonnet` +3. 尝试**以管理员身份**运行 ComfyUI +4. 查看 ComfyUI 控制台是否有 `aimdo-viz: LibreHardwareMonitor` 相关日志 + +### 功耗显示的差异 + +AMD 功耗显示为 **GPU Package**(整个显卡的总功耗),单位是瓦特 (W),没有功率上限对比。这与 NVIDIA 的显示格式略有不同(NVIDIA 有 `功耗/上限` 格式)。 + +--- + +## 10. 常见问题 + +### Q: 面板没有自动出现? + +A: 检查 ComfyUI 控制台是否有 JavaScript 报错。常见原因: +- `pythonnet` 未安装 +- `LibreHardwareMonitorLib.dll` 缺失 +- JS 文件加载失败(检查 `js/xb_memory_viz.js` 是否存在) + +### Q: GPU 利用率/温度/功耗都显示"无数据"? + +A: +- **NVIDIA**:安装 `nvidia-ml-py` 并重启 +- **AMD**:确认 `pythonnet` 已安装,查看控制台日志 + +### Q: 面板太大/太小? + +A: 右键 → **缩放** 调整面板大小,或右键 → **字号** 仅调整文字大小。 + +### Q: 如何让面板固定在顶栏? + +A: 拖拽面板到顶部 ComfyUI 操作栏的"停靠左侧"或"停靠右侧"区域释放,或右键 → 停靠到顶栏。 + +### Q: 图表不更新? + +A: 检查轮询间隔设置(右键 → 轮询间隔)。确保未将面板拖拽到历史数据中(右侧显示"实时"即为实时模式)。 + +### Q: 节点放到工作流中有用吗? + +A: 面板**不需要**节点即可工作。节点的作用是: +- 作为工作流书签 +- 执行时在控制台打印 VRAM 状态 + +--- + +## 附录:键盘与鼠标操作速查 + +| 操作 | 快捷键/方法 | +|------|------------| +| 移动面板 | 拖拽标题栏 | +| 快速拖拽(任意位置) | `Ctrl` + 拖拽 | +| 调整宽度 | 拖拽左/右边缘 | +| 调整高度 | 拖拽底部边缘 | +| 同时调整宽高 | 拖拽右下角 | +| 折叠/展开 | 点击 `−` / `+` | +| 弹出画中画 | 点击 `⤤` | +| 打开菜单 | 右键面板 | +| 关闭菜单 | 点击菜单外部 | +| 浏览图表历史 | 在图表上左右拖拽 | +| 图表悬停详情 | 鼠标悬停图表 | +| 切换图表 GPU 曲线 | 点击 `GPU XX%` 文字 | +| 折叠模型详情 | 点击模型名称 | +| 卸载单个模型 | 点击模型行 `[×]` | diff --git a/js/xb_memory_viz.css b/js/xb_memory_viz.css new file mode 100644 index 0000000..69bd4cf --- /dev/null +++ b/js/xb_memory_viz.css @@ -0,0 +1,670 @@ +/* + aimdo memory visualization — static styles. + + JS still owns dynamic values (positions, widths, scale/zoom, drop-zone hover state). + All theme palettes live here, indexed by data-aimdo-theme on . JS toggles + that attribute and reads computed CSS variables back into a small C palette + object — needed because can't see CSS custom properties. +*/ + +/* ---------------- themes ---------------- + :root holds the default theme. Other themes override only what differs from + default — JS toggles them by writing data-aimdo-theme on . + fadeOutFrom / fadeOutTo are comma-separated RGB triplets parsed by JS for + the canvas fade animation; CSS itself doesn't consume them. +*/ +:root { + --aimdo-bg: #181818; + --aimdo-rowBg: #1f1f1f; + --aimdo-headerBg: #202020; + --aimdo-border: #2a2a2a; + --aimdo-btn: #2a2a2a; + --aimdo-btnText: #888; + --aimdo-text: #b0b0b0; + --aimdo-textDim: #707070; + --aimdo-running: #b0b0b0; + --aimdo-vram: #e67e22; + --aimdo-torch: #2ecc71; + --aimdo-pinned: #4a9eff; + --aimdo-loadedRam: #0b3d66; + --aimdo-unloaded: #3a3a3a; + --aimdo-torchCache: #1a7a3a; + --aimdo-python: #9b59b6; + --aimdo-other: #505050; + --aimdo-graphBg: #0e0e0e; + --aimdo-gridLine: #1e1e1e; + --aimdo-totalLine: #d0d0d0; + --aimdo-gpuUtil: #c9a227; + --aimdo-gpuUtilHi: #e74c3c; + --aimdo-capLine: #555; + --aimdo-barBg: #222; + --aimdo-fadeOutFrom: 200, 60, 60; + --aimdo-fadeOutTo: 58, 58, 58; + + /* model type colors (match LiteGraph slot colors) */ + --aimdo-type-model: #B39DDB; + --aimdo-type-vae: #FF6E6E; + --aimdo-type-clip: #FFD500; + --aimdo-type-clip_vision: #A8DADC; + --aimdo-type-controlnet: #76B900; + --aimdo-type-style_model: #C2FFAE; + --aimdo-type-gligen: #9B59B6; + --aimdo-type-upscale_model: #7AB1FF; +} + +[data-aimdo-theme="light"] { + --aimdo-bg: #f5f5f5; --aimdo-rowBg: #ebebeb; --aimdo-headerBg: #e2e2e2; + --aimdo-border: #a8a8a8; --aimdo-btn: #c8c8c8; --aimdo-btnText: #333; + --aimdo-text: #1a1a1a; --aimdo-textDim: #555; + --aimdo-graphBg: #fdfdfd; --aimdo-gridLine: #c8c8c8; --aimdo-barBg: #b8b8b8; + --aimdo-vram: #c2410c; --aimdo-torch: #15803d; --aimdo-pinned: #1d4ed8; + --aimdo-loadedRam: #7c9cc8; + --aimdo-unloaded: #888; --aimdo-torchCache: #166534; --aimdo-python: #6d28d9; + --aimdo-other: #666; + --aimdo-totalLine: #222; --aimdo-gpuUtil: #854d0e; --aimdo-gpuUtilHi: #b91c1c; + --aimdo-capLine: #555; --aimdo-running: #333; + --aimdo-fadeOutFrom: 180, 60, 60; --aimdo-fadeOutTo: 136, 136, 136; + + --aimdo-type-model: #7c3aed; --aimdo-type-vae: #dc2626; --aimdo-type-clip: #ca8a04; + --aimdo-type-clip_vision: #0891b2; --aimdo-type-controlnet: #15803d; + --aimdo-type-style_model: #22c55e; --aimdo-type-gligen: #9333ea; --aimdo-type-upscale_model: #1e40af; +} + +[data-aimdo-theme="sepia"] { + --aimdo-bg: #f4ecd8; --aimdo-rowBg: #ebe1c9; --aimdo-headerBg: #e0d4b6; + --aimdo-border: #b8a982; --aimdo-btn: #d6c89e; --aimdo-btnText: #6b5a3e; + --aimdo-text: #3a2f1e; --aimdo-textDim: #7a6a4e; + --aimdo-graphBg: #faf6e8; --aimdo-gridLine: #d8cba8; --aimdo-barBg: #c9b88a; + --aimdo-vram: #b8732e; --aimdo-torch: #6b8e23; --aimdo-pinned: #5a7a8c; + --aimdo-loadedRam: #3a5a6c; + --aimdo-unloaded: #c9b88a; --aimdo-torchCache: #4a6e23; --aimdo-python: #7a4a8a; + --aimdo-other: #8a7a5a; + --aimdo-totalLine: #4a3a2a; --aimdo-gpuUtil: #8a6818; --aimdo-gpuUtilHi: #a83a2a; + --aimdo-capLine: #8a7a5a; --aimdo-running: #5a4a3a; + --aimdo-fadeOutFrom: 168, 88, 68; --aimdo-fadeOutTo: 201, 184, 138; + + --aimdo-type-model: #7a5e8a; --aimdo-type-vae: #b03030; --aimdo-type-clip: #a87818; + --aimdo-type-clip_vision: #5a8a8a; --aimdo-type-controlnet: #6b8e23; + --aimdo-type-style_model: #7a9a5a; --aimdo-type-gligen: #7a4a8a; --aimdo-type-upscale_model: #5a7a9a; +} + +/* Pip-Boy CRT — green screen housed in metallic gray-brown chrome */ +[data-aimdo-theme="fallout"] { + --aimdo-bg: #0a1a0a; --aimdo-rowBg: #0f2510; --aimdo-headerBg: #2a2520; + --aimdo-border: #5a4a3a; --aimdo-btn: #3a3530; --aimdo-btnText: #c8b89a; + --aimdo-text: #7fff7f; --aimdo-textDim: #3a8a3a; + --aimdo-graphBg: #051005; --aimdo-gridLine: #1a4a1a; --aimdo-barBg: #0f2510; + --aimdo-vram: #88ff66; --aimdo-torch: #ccff44; --aimdo-pinned: #44ffcc; + --aimdo-loadedRam: #2a7a5a; + --aimdo-unloaded: #1a4a1a; --aimdo-torchCache: #5a9a3a; --aimdo-python: #aaff88; + --aimdo-other: #2a4a2a; + --aimdo-totalLine: #7fff7f; --aimdo-gpuUtil: #ffcc44; --aimdo-gpuUtilHi: #ff5555; + --aimdo-capLine: #3a8a3a; --aimdo-running: #88ff66; + --aimdo-fadeOutFrom: 220, 80, 80; --aimdo-fadeOutTo: 26, 74, 26; + + --aimdo-type-model: #aaff88; --aimdo-type-vae: #ff8866; --aimdo-type-clip: #ffdd44; + --aimdo-type-clip_vision: #88ffdd; --aimdo-type-controlnet: #aaff44; + --aimdo-type-style_model: #ccffaa; --aimdo-type-gligen: #ff88cc; --aimdo-type-upscale_model: #66ddff; +} + +/* pastel pink with mint/lavender accents so data colors stay distinguishable */ +[data-aimdo-theme="pink"] { + --aimdo-bg: #fff0f5; --aimdo-rowBg: #ffeaf2; --aimdo-headerBg: #ffd6e8; + --aimdo-border: #e8a4c4; --aimdo-btn: #ffc4d6; --aimdo-btnText: #7a3a5a; + --aimdo-text: #4a1f3a; --aimdo-textDim: #8a4a6a; + --aimdo-graphBg: #fff5fa; --aimdo-gridLine: #f5cadc; --aimdo-barBg: #f5c0d4; + --aimdo-vram: #e91e63; --aimdo-torch: #10a37f; --aimdo-pinned: #a78bfa; + --aimdo-loadedRam: #7a5ec0; + --aimdo-unloaded: #d8b4c0; --aimdo-torchCache: #0f8a6a; --aimdo-python: #7c3aed; + --aimdo-other: #a878a0; + --aimdo-totalLine: #4a1f3a; --aimdo-gpuUtil: #e0a04a; --aimdo-gpuUtilHi: #d32f5e; + --aimdo-capLine: #b67a8a; --aimdo-running: #5a2a4a; + --aimdo-fadeOutFrom: 220, 80, 110; --aimdo-fadeOutTo: 216, 180, 192; + + --aimdo-type-model: #e91e63; --aimdo-type-vae: #ef4444; --aimdo-type-clip: #f59e0b; + --aimdo-type-clip_vision: #0ea5a4; --aimdo-type-controlnet: #65a30d; + --aimdo-type-style_model: #f472b6; --aimdo-type-gligen: #a855f7; --aimdo-type-upscale_model: #3b82f6; +} + +/* Inherits ComfyUI's design tokens via var() so the panel adopts whatever + palette the host is in (light/dark mode is read live through the cascade). + --aimdo-type-* and --aimdo-fadeOut* are intentionally omitted — the + LiteGraph slot hues and fade RGB triplets from :root carry through. */ +[data-aimdo-theme="comfy"] { + --aimdo-bg: var(--comfy-menu-bg); + --aimdo-rowBg: var(--comfy-menu-secondary-bg); + --aimdo-headerBg: var(--comfy-menu-bg); + --aimdo-border: var(--border-color); + --aimdo-btn: var(--comfy-input-bg); + --aimdo-btnText: var(--input-text); + --aimdo-text: var(--input-text); + --aimdo-textDim: var(--descrip-text); + --aimdo-running: var(--input-text); + --aimdo-graphBg: var(--comfy-menu-secondary-bg); + --aimdo-gridLine: var(--border-color); + --aimdo-barBg: var(--comfy-input-bg); + --aimdo-totalLine: var(--fg-color); + --aimdo-capLine: var(--border-color); + + --aimdo-vram: var(--color-gold-500); + --aimdo-torch: var(--color-jade-600); + --aimdo-pinned: var(--color-azure-400); + --aimdo-loadedRam: var(--color-cobalt-800); + --aimdo-unloaded: var(--color-ash-800); + --aimdo-torchCache: var(--color-jade-600); + --aimdo-python: var(--color-magenta-700); + --aimdo-other: var(--color-neutral-550); + --aimdo-gpuUtil: var(--color-gold-500); + --aimdo-gpuUtilHi: var(--color-coral-500); + + /* Neutral fade endpoints chosen to read OK against both light and dark + Comfy backgrounds — the dark-tuned :root defaults would streak black + on light mode. */ + --aimdo-fadeOutFrom: 200, 100, 80; + --aimdo-fadeOutTo: 128, 128, 128; +} + +/* CUSTOM THEME: Lucifer - Black and Red */ +[data-aimdo-theme="lucifer"] { + --aimdo-bg: #000000; + --aimdo-rowBg: #050000; + --aimdo-headerBg: #000000; + --aimdo-border: #FF0000; + --aimdo-btn: #110000; + --aimdo-btnText: #FF0000; + --aimdo-text: #ffffff; + --aimdo-textDim: #880000; + --aimdo-running: #ffffff; + --aimdo-vram: #FF0000; + --aimdo-torch: #CC0000; + --aimdo-pinned: #880000; + --aimdo-loadedRam: #440000; + --aimdo-unloaded: #222222; + --aimdo-torchCache: #660000; + --aimdo-python: #FF0000; + --aimdo-other: #444444; + --aimdo-graphBg: #000000; + --aimdo-gridLine: #220000; + --aimdo-totalLine: #ffffff; + --aimdo-gpuUtil: #FF0000; + --aimdo-gpuUtilHi: #FF5555; + --aimdo-capLine: #FF0000; + --aimdo-barBg: #110000; + --aimdo-fadeOutFrom: 255, 0, 0; + --aimdo-fadeOutTo: 0, 0, 0; + + /* model type colors — all red to match the Obsidian Redline aesthetic */ + --aimdo-type-model: #FF0000; + --aimdo-type-vae: #FF0000; + --aimdo-type-clip: #FF0000; + --aimdo-type-clip_vision: #FF0000; + --aimdo-type-controlnet: #FF0000; + --aimdo-type-style_model: #FF0000; + --aimdo-type-gligen: #FF0000; + --aimdo-type-upscale_model: #FF0000; +} + +/* ---------------- panel ---------------- */ +#aimdo-viz-panel { + position: fixed; + background: var(--aimdo-bg); + color: var(--aimdo-text); + border: 1px solid var(--aimdo-border); + border-radius: 8px; + padding: 0; + font-family: monospace; + font-size: var(--aimdo-font-size, 12px); + z-index: 50; + min-width: 200px; + width: 340px; + max-width: 100vw; + box-shadow: 0 4px 12px rgba(0,0,0,0.7); + user-select: none; + display: flex; + flex-direction: column; +} + +/* docked: flatten the panel into a horizontal row that fits the topbar actionbar */ +#aimdo-viz-panel.aimdo-docked { + position: static !important; + left: auto !important; top: auto !important; right: auto !important; bottom: auto !important; + width: auto !important; height: auto !important; + min-width: 0 !important; max-width: none !important; max-height: none !important; + border: none !important; border-radius: 0 !important; + box-shadow: none !important; background: transparent !important; + flex-direction: row !important; + align-items: center; + zoom: 1 !important; + transform: none !important; +} + +/* ---------------- header ---------------- */ +.aimdo-header { + display: flex; justify-content: space-between; align-items: center; + padding: 6px 10px; + background: var(--aimdo-headerBg); + border-radius: 8px 8px 0 0; + cursor: move; +} +.aimdo-title { + font-weight: bold; + color: var(--aimdo-text); + white-space: nowrap; +} +/* 2x3 circular-dot drag handle — matches the grip icon on ComfyUI's docked actionbar. + Only shown when docked; floating mode lets the entire header act as the drag surface. + The shape is an SVG mask painted by background-color: border-radius can't anti-alias + a 2px circle reliably, and a radial-gradient ends up chunkier than what Chrome draws + for true SVG primitives. SVG renders the same crisp dots in both browsers. */ +.aimdo-drag-handle { + display: none; + box-sizing: content-box; + width: 6px; + height: 10px; + padding: 0 4px; + background-color: var(--aimdo-textDim); + -webkit-mask-image: url("data:image/svg+xml,"); + mask-image: url("data:image/svg+xml,"); + -webkit-mask-size: 6px 10px; + mask-size: 6px 10px; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-origin: content-box; + mask-origin: content-box; + cursor: grab; + touch-action: none; + flex-shrink: 0; + align-self: center; +} +#aimdo-viz-panel.aimdo-docked .aimdo-drag-handle { + display: inline-block; + padding: 0 8px; +} +.aimdo-drag-handle:active { cursor: grabbing; } +.aimdo-drag-handle:hover { background-color: var(--aimdo-text); } +.aimdo-header-right { + display: flex; align-items: center; gap: 6px; + margin-left: auto; +} +#aimdo-viz-panel.aimdo-docked .aimdo-header { + padding: 2px 8px 2px 0 !important; + background: transparent !important; + border-radius: 0 !important; + flex-shrink: 0; + cursor: default; +} + +/* ---------------- buttons ---------------- */ +.aimdo-btn { + cursor: pointer; + font-size: 0.833em; + line-height: 1; + padding: 1px 6px; + background: var(--aimdo-btn); + border-radius: 3px; + color: var(--aimdo-btnText); + white-space: nowrap; +} +.aimdo-btn-icon { + cursor: pointer; + color: var(--aimdo-btnText); + padding: 0 4px; +} +#aimdo-viz-panel.aimdo-docked .aimdo-btn { + font-size: 0.917em; + padding: 3px 6px; +} + +/* play / cancel-running button — same shape as other header buttons, but the icon + carries the state color (green play, red stop). Hover lifts the background. */ +.aimdo-exec-btn { + cursor: pointer; + user-select: none; + display: inline-block; + text-align: center; + min-width: 1em; /* keeps width stable across ┬╗ / ┬á */ + font-size: 0.833em; + line-height: 1; + padding: 2px 6px; + background: var(--aimdo-btn); + border-radius: 3px; + color: var(--aimdo-torch); + white-space: nowrap; + transition: filter 0.1s; +} +.aimdo-exec-btn:hover { filter: brightness(1.4); } +.aimdo-exec-btn.is-running { color: var(--aimdo-gpuUtilHi); } +#aimdo-viz-panel.aimdo-docked .aimdo-exec-btn { + font-size: 1em; + padding: 2.5px 10px; +} + +/* ---------------- mini-bar ---------------- */ +.aimdo-mini-bar { + display: none; + padding: 4px 10px 6px; + font-size: 0.833em; + color: var(--aimdo-textDim); +} +.aimdo-mini-row { + display: flex; justify-content: space-between; gap: 6px; margin-bottom: 2px; +} +.aimdo-mini-track { + background: var(--aimdo-barBg); + border-radius: 2px; + height: 4px; + overflow: hidden; + display: flex; + margin-bottom: 4px; +} +.aimdo-mini-track-last { + margin-bottom: 0; +} +/* GPU section: one row per metric — bar + value on the same line. The compact 8px + font + 3px bar kicks in only when temp/power are also visible (the .is-multibar + class); with only util showing, the row keeps the regular mini-bar styling so it + matches RAM/VRAM/CPU above. */ +.aimdo-mini-inline { + display: flex; + align-items: center; + gap: 4px; +} +.mini-gpu-section.is-multibar .aimdo-mini-inline, +.mini-disk-section.is-multibar .aimdo-mini-inline { + line-height: 1; + font-size: 0.667em; +} +.mini-gpu-section.is-multibar .aimdo-mini-inline .aimdo-mini-track, +.mini-disk-section.is-multibar .aimdo-mini-inline .aimdo-mini-track { height: 3px; } +.aimdo-mini-inline .aimdo-mini-track { + flex: 1 1 0; + margin-bottom: 0; +} +.aimdo-mini-inline > .mini-gpu-usage, +.aimdo-mini-inline > .mini-temp-usage, +.aimdo-mini-inline > .mini-power-usage, +.aimdo-mini-inline > .mini-disk-read-usage, +.aimdo-mini-inline > .mini-disk-write-usage { + flex-shrink: 0; + text-align: right; + min-width: 32px; +} +/* push the header value to the right even when the GPU label is hidden */ +.mini-gpu-header-value { + margin-left: auto; +} +/* single-bar mode: hide the inline value next to the bar — the value lives in + the title row instead so the layout matches RAM/VRAM/CPU above. */ +.mini-gpu-section:not(.is-multibar) .aimdo-mini-inline > .mini-gpu-usage, +.mini-gpu-section:not(.is-multibar) .aimdo-mini-inline > .mini-temp-usage, +.mini-gpu-section:not(.is-multibar) .aimdo-mini-inline > .mini-power-usage { + display: none; +} +.aimdo-mini-fill { + height: 100%; + width: 0; + transition: width 0.35s ease-out, background 0.35s ease-out; +} +.mini-disk-read-fill { background: var(--aimdo-pinned); } +.mini-disk-write-fill { background: var(--aimdo-vram); } +.mini-faults-fill { background: var(--aimdo-gpuUtil); } + +/* Optional group dividers: a thin line between logical groups (memory, + storage, compute). The .mini-group-start class marks the first section + of each non-leading group. Sections of the same group sit flush; if every + section in a group is hidden the divider still lands on the next visible + group-start (acceptable, matches the leading-edge quirk elsewhere). */ +.aimdo-mini-bar.has-separators > .mini-group-start { + border-top: 1px solid var(--aimdo-border); + padding-top: 10px; + margin-top: 10px; +} +#aimdo-viz-panel.aimdo-docked .aimdo-mini-bar.has-separators > .mini-group-start { + border-top: 0; + padding-top: 0; + margin-top: 0; + border-left: 1px solid var(--aimdo-border); + padding-left: 16px; + margin-left: 6px; +} + +/* free disk space: one row per selected drive, mountpoint left, bar middle, "free" right. + Mountpoint column is fixed-width so multiple drives align vertically. */ +.mini-diskspace-section.is-multibar .aimdo-mini-inline { line-height: 1; font-size: 0.667em; } +.mini-diskspace-section.is-multibar .aimdo-mini-inline .aimdo-mini-track { height: 3px; } +.mini-diskspace-drive-row > .mini-diskspace-mp { + flex-shrink: 0; + text-align: left; + min-width: 18px; + max-width: 36px; + overflow: hidden; + text-overflow: ellipsis; +} +.mini-diskspace-drive-row > .mini-diskspace-usage { + flex-shrink: 0; + text-align: right; + min-width: 32px; +} +.mini-diskspace-section.no-units .mini-diskspace-drive-row > .mini-diskspace-usage { + min-width: 18px; +} + +/* docked mini-bar: horizontal, fixed-width sections */ +#aimdo-viz-panel.aimdo-docked .aimdo-mini-bar { + display: flex !important; flex-direction: row !important; + padding: 0 !important; gap: 12px; align-items: center; + flex: 1 1 auto; min-width: 0; + font-size: 0.75em; +} +#aimdo-viz-panel.aimdo-docked .aimdo-mini-row { + flex-wrap: wrap; + gap: 0 6px; + margin-bottom: 3px; +} +#aimdo-viz-panel.aimdo-docked .aimdo-mini-row > span { + flex-shrink: 0; + white-space: nowrap; +} +#aimdo-viz-panel.aimdo-docked .aimdo-mini-bar > * { + width: var(--aimdo-dock-section-w, 110px); + min-width: var(--aimdo-dock-section-w, 110px); + flex: 0 0 auto; + overflow: hidden; +} + +/* ---------------- body ---------------- */ +#aimdo-viz-body { + padding: 8px 10px; + flex: 1 1 auto; + min-height: 120px; + display: flex; + flex-direction: column; + overflow: hidden; +} +#aimdo-viz-panel.aimdo-docked #aimdo-viz-body { display: none !important; } +#aimdo-viz-panel.aimdo-docked.aimdo-docked-expanded #aimdo-viz-body { + display: flex !important; + position: fixed !important; + z-index: 1500; + width: 420px; + max-height: 60vh; + background: var(--aimdo-bg); + color: var(--aimdo-text); + border: 1px solid var(--aimdo-border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.7); +} + +/* ---------------- bar segments (mini-bar + main content rows) ---------------- */ +.aimdo-seg { height: 100%; } +.aimdo-seg-vram { background: var(--aimdo-vram); } +.aimdo-seg-torch { background: var(--aimdo-torch); } +.aimdo-seg-torchCache { background: var(--aimdo-torchCache); } +.aimdo-seg-pinned { background: var(--aimdo-pinned); } +.aimdo-seg-loadedRam { background: var(--aimdo-loadedRam); } +.aimdo-seg-python { background: var(--aimdo-python); } +.aimdo-seg-other { background: var(--aimdo-other); } +.aimdo-seg-unloaded { background: var(--aimdo-unloaded); } + +/* ---------------- model rows (scroll area) ---------------- */ +#aimdo-models { scrollbar-width: thin; scrollbar-color: var(--aimdo-btn) transparent; } +#aimdo-models::-webkit-scrollbar { width: 8px; } +#aimdo-models::-webkit-scrollbar-track { background: transparent; } +#aimdo-models::-webkit-scrollbar-thumb { background: var(--aimdo-btn); border-radius: 4px; } +#aimdo-models::-webkit-scrollbar-thumb:hover { background: var(--aimdo-textDim); } + +.aimdo-model-row { + margin-top: 6px; + background: var(--aimdo-rowBg); + /* transparent border reserves space so the row height stays constant when the colored stroke toggles */ + border: 1px solid transparent; + border-radius: 4px; + padding: 6px 8px; +} +.aimdo-model-head { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 2px; +} +.aimdo-model-name { + cursor: pointer; user-select: none; + padding: 1px 4px; margin: -1px -4px; + border-radius: 3px; + transition: background 0.1s; +} +.aimdo-model-name:hover { background: var(--aimdo-btn); } +.aimdo-model-chevron { + color: var(--aimdo-textDim); + margin-right: 4px; + font-size: 0.75em; +} +.aimdo-model-right { + display: flex; align-items: center; gap: 6px; +} +.aimdo-model-bar { + background: var(--aimdo-barBg); + border-radius: 3px; + height: 10px; + overflow: hidden; + display: flex; +} +.aimdo-model-legend { + display: flex; gap: 8px; + font-size: 0.833em; + color: var(--aimdo-textDim); + margin-top: 2px; +} +.aimdo-unload-btn, .aimdo-reset-wm-btn { + cursor: pointer; + font-size: 0.75em; + padding: 0px 4px; + background: var(--aimdo-btn); + border-radius: 2px; + color: var(--aimdo-btnText); +} + +.aimdo-graph-canvas { cursor: crosshair; } + +/* graph-height resize bar — highlights on hover and stays lit while dragging */ +.aimdo-graph-resize { + height: 4px; cursor: ns-resize; + flex-shrink: 0; margin: 1px 0; +} +.aimdo-graph-resize:hover, +.aimdo-graph-resize.is-dragging { background: var(--aimdo-btn); } + +/* ---------------- menus (root + submenus + unload menu) ---------------- */ +.aimdo-menu { + display: none; + position: fixed; + z-index: 1500; + background: var(--aimdo-headerBg); + color: var(--aimdo-text); + border: 1px solid var(--aimdo-border); + border-radius: 4px; + padding: 2px 0; + min-width: 120px; + box-shadow: 0 4px 12px rgba(0,0,0,0.7); + font-family: monospace; + font-size: 0.833em; +} +.aimdo-menu-item { + padding: 4px 10px; + cursor: pointer; + white-space: nowrap; +} +.aimdo-menu-item:hover { + background: var(--aimdo-btn); +} +.aimdo-menu-item.is-disabled { + opacity: 0.4; + cursor: default; +} +.aimdo-menu-header { + padding: 4px 10px 2px; + color: var(--aimdo-textDim); + font-size: 0.75em; + text-transform: uppercase; + letter-spacing: 0.5px; + border-top: 1px solid var(--aimdo-border); + margin-top: 2px; + cursor: default; +} +.aimdo-menu-item.is-disabled:hover { + background: transparent; +} +.aimdo-menu-parent { + padding: 4px 10px; cursor: default; white-space: nowrap; + display: flex; justify-content: space-between; gap: 12px; +} +.aimdo-menu-parent:hover { + background: var(--aimdo-btn); +} +.aimdo-chevron { color: var(--aimdo-textDim); } +.aimdo-check { + display: inline-block; + width: 12px; + text-align: center; +} + +/* ---------------- resize handles ---------------- */ +.aimdo-edge-handle { position: absolute; z-index: 1; } +.aimdo-edge-left { top: 28px; bottom: 0; left: 0; width: 4px; cursor: ew-resize; } +.aimdo-edge-right { top: 28px; bottom: 0; right: 0; width: 4px; cursor: ew-resize; } +.aimdo-edge-bottom { left: 4px; right: 4px; bottom: 0; height: 4px; cursor: ns-resize; } +.aimdo-corner-handle { + position: absolute; right: 0; bottom: 0; + width: 8px; height: 8px; cursor: nwse-resize; z-index: 2; + background: linear-gradient(135deg, + transparent 50%, + var(--aimdo-textDim) 50%, var(--aimdo-textDim) 60%, + transparent 60%, transparent 75%, + var(--aimdo-textDim) 75%, var(--aimdo-textDim) 85%, + transparent 85%); + border-bottom-right-radius: 4px; + opacity: 0.55; +} +.aimdo-corner-handle:hover { opacity: 1; } +#aimdo-viz-panel.aimdo-docked .aimdo-edge-handle, +#aimdo-viz-panel.aimdo-docked .aimdo-corner-handle { display: none !important; } + +/* ---------------- drop zone (shown during a drag toward the topbar) ---------------- */ +.aimdo-dropzone { + position: relative; z-index: 1501; + align-self: stretch; margin: 6px; + padding: 0 12px; + display: flex; align-items: center; justify-content: center; + font-family: monospace; font-size: 0.917em; + border: 2px dashed #3b82f6; border-radius: 6px; + background: rgba(59,130,246,0.4); color: #fff; + pointer-events: auto; min-width: 160px; + opacity: 0.9; + transition: transform 0.12s, opacity 0.12s, box-shadow 0.12s, border-width 0.12s; +} +.aimdo-dropzone-left { order: -2; } +.aimdo-dropzone-right { order: 2; } +.aimdo-dropzone.is-hover { + opacity: 1; transform: scale(1.04); + border-width: 3px; box-shadow: 0 0 20px rgba(59,130,246,0.45); +} diff --git a/js/xb_memory_viz.js b/js/xb_memory_viz.js new file mode 100644 index 0000000..8b7bc14 --- /dev/null +++ b/js/xb_memory_viz.js @@ -0,0 +1,3158 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; +import { + GRAPH_POINTS, HISTORY_TICK_MS, + history, graphHover, graphState, + pushHistory, pushExecEvent, historyGet, smoothGpuUtil, + loadHistory, saveHistory, clearHistoryStorage, + loadGraphSavedState, resetHistoryState, + drawGraph, buildGraphSubmenu, initGraph, +} from "./xb_memory_viz_graph.js"; + +let pollInterval = 500; +const FADE_TICKS = 6; + +const execState = { running: false, node: null, progress: null }; +let peakVramUsed = 0; +let modelCollapsed = {}; +let colorModelBars = false; +let colorModelStroke = true; +let colorModelName = true; +let showLegends = true; +let showRamInMini = true; +let showVramInMini = true; +let showGpuInMini = true; +let showCpuInMini = true; +let showPagefileInMini = false; +let showDiskInMini = false; +const DISK_PEAK_FLOOR = 50 * 1024 * 1024; // 50 MB/s — small enough idle activity is visible +const DISK_PEAK_HALFLIFE = 30; // seconds — peak decays back toward floor when quiet +const diskState = { + prevRead: null, prevWrite: null, prevTime: 0, + peakRead: DISK_PEAK_FLOOR, peakWrite: DISK_PEAK_FLOOR, +}; +let showFaultsInMini = false; +const FAULT_PEAK_FLOOR = 50; // faults/s — keeps light activity visible +const FAULT_PEAK_HALFLIFE = 30; +const faultState = { prev: null, prevTime: 0, peak: FAULT_PEAK_FLOOR }; +// Free-disk-space monitor — array of selected mountpoints (e.g. ["C:\\","D:\\"]). +// allDisksCache holds the most recent full enumeration so the tooltip can list +// every fixed drive even when only some are pinned to the bar. +let selectedDisks = []; +let allDisksCache = []; +let wantDisksList = false; // one-shot — set when settings menu wants a fresh drive list +let _rebuildDriveMenu = null; // assigned by createPanel; called from renderData when allDisksCache refreshes +let showHwNames = true; +let showTitle = true; +let showExecBtn = false; // optional play / cancel-running button in the header +let miniShowNumbers = true; +let miniShowUnits = true; +let miniShowType = true; +let miniShowGpuTemp = true; +let miniShowGpuPower = true; +let miniShowSeparators = true; +let graphHeight = 80; +let currentTheme = "default"; +let fontSize = 12; // 面板基础字号 (px) +let lang = "zh"; // 界面语言: "zh" | "en" + +// ── 中英文翻译映射表 ── +const L = { + zh: { + title: "硬件监控", titleAimdo: "硬件监控 (aimdo)", + unload: "卸载 ▾", unloadTitle: "卸载模型 / 释放缓存 (点击查看选项)", + popout: "弹出到画中画窗口", collapse: "折叠 / 展开面板", + cancelRun: "取消正在运行的工作流", runWorkflow: "运行工作流", + dragMove: "拖拽移动 (或拖到顶部停靠)", dragResize: "拖拽调整大小", + dragResizeGraph: "拖拽调整图表高度", + ram: "内存", vram: "显存", pagefile: "页面文件", faults: "硬缺页", + disk: "磁盘", diskSpace: "磁盘空间", + noGpu: "不可用 (GPU未检测到)", fetchError: "数据获取失败", + peak: "峰值", cache: "缓存", running: "运行中", idle: "空闲", + noModels: "暂无已加载模型", clickCollapse: "点击折叠/展开", + unloadModel: "卸载此模型", pinned: "固定", loaded: "已加载", + unloaded: "未加载", ramLabel: "内存", static_: "静态", + pinnedRAM: "固定内存", loadedRAM: "已加载内存", vramLabel: "显存", + ramTooltip: "内存", wm: "水位", wmTitle: "重置水位线", + na: "无数据", live: "实时", + models: "模型", torch: "PyTorch", other: "其他", python: "Python", + total: "总量", gpuPct: "GPU利用率", + pageVRAM: "页显存", pageUnloaded: "页未加载", + vramColor: "显存颜色", totalColor: "总量颜色", smoothness: "平滑度", + reset: "重置", + graphArea: "面积图 (堆叠)", graphBars: "柱状图", graphTicker: "K线图", graphDots: "点阵 (渐变)", + menuScale: "缩放", menuFontSize: "字号", menuPoll: "轮询间隔", + menuDisplay: "显示", menuMini: "迷你视图", menuGraph: "图表", + menuTheme: "主题", menuDockWidth: "停靠宽度", menuLang: "语言", + dockSectionW: "区域宽度", dockLeft: "停靠左侧", dockRight: "停靠右侧", + undock: "取消停靠 (浮动)", dockTop: "停靠到顶栏", + resetHistory: "重置历史", resetHistoryTitle: "重置峰值显存标记并清除历史图表", + toggleColorBars: "模型条着色", toggleColorStroke: "模型边框着色", + toggleColorName: "模型名着色", toggleShowLegends: "显示图例", + toggleShowTitle: "显示标题", toggleExecBtn: "执行按钮", + toggleRam: "内存", toggleVram: "显存", togglePagefile: "页面文件", + toggleIO: "I/O", toggleFaults: "页面抖动 (硬缺页)", + toggleUtil: "利用率", toggleNames: "设备名称", + toggleNumbers: "数值显示", toggleUnits: "单位显示", + toggleType: "类型标签", toggleTemp: "温度", togglePower: "功耗", + toggleSeparators: "分隔线", toggleDisk: "磁盘", drives: "磁盘驱动器", + detecting: "检测中…", + unloadAimdo: "aimdo (立即卸载)", unloadAimdoTitle: "立即卸载 aimdo 管理的模型", + unloadModels: "模型", unloadModelsTitle: "ComfyUI /free — 下次队列时卸载模型", + unloadCache: "模型 + 节点缓存", unloadCacheTitle: "ComfyUI /free — 卸载模型并清除节点输出缓存", + diskUnavailable: "不可用", diskUsed: "已用", diskFree: "剩余", + gpuPowerTitle: "GPU功耗 / 上限", gpuLineTitle: "点击切换图表上的GPU曲线", + langZh: "中文", langEn: "English", + }, + en: { + title: "Memory", titleAimdo: "Memory (aimdo)", + unload: "unload ▾", unloadTitle: "Unload models / free cache (click for options)", + popout: "Pop out into a Picture-in-Picture window", collapse: "Collapse / expand panel", + cancelRun: "Cancel running workflow", runWorkflow: "Run workflow", + dragMove: "Drag to move (or to dock at the top)", dragResize: "Drag to resize", + dragResizeGraph: "Drag to resize graph", + ram: "RAM", vram: "VRAM", pagefile: "Pagefile", faults: "Faults", + disk: "Disk", diskSpace: "Disk space", + noGpu: "Not available (GPU not detected)", fetchError: "Error fetching data", + peak: "peak", cache: "cache", running: "running", idle: "idle", + noModels: "No models loaded", clickCollapse: "Click to collapse/expand", + unloadModel: "Unload this model", pinned: "pinned", loaded: "loaded", + unloaded: "unloaded", ramLabel: "RAM", static_: "static", + pinnedRAM: "pinned RAM", loadedRAM: "loaded RAM", vramLabel: "VRAM", + ramTooltip: "RAM", wm: "wm", wmTitle: "reset watermark", + na: "N/A", live: "live", + models: "models", torch: "torch", other: "other", python: "python", + total: "total", gpuPct: "GPU %", + pageVRAM: "VRAM", pageUnloaded: "unloaded", + vramColor: "VRAM color", totalColor: "Total color", smoothness: "Total smoothness", + reset: "reset", + graphArea: "Area (stacked)", graphBars: "Bars", graphTicker: "Ticker", graphDots: "Dots (fade)", + menuScale: "Scale", menuFontSize: "Font size", menuPoll: "Polling interval", + menuDisplay: "Display", menuMini: "Mini view", menuGraph: "Graph", + menuTheme: "Theme", menuDockWidth: "Dock width", menuLang: "Language", + dockSectionW: "Section width", dockLeft: "Dock left", dockRight: "Dock right", + undock: "Undock to floating", dockTop: "Dock to top", + resetHistory: "Reset history", resetHistoryTitle: "Reset peak VRAM marker and clear history graph", + toggleColorBars: "Color model bars", toggleColorStroke: "Color model stroke", + toggleColorName: "Color model name", toggleShowLegends: "Show legends", + toggleShowTitle: "Show title", toggleExecBtn: "Execute button", + toggleRam: "RAM", toggleVram: "VRAM", togglePagefile: "Pagefile", + toggleIO: "I/O", toggleFaults: "Thrashing (hard faults)", + toggleUtil: "util", toggleNames: "Device names", + toggleNumbers: "Numbers", toggleUnits: "Units", + toggleType: "Type labels", toggleTemp: "temp", togglePower: "power", + toggleSeparators: "Separators", toggleDisk: "Disk", drives: "Drives", + detecting: "detecting…", + unloadAimdo: "aimdo (immediate)", unloadAimdoTitle: "Immediately unload aimdo-managed models", + unloadModels: "models", unloadModelsTitle: "ComfyUI /free — unload models on next queue tick", + unloadCache: "models + node cache", unloadCacheTitle: "ComfyUI /free — unload models and clear node output cache", + diskUnavailable: "unavailable", diskUsed: "used", diskFree: "free", + gpuPowerTitle: "GPU power draw / cap", gpuLineTitle: "Click to toggle GPU line on graph", + langZh: "中文", langEn: "English", + } +}; + +function _t(key) { return (L[lang] && L[lang][key]) || key; } + +const STORAGE_KEY = "aimdo_viz_state"; +function loadState() { + try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; } + catch { return {}; } +} +function saveState(patch) { + const s = loadState(); + Object.assign(s, patch); + localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); +} + +// Themes live in aimdo_viz.css as `[data-aimdo-theme=""]` blocks layered +// over a :root default. Selecting a theme is just setting that attribute on +// — CSS does the rest. JS keeps a parallel `C` palette object only +// because can't read CSS variables; canvas rendering reads hex/rgb +// strings from C, which we refresh from computed CSS on each theme switch. +const THEME_NAMES = ["default", "comfy", "light", "sepia", "fallout", "pink", "lucifer"]; + +// Keys whose values are color strings the canvas can read directly. fadeOutFrom / +// fadeOutTo are stored as comma-separated RGB triplets in CSS and parsed into +// [r,g,b] arrays for the canvas fade animation. +const PALETTE_KEYS = Object.freeze([ + "vram","torch","pinned","loadedRam","unloaded","torchCache","python","other", + "text","textDim","running","bg","rowBg","headerBg","border","btn","btnText", + "graphBg","gridLine","totalLine","gpuUtil","gpuUtilHi","capLine","barBg", +]); +const MODEL_TYPE_KEYS = Object.freeze([ + "model","vae","clip","clip_vision","controlnet","style_model","gligen","upscale_model", +]); +const RGB_KEYS = Object.freeze(["fadeOutFrom", "fadeOutTo"]); + +// Filled by applyPalette() from computed CSS once the stylesheet is loaded. +// Canvas drawing reads from here because can't read CSS variables; +// the DOM reads var(--aimdo-X) directly from CSS and never touches C. +const C = {}; +const MODEL_TYPE_COLOR = {}; + +// Load the stylesheet and expose the load as a promise. Init code awaits this +// before touching C so the canvas never draws against an unpopulated palette. +const cssLoaded = new Promise((resolve) => { + const id = "aimdo-viz-stylesheet"; + if (document.getElementById(id)) { resolve(); return; } + const link = document.createElement("link"); + link.id = id; + link.rel = "stylesheet"; + link.href = new URL("xb_memory_viz.css", import.meta.url).href; + link.addEventListener("load", () => resolve(), { once: true }); + link.addEventListener("error", () => resolve(), { once: true }); // proceed even on 404 + document.head.appendChild(link); +}); + +// The panel uses a custom element, hook disconnectedCallback to detect whena something removes it from the DOM +if (!customElements.get("aimdo-viz-panel")) { + customElements.define("aimdo-viz-panel", class extends HTMLElement { + disconnectedCallback() { if (this._onDisconnect) this._onDisconnect(); } + }); +} + +function parseRgbTriplet(s) { + const parts = s.split(",").map(x => parseInt(x.trim(), 10)); + if (parts.length === 3 && parts.every(Number.isFinite)) return parts; + return null; +} + +// Set the theme on so the matching CSS block takes effect, then +// refresh C from computed CSS variables for canvas use. +function applyPalette(name) { + const root = document.documentElement; + if (name && name !== "default" && THEME_NAMES.includes(name)) { + root.dataset.aimdoTheme = name; + } else { + delete root.dataset.aimdoTheme; + } + const cs = getComputedStyle(root); + for (const k of PALETTE_KEYS) { + const v = cs.getPropertyValue(`--aimdo-${k}`).trim(); + if (v) C[k] = v; + } + for (const k of MODEL_TYPE_KEYS) { + const v = cs.getPropertyValue(`--aimdo-type-${k}`).trim(); + if (v) MODEL_TYPE_COLOR[k] = v; + } + for (const k of RGB_KEYS) { + const v = cs.getPropertyValue(`--aimdo-${k}`).trim(); + const rgb = v && parseRgbTriplet(v); + if (rgb) C[k] = rgb; + } +} + +// The "comfy" theme maps --aimdo-* to ComfyUI's --comfy-*/--color-* tokens via +// var(). Those tokens themselves change when ComfyUI toggles its .dark-theme +// class on or . DOM elements track that automatically through +// the cascade, but the cached `C` palette used by the canvas does not — so we +// re-run applyPalette whenever the host's theme class flips. +function watchHostThemeFlip() { + const reapply = () => { if (currentTheme === "comfy") applyPalette("comfy"); }; + const opts = { attributes: true, attributeFilter: ["class"] }; + new MutationObserver(reapply).observe(document.documentElement, opts); + if (document.body) new MutationObserver(reapply).observe(document.body, opts); +} + +function hexToRgb(hex) { + const h = hex.replace("#", ""); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} +function hexToRgba(hex, alpha) { + const [r, g, b] = hexToRgb(hex); + return `rgba(${r},${g},${b},${alpha})`; +} +// blend [r,g,b] toward white. Used so fade-in stays in the type's hue family. +function lightenRgb([r, g, b], amount) { + return [ + Math.round(r + (255 - r) * amount), + Math.round(g + (255 - g) * amount), + Math.round(b + (255 - b) * amount), + ]; +} + +initGraph({ C, hexToRgb, hexToRgba, saveState, _t }); + + +function escHtml(s) { + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function gpuUtilColor(pct) { + if (pct < 10) return C.textDim; + if (pct < 80) return C.gpuUtil; + return C.gpuUtilHi; +} + +function gpuTempColor(c) { + if (c < 60) return C.textDim; + if (c < 80) return C.gpuUtil; + return C.gpuUtilHi; +} + +function gpuPowerColor(draw_mW, limit_mW) { + if (draw_mW == null) return C.textDim; + if (limit_mW == null || limit_mW <= 0) return C.gpuUtil; + return gpuUtilColor(draw_mW / limit_mW * 100); +} + +function formatPower(draw_mW, limit_mW, withUnit = true) { + if (draw_mW == null) return null; + const draw = Math.round(draw_mW / 1000); + const u = withUnit ? "W" : ""; + if (limit_mW == null || limit_mW <= 0) return `${draw}${u}`; + return `${draw}/${Math.round(limit_mW / 1000)}${u}`; +} + +function formatClock(ms) { + const d = new Date(ms); + const pad = n => n.toString().padStart(2, "0"); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +function shortenGpuName(name) { + return name.replace(/^NVIDIA\s+/i, "").replace(/^GeForce\s+/i, "").replace(/\s+Laptop GPU$/i, ""); +} + +function shortenCpuName(name) { + return name + .replace(/\(R\)|\(TM\)|\(tm\)/gi, "") + .replace(/\s+CPU\s+@.*$/i, "") // "i9-12900K CPU @ 3.20GHz" → "i9-12900K" + .replace(/\s+\d+-Core\s+Processor$/i, "")// "Ryzen 9 7950X 16-Core Processor" → "Ryzen 9 7950X" + .replace(/^Intel\s+Core\s+/i, "") + .replace(/^AMD\s+/i, "") + .replace(/\s+/g, " ") + .trim(); +} + +function formatBytes(bytes, withUnit = true) { + if (bytes == null) return "?"; + // non-breaking space ( ) so "8.4 GB" never line-wraps between value and unit. + if (bytes >= 1024 ** 3) return (bytes / 1024 ** 3).toFixed(1) + (withUnit ? " GB" : ""); + if (bytes >= 1024 ** 2) return (bytes / 1024 ** 2).toFixed(0) + (withUnit ? " MB" : ""); + return (bytes / 1024).toFixed(0) + (withUnit ? " KB" : ""); +} + + +// "C:\" → "C:", "/" stays "/". Avoids stripping a bare root slash on POSIX. +function shortMountpoint(mp) { + if (!mp) return ""; + if (mp.length > 1) return mp.replace(/[\\\/]+$/, ""); + return mp; +} + +function buildDiskTooltip(disks) { + if (!disks || !disks.length) return ""; + return disks.map(d => { + const mp = shortMountpoint(d.mountpoint); + const label = d.label ? ` "${d.label}"` : ""; + if (d.total == null || d.free == null) return `${mp}${label}: ${_t("diskUnavailable")}`; + const used = d.total - d.free; + const pct = Math.round(used / d.total * 100); + return `${mp}${label}: ${formatBytes(used)} / ${formatBytes(d.total)} (${_t("diskUsed")}${pct}%, ${_t("diskFree")}${formatBytes(d.free)})`; + }).join("\n"); +} + +// per-model residency diff state +const modelState = {}; + +function diffResidency(key, residency) { + let st = modelState[key]; + if (!st || st.prev.length !== residency.length) { + st = { prev: new Uint8Array(residency), changeAge: new Uint8Array(residency.length) }; + modelState[key] = st; + return st; + } + + for (let i = 0; i < residency.length; i++) { + if (residency[i] !== st.prev[i]) { + st.changeAge[i] = FADE_TICKS; + } else if (st.changeAge[i] > 0) { + st.changeAge[i]--; + } + st.prev[i] = residency[i]; + } + return st; +} + +// draw page grid to canvas — much faster than 700 DOM divs. +// vramColor (hex) tints static cells and the fade-in landing color per model. +// Normal mode keeps the original yellow→vram "warm-up" pulse; per-model coloring +// uses a lightened type color so the hue stays in the model's family. +function drawPageGrid(ctx, cssW, residency, changeAge, panelScale, vramColor) { + const vramHex = vramColor || C.vram; + const vramRgb = hexToRgb(vramHex); + const fadeInFromRgb = vramColor ? lightenRgb(vramRgb, 0.55) : [255, 220, 0]; + const cellSize = 6; + const gap = 1; + const step = cellSize + gap; + const cols = Math.max(1, Math.floor((cssW + gap) / step)); + const rows = Math.ceil(residency.length / cols); + const cssH = rows * step; + + const dpr = window.devicePixelRatio || 1; + const totalScale = panelScale * dpr; + const canvas = ctx.canvas; + const backingW = Math.max(1, Math.round(cssW * totalScale)); + const backingH = Math.max(1, Math.round((cssH || 1) * totalScale)); + + // skip draw when nothing's animating and inputs match the previous call. + let anyAnimating = false; + for (let i = 0; i < changeAge.length; i++) if (changeAge[i] > 0) { anyAnimating = true; break; } + const sig = `${vramHex}|var(--aimdo-unloaded)|${backingW}x${backingH}|${residency.length}`; + if (!anyAnimating && canvas._lastSig === sig) return; + canvas._lastSig = sig; + + if (canvas.width !== backingW) canvas.width = backingW; + if (canvas.height !== backingH) canvas.height = backingH; + canvas.style.height = (cssH || 1) + "px"; + ctx.setTransform(totalScale, 0, 0, totalScale, 0, 0); + ctx.clearRect(0, 0, cssW, cssH || 1); + + // batch: draw all static vram cells, then all static unloaded, then animated individually + const animated = []; + + ctx.fillStyle = vramColor || C.vram; + for (let i = 0; i < residency.length; i++) { + if (changeAge[i] > 0) { animated.push(i); continue; } + if (!(residency[i] & 1)) continue; + ctx.fillRect((i % cols) * step, Math.floor(i / cols) * step, cellSize, cellSize); + } + + ctx.fillStyle = C.unloaded; + for (let i = 0; i < residency.length; i++) { + if (changeAge[i] > 0 || (residency[i] & 1)) continue; + ctx.fillRect((i % cols) * step, Math.floor(i / cols) * step, cellSize, cellSize); + } + + // fade-in: lightened type color → type color. fade-out: red → gray (universal "removed"). + for (const i of animated) { + const resident = residency[i] & 1; + const t = changeAge[i] / FADE_TICKS; + const [fr, fg, fb] = resident ? fadeInFromRgb : C.fadeOutFrom; + const [tr, tg, tb] = resident ? vramRgb : C.fadeOutTo; + ctx.fillStyle = `rgb(${Math.round(fr * t + tr * (1 - t))},${Math.round(fg * t + tg * (1 - t))},${Math.round(fb * t + tb * (1 - t))})`; + ctx.fillRect((i % cols) * step, Math.floor(i / cols) * step, cellSize, cellSize); + } +} + +function createPanel() { + const saved = loadState(); + if (saved.pollInterval) pollInterval = saved.pollInterval; + loadGraphSavedState(saved); + if (typeof saved.colorModelBars === "boolean") colorModelBars = saved.colorModelBars; + if (typeof saved.colorModelStroke === "boolean") colorModelStroke = saved.colorModelStroke; + if (typeof saved.colorModelName === "boolean") colorModelName = saved.colorModelName; + if (typeof saved.showLegends === "boolean") showLegends = saved.showLegends; + if (typeof saved.showRamInMini === "boolean") showRamInMini = saved.showRamInMini; + if (typeof saved.showVramInMini === "boolean") showVramInMini = saved.showVramInMini; + if (typeof saved.showGpuInMini === "boolean") showGpuInMini = saved.showGpuInMini; + if (typeof saved.showCpuInMini === "boolean") showCpuInMini = saved.showCpuInMini; + if (typeof saved.showPagefileInMini === "boolean") showPagefileInMini = saved.showPagefileInMini; + if (typeof saved.showDiskInMini === "boolean") showDiskInMini = saved.showDiskInMini; + if (typeof saved.showFaultsInMini === "boolean") showFaultsInMini = saved.showFaultsInMini; + if (Array.isArray(saved.selectedDisks)) selectedDisks = saved.selectedDisks.slice(); + if (typeof saved.showHwNames === "boolean") showHwNames = saved.showHwNames; + if (typeof saved.showTitle === "boolean") showTitle = saved.showTitle; + if (typeof saved.showExecBtn === "boolean") showExecBtn = saved.showExecBtn; + if (typeof saved.miniShowNumbers === "boolean") miniShowNumbers = saved.miniShowNumbers; + if (typeof saved.miniShowUnits === "boolean") miniShowUnits = saved.miniShowUnits; + if (typeof saved.miniShowType === "boolean") miniShowType = saved.miniShowType; + if (typeof saved.miniShowGpuTemp === "boolean") miniShowGpuTemp = saved.miniShowGpuTemp; + if (typeof saved.miniShowGpuPower === "boolean") miniShowGpuPower = saved.miniShowGpuPower; + if (typeof saved.miniShowSeparators === "boolean") miniShowSeparators = saved.miniShowSeparators; + if (typeof saved.graphHeight === "number" && saved.graphHeight > 0) graphHeight = saved.graphHeight; + if (typeof saved.theme === "string" && THEME_NAMES.includes(saved.theme)) { + currentTheme = saved.theme; + } + if (typeof saved.fontSize === "number" && saved.fontSize >= 8 && saved.fontSize <= 24) { + fontSize = saved.fontSize; + } + if (typeof saved.lang === "string" && (saved.lang === "zh" || saved.lang === "en")) { + lang = saved.lang; + } + // always run — primes C from computed CSS so the canvas matches the stylesheet + applyPalette(currentTheme); + watchHostThemeFlip(); + if (saved.modelCollapsed && typeof saved.modelCollapsed === "object") modelCollapsed = saved.modelCollapsed; + let panelScale = typeof saved.scale === "number" ? saved.scale : 1; + + // structural styles live in aimdo_viz.css; CSS variables on :root carry the palette. + // theme switches go through applyPalette → setCssVars; no per-element repaint here. + const panel = document.createElement("aimdo-viz-panel"); + panel.id = "aimdo-viz-panel"; + panel.style.zoom = panelScale; + panel.style.setProperty('--aimdo-font-size', fontSize + 'px'); + panel._scale = panelScale; + if (saved.width != null) panel.style.width = Math.min(saved.width, window.innerWidth) / panelScale + "px"; + // explicit height only when expanded; collapsed shrinks to header. + // separate height persistence per mode — expanding/collapsing loads the right one + const initialHeight = saved.collapsed ? saved.heightCollapsed : saved.height; + if (initialHeight != null) { + panel.style.height = Math.min(initialHeight, window.innerHeight) / panelScale + "px"; + } else if (!saved.collapsed) { + // 无保存高度时设置合理的默认高度,防止内容显示不全 + panel.style.height = Math.min(480, window.innerHeight * 0.5) / panelScale + "px"; + } + + // CSS sizes are pre-zoom (logical); divide visual targets by panelScale. + // 50vh cap when auto-growing; relaxes to viewport when user sets explicit height. + let pipWindow = null; // set when the panel is moved into a PiP window + const isPoppedOut = () => pipWindow && !pipWindow.closed; + function applyConstraints() { + if (isPoppedOut()) return; // PiP fills its own window; main-page bounds don't apply + const heightCapFrac = panel.style.height ? 1.0 : 0.5; + panel.style.minWidth = (200 / panelScale) + "px"; + panel.style.maxWidth = (window.innerWidth / panelScale) + "px"; + panel.style.maxHeight = (window.innerHeight * heightCapFrac / panelScale) + "px"; + } + applyConstraints(); + + // .graph-canvas-panel shrinks when sidebars open; #graph-canvas-container doesn't + function getCanvasEl() { + return document.querySelector(".graph-canvas-panel") + || document.getElementById("graph-canvas-container") + || document.getElementById("graph-canvas") + || (app && app.canvasEl) + || null; + } + + function getCanvasBounds() { + const el = getCanvasEl(); + if (el) { + const r = el.getBoundingClientRect(); + if (r.width > 0 && r.height > 0) return r; + } + return { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }; + } + + let rightOffset = saved.rightOffset != null ? saved.rightOffset : 10; + let bottomOffset = saved.bottomOffset != null ? saved.bottomOffset : 10; + + // Topbar/tabs sit above the panel; leaf bars block only where their items actually sit. + // Cache the layout so drag mousemoves don't trigger getBoundingClientRect on every leaf. + let chromeCache = null; + function refreshChromeCache() { + const fullSels = [".comfyui-body-top", ".topbar-container", ".workflow-tabs-container", ".workflow-tabs"]; + let bottom = 0; + for (const s of fullSels) { + for (const el of document.querySelectorAll(s)) { + const r = el.getBoundingClientRect(); + if (r.height > 0 && r.bottom > bottom) bottom = r.bottom; + } + } + const containers = []; + const leaves = []; + for (const el of document.querySelectorAll(".actionbar-container")) { + const r = el.getBoundingClientRect(); + if (r.height <= 0) continue; + containers.push({ left: r.left, right: r.right, bottom: r.bottom }); + for (const node of el.querySelectorAll("*")) { + if (node.children.length > 0) continue; + const nr = node.getBoundingClientRect(); + if (nr.width <= 0 || nr.height <= 0) continue; + leaves.push({ left: nr.left, right: nr.right, bottom: nr.bottom }); + } + } + const sideLeft = []; + const sideRight = []; + for (const el of document.querySelectorAll(".side-toolbar-container")) { + const r = el.getBoundingClientRect(); + if (r.width <= 0) continue; + const isLeft = r.left < 8; + const isRight = window.innerWidth - r.right < 8; + if (isLeft) sideLeft.push({ right: r.right, top: r.top, bottom: r.bottom }); + else if (isRight) sideRight.push({ left: r.left, top: r.top, bottom: r.bottom }); + } + chromeCache = { fullBottom: bottom, containers, leaves, sideLeft, sideRight }; + } + function invalidateChromeCache() { chromeCache = null; } + // sidebars don't fire window.resize and may overlay (not shrink) the canvas, so the + // canvas ResizeObserver alone can't catch them — drop the stale cache and re-clamp here. + function onSideChromeChange() { invalidateChromeCache(); applyOffsets(); } + window.addEventListener("resize", invalidateChromeCache); + let chromeObserversAttached = false; + function attachChromeObservers() { + if (chromeObserversAttached) return; + const ac = document.querySelector(".actionbar-container"); + const top = document.querySelector(".comfyui-body-top"); + if (!ac && !top) { requestAnimationFrame(attachChromeObservers); return; } + chromeObserversAttached = true; + const targets = [ac, top].filter(Boolean); + for (const t of targets) { + new MutationObserver(invalidateChromeCache).observe(t, { childList: true, subtree: true }); + if (typeof ResizeObserver !== "undefined") new ResizeObserver(invalidateChromeCache).observe(t); + } + // body-side wrappers are stable grid areas; the sidebar panel toggles inside them, + // changing their width. Observe both so opening either sidebar re-clamps the panel. + for (const sel of [".comfyui-body-left", ".comfyui-body-right"]) { + const el = document.querySelector(sel); + if (!el) continue; + // childList only (no subtree) — catch the panel toggling in/out without firing + // on the sidebar's own live content updates. Width changes covered by the resize obs. + new MutationObserver(onSideChromeChange).observe(el, { childList: true }); + if (typeof ResizeObserver !== "undefined") new ResizeObserver(onSideChromeChange).observe(el); + } + } + attachChromeObservers(); + + function getTopChromeBottom(panelLeft, panelRight) { + if (!chromeCache) refreshChromeCache(); + let bottom = chromeCache.fullBottom; + if (panelLeft == null) { + for (const c of chromeCache.containers) if (c.bottom > bottom) bottom = c.bottom; + return bottom; + } + for (const nr of chromeCache.leaves) { + if (nr.right <= panelLeft || nr.left >= panelRight) continue; + if (nr.bottom > bottom) bottom = nr.bottom; + } + return bottom; + } + + function getSideChromeBounds(panelTop, panelBottom) { + if (!chromeCache) refreshChromeCache(); + let minLeft = 0; + let maxRight = window.innerWidth; + for (const r of chromeCache.sideLeft) { + if (panelTop != null && (r.bottom <= panelTop || r.top >= panelBottom)) continue; + if (r.right > minLeft) minLeft = r.right; + } + for (const r of chromeCache.sideRight) { + if (panelTop != null && (r.bottom <= panelTop || r.top >= panelBottom)) continue; + if (r.left < maxRight) maxRight = r.left; + } + return { minLeft, maxRight }; + } + + function clampOffsets(ro, bo) { + const b = getCanvasBounds(); + const pr = panel.getBoundingClientRect(); + const w = pr.width, h = pr.height; + const vw = window.innerWidth, vh = window.innerHeight; + const panelRight = b.right - ro; + const panelLeft = panelRight - w; + const panelTop = b.bottom - bo - h; + const panelBottom = b.bottom - bo; + const minTop = getTopChromeBottom(panelLeft, panelRight); + const { minLeft, maxRight } = getSideChromeBounds(panelTop, panelBottom); + + // when window's too short for both constraints, prefer the chrome + // clamp over the viewport edge so we never overlap the topbar/sidebar. + const boHi = b.bottom - h - minTop; + const boLo = b.bottom - vh; + const boClamped = boHi < boLo ? boHi : Math.max(boLo, Math.min(bo, boHi)); + + // roLo combines viewport-right and right-chrome constraints (whichever is tighter). + const roHi = b.right - w - minLeft; + const roLo = Math.max(b.right - vw, b.right - maxRight); + const roClamped = roHi < roLo ? roHi : Math.max(roLo, Math.min(ro, roHi)); + return { ro: roClamped, bo: boClamped, b, w, h }; + } + + // visual-only clamp; closure offsets stay as user intent so they survive temporary shrinks. + // CSS zoom scales style.left/top along with size, so we divide by panelScale to land at + // the intended viewport position rather than position × scale. + function applyOffsets() { + if (pipWindow && !pipWindow.closed) return; // PiP owns layout while popped out + if (isDocked) return; // docked panel lives in the actionbar flex flow + const { ro, bo, b, w, h } = clampOffsets(rightOffset, bottomOffset); + // while actively dragging, bypass clamping so the user can fly over the topbar + // to reach the dock drop zone. mouseup re-clamps before persisting. + const useRo = dragging ? rightOffset : ro; + const useBo = dragging ? bottomOffset : bo; + const tx = (b.right - w - useRo) / panelScale; + const ty = (b.bottom - h - useBo) / panelScale; + panel.style.left = "0"; + panel.style.top = "0"; + panel.style.right = "auto"; + panel.style.bottom = "auto"; + // translate3d keeps positioning on the compositor — no layout per drag move. + panel.style.transform = `translate3d(${tx}px, ${ty}px, 0)`; + } + window.addEventListener("resize", () => { applyConstraints(); applyOffsets(); positionDockedBody(); }); + + // --- Docking to ComfyUI's top actionbar --- + // Same pattern as ComfyActionbar.vue: during a drag we expose a drop zone inside + // .actionbar-container; mouseup-while-hovering reparents the panel into that container + // and switches it to a flat horizontal mini-bar layout via the .aimdo-docked class. + let isDocked = !!saved.docked; + let dockSide = saved.dockSide === "left" ? "left" : "right"; + let dockExpanded = false; // session-only: body shown as overlay below the docked mini-bar + let autoDockPending = false; // true while the post-load redock poll is running + let dockSectionWidth = (typeof saved.dockSectionWidth === "number" && saved.dockSectionWidth > 40) + ? saved.dockSectionWidth : 110; + let savedPanelCss = null; // snapshot before docking so undock can restore exact styles + let preDockCollapsed = null; + let dropZoneLeft = null; // present only while a drag is in progress + let dropZoneRight = null; + let dropZoneHoverSide = null; // "left" | "right" | null + function applyDockSectionWidth() { + panel.style.setProperty("--aimdo-dock-section-w", dockSectionWidth + "px"); + positionDockedBody(); + } + applyDockSectionWidth(); + + // overlay anchored under the docked panel; right-docked panels extend the overlay leftward + // so it doesn't get pushed off-screen when the panel sits at the topbar's right edge. + function positionDockedBody() { + if (!isDocked || !dockExpanded) return; + const r = panel.getBoundingClientRect(); + const ac = getActionbarContainer(); + const acr = ac ? ac.getBoundingClientRect() : null; + const overlayW = 420; + const anchor = dockSide === "right" + ? (acr ? acr.right - overlayW : r.right - overlayW) + : (acr ? acr.left : r.left); + const left = Math.max(4, Math.min(window.innerWidth - overlayW - 4, anchor)); + body.style.top = (r.bottom + 12) + "px"; + body.style.left = left + "px"; + } + + function getActionbarContainer() { + return document.querySelector(".actionbar-container"); + } + + // two zones — one before the existing actionbar items, one after — let the user choose + // which side to dock on via CSS order on the panel. + function makeDropZone(side) { + const dz = document.createElement("div"); + dz.className = "aimdo-dropzone aimdo-dropzone-" + side; + dz.textContent = side === "left" ? _t("dockLeft") : _t("dockRight"); + dz.addEventListener("mouseenter", () => { + if (!dragging) return; + dropZoneHoverSide = side; + dz.classList.add("is-hover"); + }); + dz.addEventListener("mouseleave", () => { + if (dropZoneHoverSide === side) dropZoneHoverSide = null; + dz.classList.remove("is-hover"); + }); + return dz; + } + function ensureDropZone() { + const ac = getActionbarContainer(); + if (!ac) return null; + if (dropZoneLeft && dropZoneLeft.parentNode === ac) return ac; + if (dropZoneLeft) dropZoneLeft.remove(); + if (dropZoneRight) dropZoneRight.remove(); + dropZoneLeft = makeDropZone("left"); + dropZoneRight = makeDropZone("right"); + ac.appendChild(dropZoneLeft); + ac.appendChild(dropZoneRight); + return ac; + } + function clearDropZone() { + dropZoneHoverSide = null; + if (dropZoneLeft) { dropZoneLeft.remove(); dropZoneLeft = null; } + if (dropZoneRight) { dropZoneRight.remove(); dropZoneRight = null; } + } + + function dock(side) { + const ac = getActionbarContainer(); + if (!ac) return false; + if (side === "left" || side === "right") dockSide = side; + if (isDocked) { + panel.style.order = dockSide === "left" ? "-1" : "1"; + if (panel.parentNode !== ac) ac.appendChild(panel); + saveState({ dockSide }); + return true; + } + preDockCollapsed = collapsed; + if (!collapsed) { + collapsed = true; + body.style.display = "none"; + miniBar.style.display = "block"; + toggleBtn.textContent = "+"; + } + dockExpanded = false; + savedPanelCss = panel.style.cssText; + panel.classList.add("aimdo-docked"); + panel.style.order = dockSide === "left" ? "-1" : "1"; + ac.appendChild(panel); + isDocked = true; + saveState({ docked: true, dockSide }); + return true; + } + + function reconcileDocking() { + if (!isDocked || panel.isConnected) return; + const ac = getActionbarContainer(); + if (!ac) return; + panel.style.order = dockSide === "left" ? "-1" : "1"; + ac.appendChild(panel); + } + // The panel is a custom element — its disconnectedCallback fires the instant Vue + // (or anything else) removes it from the DOM. + panel._onDisconnect = () => { if (isDocked) queueMicrotask(reconcileDocking); }; + + function undock() { + autoDockPending = false; // explicit undock cancels any in-flight auto-redock poll + if (!isDocked) return; + dockExpanded = false; + panel.classList.remove("aimdo-docked", "aimdo-docked-expanded"); + body.style.top = ""; + body.style.left = ""; + if (savedPanelCss != null) panel.style.cssText = savedPanelCss; + savedPanelCss = null; + document.body.appendChild(panel); + isDocked = false; + const restoreCollapsed = preDockCollapsed != null ? preDockCollapsed : false; + preDockCollapsed = null; + collapsed = restoreCollapsed; + body.style.display = collapsed ? "none" : "flex"; + miniBar.style.display = collapsed ? "block" : "none"; + toggleBtn.textContent = collapsed ? "+" : "−"; + if (!collapsed) { + const s = loadState(); + const h = s.height; + panel.style.height = h != null ? (Math.min(h, window.innerHeight) / panelScale + "px") : ""; + } + saveState({ collapsed, docked: false }); + applyConstraints(); + applyOffsets(); + } + + const header = document.createElement("div"); + header.className = "aimdo-header"; + // visible drag affordance matching ComfyUI's docked actionbar handle — six dots in a 2x3 grid + const dragHandle = document.createElement("span"); + dragHandle.className = "aimdo-drag-handle"; + dragHandle.title = _t("dragMove"); + header.appendChild(dragHandle); + const titleSpan = document.createElement("span"); + titleSpan.className = "aimdo-title"; + titleSpan.textContent = _t("title"); + header.appendChild(titleSpan); + + const miniBar = document.createElement("div"); + miniBar.className = "aimdo-mini-bar"; + miniBar.innerHTML = `
+
+ ${_t("ram")} +
+
+
+
+
+
+
+
+
+
+ ${_t("vram")} +
+
+
+
+
+
+
+
+
+
+ ${_t("pagefile")} +
+
+
+
+
+
+
+ ${_t("faults")} +
+
+
+
+
+ ${_t("disk")} +
+
+
+ +
+
+
+ +
+
+
+
+ ${_t("diskSpace")} +
+
+
+
+
+ CPU +
+
+
+
+
+ GPU +
+
+
+ +
+
+
+ +
+
+
+ +
+
`; + + // refs cached once so the per-tick render path doesn't re-query + const mbRefs = { + bar: miniBar, + ramSection: miniBar.querySelector(".mini-ram-section"), + ramLabel: miniBar.querySelector(".mini-ram-label"), + ramUsage: miniBar.querySelector(".mini-ram-usage"), + ramSegs: miniBar.querySelectorAll(".mini-ram-bar > .aimdo-seg"), + vramSection: miniBar.querySelector(".mini-vram-section"), + vramLabel: miniBar.querySelector(".mini-vram-label"), + vramUsage: miniBar.querySelector(".mini-vram-usage"), + vramSegs: miniBar.querySelectorAll(".mini-vram-bar > .aimdo-seg"), + cpuSection: miniBar.querySelector(".mini-cpu-section"), + cpuLabel: miniBar.querySelector(".mini-cpu-label"), + cpuUsage: miniBar.querySelector(".mini-cpu-usage"), + cpuFill: miniBar.querySelector(".mini-cpu-fill"), + pagefileSection: miniBar.querySelector(".mini-pagefile-section"), + pagefileLabel: miniBar.querySelector(".mini-pagefile-label"), + pagefileUsage: miniBar.querySelector(".mini-pagefile-usage"), + pagefileSegs: miniBar.querySelectorAll(".mini-pagefile-bar > .aimdo-seg"), + faultsSection: miniBar.querySelector(".mini-faults-section"), + faultsLabel: miniBar.querySelector(".mini-faults-label"), + faultsUsage: miniBar.querySelector(".mini-faults-usage"), + faultsFill: miniBar.querySelector(".mini-faults-fill"), + diskSection: miniBar.querySelector(".mini-disk-section"), + diskLabel: miniBar.querySelector(".mini-disk-label"), + diskReadUsage: miniBar.querySelector(".mini-disk-read-usage"), + diskReadFill: miniBar.querySelector(".mini-disk-read-fill"), + diskWriteUsage: miniBar.querySelector(".mini-disk-write-usage"), + diskWriteFill: miniBar.querySelector(".mini-disk-write-fill"), + diskSpaceSection: miniBar.querySelector(".mini-diskspace-section"), + diskSpaceLabel: miniBar.querySelector(".mini-diskspace-label"), + diskSpaceRows: miniBar.querySelector(".mini-diskspace-rows"), + gpuSection: miniBar.querySelector(".mini-gpu-section"), + gpuRow: miniBar.querySelector(".mini-gpu-row"), + gpuLabel: miniBar.querySelector(".mini-gpu-label"), + gpuHeaderValue: miniBar.querySelector(".mini-gpu-header-value"), + utilRow: miniBar.querySelector(".mini-util-row"), + gpuUsage: miniBar.querySelector(".mini-gpu-usage"), + gpuFill: miniBar.querySelector(".mini-gpu-fill"), + tempRow: miniBar.querySelector(".mini-temp-row"), + tempUsage: miniBar.querySelector(".mini-temp-usage"), + tempFill: miniBar.querySelector(".mini-temp-fill"), + powerRow: miniBar.querySelector(".mini-power-row"), + powerUsage: miniBar.querySelector(".mini-power-usage"), + powerFill: miniBar.querySelector(".mini-power-fill"), + }; + miniBar._refs = mbRefs; + + const headerRight = document.createElement("div"); + headerRight.className = "aimdo-header-right"; + + // optional play / cancel-running button. Toggles based on execState.running which + // setup() keeps current via api event listeners; we also call updateExecBtnState + // directly from those listeners so the button flips the instant execution starts/ends. + const execBtn = document.createElement("span"); + execBtn.className = "aimdo-exec-btn"; + execBtn.style.display = showExecBtn ? "" : "none"; + function updateExecBtnState() { + if (execState.running) { + execBtn.classList.add("is-running"); + execBtn.textContent = "■"; + execBtn.title = _t("cancelRun"); + } else { + execBtn.classList.remove("is-running"); + execBtn.textContent = "▶"; + execBtn.title = _t("runWorkflow"); + } + } + updateExecBtnState(); + let execBusy = false; + execBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + // re-entrancy guard: fast double-clicks during the in-flight request would + // otherwise double-queue (or send a redundant interrupt). + if (execBusy) return; + execBusy = true; + try { + if (execState.running) await api.interrupt(null); + else await app.queuePrompt(0); + } catch (err) { + console.error("aimdo-viz: exec/interrupt failed", err); + } finally { + execBusy = false; + } + }); + headerRight.appendChild(execBtn); + + const unloadBtn = document.createElement("span"); + unloadBtn.className = "aimdo-btn"; + unloadBtn.textContent = _t("unload"); + unloadBtn.title = _t("unloadTitle"); + + const unloadMenu = document.createElement("div"); + unloadMenu.className = "aimdo-menu"; + unloadMenu.style.minWidth = "160px"; + const unloadOptions = [ + { label: _t("unloadAimdo"), title: _t("unloadAimdoTitle"), run: () => + api.fetchApi("/aimdo/unload_all", { method: "POST" }) }, + { label: _t("unloadModels"), title: _t("unloadModelsTitle"), run: () => + api.fetchApi("/free", { method: "POST", + body: JSON.stringify({ unload_models: true }), + headers: { "Content-Type": "application/json" } }) }, + { label: _t("unloadCache"), title: _t("unloadCacheTitle"), run: () => + api.fetchApi("/free", { method: "POST", + body: JSON.stringify({ unload_models: true, free_memory: true }), + headers: { "Content-Type": "application/json" } }) }, + ]; + for (const opt of unloadOptions) { + const item = document.createElement("div"); + item.className = "aimdo-menu-item"; + item.textContent = opt.label; + item.title = opt.title; + item.addEventListener("click", async (e) => { + e.stopPropagation(); + unloadMenu.style.display = "none"; + unloadBtn.textContent = "..."; + try { await opt.run(); } + finally { unloadBtn.textContent = _t("unload"); } + }); + unloadMenu.appendChild(item); + } + document.body.appendChild(unloadMenu); + + unloadBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (unloadMenu.style.display === "block") { + unloadMenu.style.display = "none"; + return; + } + const r = unloadBtn.getBoundingClientRect(); + unloadMenu.style.zoom = panelScale; + // style.left/top are pre-zoom on a zoomed element, divide visual targets by scale + unloadMenu.style.left = (Math.max(4, r.right - 160 * panelScale) / panelScale) + "px"; + unloadMenu.style.top = ((r.bottom + 2) / panelScale) + "px"; + unloadMenu.style.display = "block"; + }); + document.addEventListener("click", (e) => { + if (e.target !== unloadBtn && !unloadMenu.contains(e.target)) { + unloadMenu.style.display = "none"; + } + }); + + function resetHistory() { + peakVramUsed = 0; + resetHistoryState(); + clearHistoryStorage(); + } + + const popoutBtn = document.createElement("span"); + popoutBtn.className = "aimdo-btn-icon"; + popoutBtn.style.fontSize = "1em"; + popoutBtn.textContent = "\u2924"; + popoutBtn.title = _t("popout"); + popoutBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + if (pipWindow && !pipWindow.closed) { pipWindow.close(); return; } + if (!window.documentPictureInPicture) { + alert("Picture-in-Picture isn't supported here. Try Chrome or Edge."); + return; + } + // PiP rewrites size to fill its window; .aimdo-docked's !important rules would fight it + if (isDocked) undock(); + const r = panel.getBoundingClientRect(); + pipWindow = await window.documentPictureInPicture.requestWindow({ + width: Math.max(280, Math.round(r.width)), + height: Math.max(200, Math.round(r.height)), + }); + // mirror the stylesheet into the PiP window. Wait for the cloned to finish + // loading before continuing, otherwise the panel briefly renders unstyled while the + // CSS file is being fetched in the PiP document. + const styleSrc = document.getElementById("aimdo-viz-stylesheet"); + if (styleSrc) { + const clone = styleSrc.cloneNode(true); + await new Promise((resolve) => { + clone.addEventListener("load", resolve, { once: true }); + clone.addEventListener("error", resolve, { once: true }); // proceed anyway after a 404 + pipWindow.document.head.appendChild(clone); + }); + } + // mirror the active theme onto PiP's — the cloned stylesheet contains + // every theme's overrides, the data attribute picks which block applies + const themeAttr = document.documentElement.dataset.aimdoTheme; + if (themeAttr) pipWindow.document.documentElement.dataset.aimdoTheme = themeAttr; + // remember origins so we can restore on close + const moved = []; + const remember = el => moved.push({ el, parent: el.parentNode, next: el.nextSibling }); + remember(panel); + for (const m of [rootMenu, unloadMenu, ...allSubmenus]) remember(m); + const origPanelCss = panel.style.cssText; + // fill the PiP window; drop the fixed positioning the main-page math expects + Object.assign(panel.style, { + position: "static", left: "auto", top: "auto", right: "auto", bottom: "auto", + transform: "none", + width: "100%", height: "100vh", maxWidth: "none", maxHeight: "none", + border: "none", borderRadius: "0", boxShadow: "none", + }); + pipWindow.document.body.style.margin = "0"; + pipWindow.document.body.style.background = C.bg; + for (const { el } of moved) pipWindow.document.body.appendChild(el); + pipWindow.addEventListener("pagehide", () => { + panel.style.cssText = origPanelCss; + for (const { el, parent, next } of moved) { + if (!parent) continue; + if (next && next.parentNode === parent) parent.insertBefore(el, next); + else parent.appendChild(el); + } + pipWindow = null; + applyOffsets(); + }, { once: true }); + }); + + const toggleBtn = document.createElement("span"); + toggleBtn.className = "aimdo-btn-icon"; + toggleBtn.style.fontSize = "1.333em"; + toggleBtn.textContent = "\u2212"; + toggleBtn.title = _t("collapse"); + + const body = document.createElement("div"); + body.id = "aimdo-viz-body"; + + let collapsed = !!saved.collapsed; + if (collapsed) { + body.style.display = "none"; + toggleBtn.textContent = "+"; + miniBar.style.display = "block"; + } + toggleBtn.addEventListener("click", (e) => { + e.stopPropagation(); + // docked: toggle the overlay body below the docked mini-bar; stays docked + if (isDocked) { + dockExpanded = !dockExpanded; + if (dockExpanded) { + panel.classList.add("aimdo-docked-expanded"); + positionDockedBody(); + toggleBtn.textContent = "\u2212"; + } else { + panel.classList.remove("aimdo-docked-expanded"); + toggleBtn.textContent = "+"; + } + return; + } + collapsed = !collapsed; + body.style.display = collapsed ? "none" : "flex"; + miniBar.style.display = collapsed ? "block" : "none"; + toggleBtn.textContent = collapsed ? "+" : "\u2212"; + // each mode persists its own height so toggling restores the right size. + const s = loadState(); + const h = collapsed ? s.heightCollapsed : s.height; + panel.style.height = h != null ? (Math.min(h, window.innerHeight) / panelScale + "px") : ""; + applyConstraints(); + saveState({ collapsed }); + }); + + headerRight.appendChild(unloadBtn); + headerRight.appendChild(popoutBtn); + headerRight.appendChild(toggleBtn); + header.appendChild(headerRight); + panel.appendChild(header); + panel.appendChild(miniBar); + panel.appendChild(body); + + let dragging = false, dx = 0, dy = 0; + let dragSavedPointerEvents = null; + let dragW = 0, dragH = 0; + let dragRafPending = false; + let dragLastX = 0, dragLastY = 0; + // pendingDrag distinguishes "clicking a button in the header" from "grabbing to drag". + // mousedown only arms; we wait for >DRAG_THRESHOLD px of movement before promoting to a real drag, + // so a click on unload/reset/popout never triggers an undock. + let pendingDrag = null; + const DRAG_THRESHOLD = 5; + + function promoteDrag(e) { + if (pendingDrag.dockedAtStart && isDocked) { + // jump the now-floating panel under the cursor with a reasonable grab offset + undock(); + const r = panel.getBoundingClientRect(); + dx = Math.min(r.width - 20, Math.max(20, 40)); + dy = Math.min(r.height - 8, Math.max(8, 14)); + const b = getCanvasBounds(); + rightOffset = b.right - (e.clientX - dx) - r.width; + bottomOffset = b.bottom - (e.clientY - dy) - r.height; + applyOffsets(); + } + // for non-docked starts, dx/dy were captured at mousedown + dragging = true; + pendingDrag = null; + dragSavedPointerEvents = panel.style.pointerEvents || ""; + panel.style.pointerEvents = "none"; + const r0 = panel.getBoundingClientRect(); + dragW = r0.width; + dragH = r0.height; + ensureDropZone(); + } + + header.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + // when docked, only the drag handle starts a drag — the title and buttons + // around it must stay plain-clickable. floating mode keeps the whole header. + if (isDocked && !dragHandle.contains(e.target)) return; + autoDockPending = false; // user intent overrides the pending auto-redock + pendingDrag = { startX: e.clientX, startY: e.clientY, dockedAtStart: isDocked }; + if (!isDocked) { + const r = panel.getBoundingClientRect(); + dx = e.clientX - r.left; + dy = e.clientY - r.top; + } + }); + // Ctrl/Cmd + mousedown anywhere on the panel starts a drag — capture phase + // so we intercept before child elements (buttons, edge handles) react + const isModifier = e => (e.ctrlKey || e.metaKey) && e.button === 0; + const updateCursor = (e) => { panel.style.cursor = (e.ctrlKey || e.metaKey) ? "move" : ""; }; + document.addEventListener("keydown", updateCursor); + document.addEventListener("keyup", updateCursor); + window.addEventListener("blur", () => { panel.style.cursor = ""; }); + panel.addEventListener("mousedown", (e) => { + if (!isModifier(e) || dragging || pendingDrag) return; + e.preventDefault(); + e.stopPropagation(); + pendingDrag = { startX: e.clientX, startY: e.clientY, dockedAtStart: isDocked }; + if (!isDocked) { + const r = panel.getBoundingClientRect(); + dx = e.clientX - r.left; + dy = e.clientY - r.top; + } + }, true); + // suppress the click that follows a Ctrl+drag so a Ctrl+click on a button + // doesn't trigger its action after the drag ends + panel.addEventListener("click", (e) => { + if (isModifier(e)) { + e.preventDefault(); + e.stopPropagation(); + } + }, true); + function flushDrag() { + dragRafPending = false; + if (!dragging) return; + const b = getCanvasBounds(); + rightOffset = b.right - (dragLastX - dx) - dragW; + bottomOffset = b.bottom - (dragLastY - dy) - dragH; + applyOffsets(); + } + document.addEventListener("mousemove", (e) => { + if (pendingDrag) { + const adx = Math.abs(e.clientX - pendingDrag.startX); + const ady = Math.abs(e.clientY - pendingDrag.startY); + if (Math.max(adx, ady) < DRAG_THRESHOLD) return; + promoteDrag(e); + } + if (!dragging) return; + dragLastX = e.clientX; + dragLastY = e.clientY; + if (!dragRafPending) { + dragRafPending = true; + requestAnimationFrame(flushDrag); + } + }); + // shared drag-end cleanup so a mouseup off-window (or alt-tab during drag) doesn't + // leave pendingDrag armed, pointer-events stuck at "none", or drop zones in the DOM. + function endDrag(commit) { + pendingDrag = null; + if (!dragging) { + // pendingDrag-only path (click without movement). No drop zones to clear, + // but call clearDropZone defensively in case a stale one slipped through. + clearDropZone(); + return; + } + const wantDockSide = commit ? dropZoneHoverSide : null; + clearDropZone(); + if (dragSavedPointerEvents !== null) { + panel.style.pointerEvents = dragSavedPointerEvents; + dragSavedPointerEvents = null; + } + dragging = false; // before applyOffsets so it uses the clamped position + if (commit) { + if (wantDockSide) { + dock(wantDockSide); + } else { + const c = clampOffsets(rightOffset, bottomOffset); + rightOffset = c.ro; + bottomOffset = c.bo; + applyOffsets(); + saveState({ rightOffset, bottomOffset }); + } + } else { + // abort (blur / alt-tab): snap back into bounds visually without persisting + applyOffsets(); + } + } + document.addEventListener("mouseup", () => endDrag(true)); + window.addEventListener("blur", () => endDrag(false)); + + // edge handles: left grows left (right edge anchored), right grows right (left + // edge anchored via RO ro-delta), bottom grows down (top edge anchored via bo-delta). + let suppressWidthAnchor = false; + let edgeDrag = null; + function makeEdgeHandle(side) { + const h = document.createElement("div"); + h.className = `aimdo-edge-handle aimdo-edge-${side}`; + h.title = _t("dragResize"); + h.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + // captured at drag start so the RO ro-shift mid-drag can't move the cap. + const r = panel.getBoundingClientRect(); + const { minLeft, maxRight } = getSideChromeBounds(r.top, r.bottom); + const maxWidth = side === "right" ? maxRight - r.left : r.right - minLeft; + edgeDrag = { side, startX: e.clientX, startWidth: r.width, maxWidth }; + if (side === "left") suppressWidthAnchor = true; + }); + panel.appendChild(h); + } + makeEdgeHandle("left"); + makeEdgeHandle("right"); + + // collapsed: can't shrink below header + miniBar. expanded: keep modelsDiv top visible. + function computeMinHeight(panelRect) { + if (collapsed) return miniBar.getBoundingClientRect().bottom - panelRect.top + 2; + if (refs && refs.modelsDiv) { + return Math.max(40, refs.modelsDiv.getBoundingClientRect().top - panelRect.top + 8); + } + return 80; + } + + let bottomDrag = null; + function makeBottomHandle() { + const h = document.createElement("div"); + // inset from the side handles so corners go to the ew-resize handles + h.className = "aimdo-edge-handle aimdo-edge-bottom"; + h.title = _t("dragResize"); + h.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + const r = panel.getBoundingClientRect(); + const minHeight = computeMinHeight(r); + const maxHeight = window.innerHeight - r.top; + bottomDrag = { startY: e.clientY, startHeight: r.height, minHeight, maxHeight }; + }); + panel.appendChild(h); + } + makeBottomHandle(); + + // bottom-right corner handle: diagonal width+height drag. The grip gradient + hover + // opacity live in aimdo_viz.css now. + let cornerDrag = null; + function makeCornerHandle() { + const h = document.createElement("div"); + h.className = "aimdo-corner-handle"; + h.title = _t("dragResize"); + h.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + const r = panel.getBoundingClientRect(); + const { minLeft, maxRight } = getSideChromeBounds(r.top, r.bottom); + cornerDrag = { + startX: e.clientX, startY: e.clientY, + startWidth: r.width, startHeight: r.height, + maxWidth: maxRight - r.left, + maxHeight: window.innerHeight - r.top, + minHeight: computeMinHeight(r), + }; + }); + panel.appendChild(h); + } + makeCornerHandle(); + + document.addEventListener("mousemove", (e) => { + if (!edgeDrag) return; + const delta = e.clientX - edgeDrag.startX; + const newWidth = edgeDrag.side === "left" ? edgeDrag.startWidth - delta : edgeDrag.startWidth + delta; + panel.style.width = Math.max(200, Math.min(edgeDrag.maxWidth, newWidth)) / panelScale + "px"; + }); + document.addEventListener("mousemove", (e) => { + if (!bottomDrag) return; + const delta = e.clientY - bottomDrag.startY; + const newHeight = Math.max(bottomDrag.minHeight, Math.min(bottomDrag.maxHeight, bottomDrag.startHeight + delta)); + panel.style.height = (newHeight / panelScale) + "px"; + // relax the 50vh auto-grow cap once user sets an explicit height. + applyConstraints(); + }); + document.addEventListener("mousemove", (e) => { + if (!cornerDrag) return; + const dx = e.clientX - cornerDrag.startX; + const dy = e.clientY - cornerDrag.startY; + const newWidth = Math.max(200, Math.min(cornerDrag.maxWidth, cornerDrag.startWidth + dx)); + const newHeight = Math.max(cornerDrag.minHeight, Math.min(cornerDrag.maxHeight, cornerDrag.startHeight + dy)); + panel.style.width = (newWidth / panelScale) + "px"; + panel.style.height = (newHeight / panelScale) + "px"; + applyConstraints(); + }); + document.addEventListener("mouseup", () => { + if (edgeDrag) { + edgeDrag = null; + suppressWidthAnchor = false; + } + if (bottomDrag) bottomDrag = null; + if (cornerDrag) cornerDrag = null; + }); + + // menu chrome lives in aimdo_viz.css (.aimdo-menu). Submenus differ only by + // position which we set inline at openSubmenu time. + function makeMenu() { + const m = document.createElement("div"); + m.className = "aimdo-menu"; + return m; + } + const rootMenu = makeMenu(); + const scaleSubmenu = makeMenu(); + const pollSubmenu = makeMenu(); + const displaySubmenu = makeMenu(); + const miniSubmenu = makeMenu(); + const themeSubmenu = makeMenu(); + const dockWidthSubmenu = makeMenu(); + const graphSubmenu = makeMenu(); + const gpuSubmenu = makeMenu(); + const diskSubmenu = makeMenu(); + const fontSizeSubmenu = makeMenu(); + const langSubmenu = makeMenu(); + const allSubmenus = [scaleSubmenu, fontSizeSubmenu, pollSubmenu, displaySubmenu, miniSubmenu, themeSubmenu, dockWidthSubmenu, graphSubmenu, gpuSubmenu, diskSubmenu, langSubmenu]; + function closeAllSubmenus() { for (const m of allSubmenus) m.style.display = "none"; } + + // submenu overlaps parent by 1px so mouse transit doesn't trigger mouseleave-close. + // anchorMenu defaults to rootMenu (top-level submenus) but can be another submenu + // for nested cases; keepOpen lists ancestors that must NOT be closed when this opens. + function openSubmenu(parentItem, submenu, anchorMenu, keepOpen) { + anchorMenu = anchorMenu || rootMenu; + keepOpen = keepOpen || []; + for (const m of allSubmenus) { + if (m === submenu || keepOpen.includes(m)) continue; + m.style.display = "none"; + } + submenu.style.zoom = panelScale; + submenu.style.display = "block"; + const anchorR = anchorMenu.getBoundingClientRect(); + const itemR = parentItem.getBoundingClientRect(); + const subR = submenu.getBoundingClientRect(); + let left = anchorR.right - 1; + if (left + subR.width > window.innerWidth) left = Math.max(2, anchorR.left - subR.width + 1); + submenu.style.left = (left / panelScale) + "px"; + submenu.style.top = (Math.min(itemR.top, window.innerHeight - subR.height - 4) / panelScale) + "px"; + } + + // factory for a checkbox-style toggle item bound to a module-level flag. + function makeToggleItem(label, getValue, setValue, stateKey) { + const item = document.createElement("div"); + item.className = "aimdo-menu-item"; + const render = () => { + item.innerHTML = `${getValue() ? "✓" : ""}${label}`; + }; + render(); + item.addEventListener("click", (e) => { + e.stopPropagation(); + if (item.classList.contains("is-disabled")) return; + setValue(!getValue()); + saveState({ [stateKey]: getValue() }); + render(); + // visible change kicks in on the next poll tick (<= pollInterval ms). + }); + return { item, render }; + } + + // parent item that opens a submenu on hover; chevron hints at the nesting. + function makeSubmenuParent(label, submenu, anchorMenu, keepOpen) { + const item = document.createElement("div"); + item.className = "aimdo-menu-parent"; + item.innerHTML = `${label}▸`; + item.addEventListener("mouseenter", () => openSubmenu(item, submenu, anchorMenu, keepOpen)); + return item; + } + + // --- Scale submenu + const scalePresets = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; + const scaleItems = new Map(); + function renderScaleItems() { + for (const [s, item] of scaleItems) { + const on = Math.abs(s - panelScale) < 1e-6; + item.innerHTML = `${on ? "✓" : ""}${Math.round(s * 100)}%`; + } + } + function setScale(s) { + const r = panel.getBoundingClientRect(); + const w = r.width, h = r.height; + const hadExplicitHeight = panel.style.height !== ""; + panelScale = s; + panel._scale = s; + panel.style.zoom = s; + panel.style.width = (w / s) + "px"; + if (hadExplicitHeight) panel.style.height = (h / s) + "px"; + applyConstraints(); + applyOffsets(); + saveState({ scale: s }); + } + for (const s of scalePresets) { + const item = document.createElement("div"); + item.className = "aimdo-menu-item"; + item.addEventListener("click", (e) => { + e.stopPropagation(); + setScale(s); + renderScaleItems(); + rootMenu.style.display = "none"; + closeAllSubmenus(); + }); + scaleSubmenu.appendChild(item); + scaleItems.set(s, item); + } + renderScaleItems(); + + // --- Font Size submenu (single-select, like Scale) + const fontSizePresets = [10, 11, 12, 13, 14, 16, 18, 20]; + const fontSizeItems = new Map(); + function renderFontSizeItems() { + for (const [s, item] of fontSizeItems) { + const on = s === fontSize; + item.innerHTML = `${on ? "✓" : ""}${s}px`; + } + } + function setFontSize(s) { + fontSize = s; + panel.style.setProperty('--aimdo-font-size', s + 'px'); + saveState({ fontSize: s }); + } + for (const s of fontSizePresets) { + const item = document.createElement("div"); + item.className = "aimdo-menu-item"; + item.addEventListener("click", (e) => { + e.stopPropagation(); + setFontSize(s); + renderFontSizeItems(); + rootMenu.style.display = "none"; + closeAllSubmenus(); + }); + fontSizeSubmenu.appendChild(item); + fontSizeItems.set(s, item); + } + renderFontSizeItems(); + + // --- Language submenu (single-select) + const langPresets = [ + { key: "zh", label: _t("langZh") }, + { key: "en", label: _t("langEn") }, + ]; + const langItems = new Map(); + function renderLangItems() { + for (const [k, item] of langItems) { + const on = k === lang; + const label = langPresets.find(p => p.key === k).label; + item.innerHTML = `${on ? "✓" : ""}${label}`; + } + } + function setLang(l) { + lang = l; + saveState({ lang: l }); + // 更新迷你栏静态标签 + const m = mbRefs; + if (m) { + m.ramLabel.textContent = _t("ram"); + m.vramLabel.textContent = _t("vram"); + m.pagefileLabel.textContent = _t("pagefile"); + m.faultsLabel.textContent = _t("faults"); + m.diskLabel.textContent = _t("disk"); + m.diskSpaceLabel.textContent = _t("diskSpace"); + } + // 更新标题 + if (body._titleSpan && body._titleSpan.style.display !== "none") { + body._titleSpan.textContent = _t("title"); + } + // 更新按钮 + titleSpan.textContent = _t("title"); + unloadBtn.textContent = _t("unload"); + unloadBtn.title = _t("unloadTitle"); + popoutBtn.title = _t("popout"); + toggleBtn.title = _t("collapse"); + dragHandle.title = _t("dragMove"); + graphResize.title = _t("dragResizeGraph"); + // 更新菜单项文本(下次打开菜单时生效) + } + for (const p of langPresets) { + const item = document.createElement("div"); + item.className = "aimdo-menu-item"; + item.addEventListener("click", (e) => { + e.stopPropagation(); + setLang(p.key); + renderLangItems(); + rootMenu.style.display = "none"; + closeAllSubmenus(); + }); + langSubmenu.appendChild(item); + langItems.set(p.key, item); + } + renderLangItems(); + + // --- Display submenu + const colorBars = makeToggleItem(_t("toggleColorBars"), + () => colorModelBars, v => { colorModelBars = v; }, "colorModelBars"); + const colorStroke = makeToggleItem(_t("toggleColorStroke"), + () => colorModelStroke, v => { colorModelStroke = v; }, "colorModelStroke"); + const colorName = makeToggleItem(_t("toggleColorName"), + () => colorModelName, v => { colorModelName = v; }, "colorModelName"); + const showLeg = makeToggleItem(_t("toggleShowLegends"), + () => showLegends, v => { showLegends = v; }, "showLegends"); + const showTitleItem = makeToggleItem(_t("toggleShowTitle"), + () => showTitle, v => { showTitle = v; }, "showTitle"); + const showExecBtnItem = makeToggleItem(_t("toggleExecBtn"), + () => showExecBtn, + v => { showExecBtn = v; execBtn.style.display = v ? "" : "none"; }, + "showExecBtn"); + displaySubmenu.appendChild(colorBars.item); + displaySubmenu.appendChild(colorStroke.item); + displaySubmenu.appendChild(colorName.item); + displaySubmenu.appendChild(showLeg.item); + displaySubmenu.appendChild(showTitleItem.item); + displaySubmenu.appendChild(showExecBtnItem.item); + + // --- Mini view submenu + const showRam = makeToggleItem(_t("toggleRam"), + () => showRamInMini, v => { showRamInMini = v; }, "showRamInMini"); + const showVram = makeToggleItem(_t("toggleVram"), + () => showVramInMini, v => { showVramInMini = v; }, "showVramInMini"); + const showCpu = makeToggleItem("CPU", + () => showCpuInMini, v => { showCpuInMini = v; }, "showCpuInMini"); + const showPagefile = makeToggleItem(_t("togglePagefile"), + () => showPagefileInMini, v => { showPagefileInMini = v; }, "showPagefileInMini"); + const showDisk = makeToggleItem(_t("toggleIO"), + () => showDiskInMini, v => { showDiskInMini = v; }, "showDiskInMini"); + const showFaults = makeToggleItem(_t("toggleFaults"), + () => showFaultsInMini, v => { showFaultsInMini = v; }, "showFaultsInMini"); + // labeled "util" since these live under the nested GPU submenu now + const showGpu = makeToggleItem(_t("toggleUtil"), + () => showGpuInMini, v => { showGpuInMini = v; }, "showGpuInMini"); + const showNames = makeToggleItem(_t("toggleNames"), + () => showHwNames, v => { showHwNames = v; }, "showHwNames"); + const showNumbers = makeToggleItem(_t("toggleNumbers"), + () => miniShowNumbers, v => { miniShowNumbers = v; }, "miniShowNumbers"); + const showUnits = makeToggleItem(_t("toggleUnits"), + () => miniShowUnits, v => { miniShowUnits = v; }, "miniShowUnits"); + const showType = makeToggleItem(_t("toggleType"), + () => miniShowType, v => { miniShowType = v; }, "miniShowType"); + const showGpuTemp = makeToggleItem(_t("toggleTemp"), + () => miniShowGpuTemp, v => { miniShowGpuTemp = v; }, "miniShowGpuTemp"); + const showGpuPower = makeToggleItem(_t("togglePower"), + () => miniShowGpuPower, v => { miniShowGpuPower = v; }, "miniShowGpuPower"); + const showSeparators = makeToggleItem(_t("toggleSeparators"), + () => miniShowSeparators, v => { miniShowSeparators = v; }, "miniShowSeparators"); + miniSubmenu.appendChild(showRam.item); + miniSubmenu.appendChild(showVram.item); + miniSubmenu.appendChild(showCpu.item); + miniSubmenu.appendChild(showPagefile.item); + // GPU's util / temp / power get their own submenu since they're closely related — + // keeps the Mini-view list flat and groups the three multibar toggles together. + // Each is independent: any combination can be on/off, including just temp+power. + gpuSubmenu.appendChild(showGpu.item); + gpuSubmenu.appendChild(showGpuTemp.item); + gpuSubmenu.appendChild(showGpuPower.item); + miniSubmenu.appendChild(makeSubmenuParent("GPU", gpuSubmenu, miniSubmenu, [miniSubmenu])); + + // --- Disk submenu: I/O toggle + one toggle per detected fixed drive. + // Drive items are populated lazily from the next poll once list_disks=1 + // is requested. Selecting any drive enables the disk-space mini section. + diskSubmenu.appendChild(showDisk.item); + diskSubmenu.appendChild(showFaults.item); + const drivesHeader = document.createElement("div"); + drivesHeader.className = "aimdo-menu-header"; + drivesHeader.textContent = _t("drives"); + diskSubmenu.appendChild(drivesHeader); + const drivesPlaceholder = document.createElement("div"); + drivesPlaceholder.className = "aimdo-menu-item is-disabled"; + drivesPlaceholder.innerHTML = `(${_t("detecting")})`; + diskSubmenu.appendChild(drivesPlaceholder); + const driveItems = new Map(); // mountpoint → menu item element + function renderDriveItems() { + for (const [mp, item] of driveItems) { + const on = selectedDisks.includes(mp); + const entry = allDisksCache.find(d => d.mountpoint === mp); + // Volume labels are user-supplied (e.g. drive renamed in Explorer), + // so escape them before splicing into innerHTML. + const lbl = entry && entry.label ? ` ${escHtml(entry.label)}` : ""; + item.innerHTML = `${on ? "✓" : ""}${escHtml(shortMountpoint(mp))}${lbl}`; + } + } + function rebuildDriveMenu() { + if (!allDisksCache.length) return; + if (drivesPlaceholder.parentNode) drivesPlaceholder.remove(); + // Remove items for drives no longer present, and add new ones. + const present = new Set(allDisksCache.map(d => d.mountpoint)); + for (const [mp, item] of driveItems) { + if (!present.has(mp)) { item.remove(); driveItems.delete(mp); } + } + for (const d of allDisksCache) { + if (driveItems.has(d.mountpoint)) continue; + const item = document.createElement("div"); + item.className = "aimdo-menu-item"; + item.addEventListener("click", (e) => { + e.stopPropagation(); + const idx = selectedDisks.indexOf(d.mountpoint); + if (idx >= 0) selectedDisks.splice(idx, 1); + else selectedDisks.push(d.mountpoint); + saveState({ selectedDisks }); + renderDriveItems(); + }); + diskSubmenu.appendChild(item); + driveItems.set(d.mountpoint, item); + } + renderDriveItems(); + } + _rebuildDriveMenu = rebuildDriveMenu; + const diskParent = makeSubmenuParent(_t("toggleDisk"), diskSubmenu, miniSubmenu, [miniSubmenu]); + // first hover triggers a fresh enumeration on the next poll + diskParent.addEventListener("mouseenter", () => { wantDisksList = true; }); + miniSubmenu.appendChild(diskParent); + + // when the cursor enters a non-parent item in miniSubmenu, close nested submenus + // — otherwise they'd linger open while the user is interacting with sibling toggles. + miniSubmenu.addEventListener("mouseover", (e) => { + const item = e.target.closest(".aimdo-menu-item, .aimdo-menu-parent"); + if (item && item.classList.contains("aimdo-menu-item")) { + gpuSubmenu.style.display = "none"; + diskSubmenu.style.display = "none"; + } + }); + miniSubmenu.appendChild(showNames.item); + miniSubmenu.appendChild(showType.item); + miniSubmenu.appendChild(showNumbers.item); + miniSubmenu.appendChild(showUnits.item); + miniSubmenu.appendChild(showSeparators.item); + + // --- Polling interval submenu (single-select like Scale) + const pollPresets = [100, 250, 500, 1000, 2000, 5000]; + const pollItems = new Map(); + function renderPollItems() { + for (const [ms, item] of pollItems) { + const on = ms === pollInterval; + const label = ms < 1000 ? `${ms} ms` : `${ms / 1000} s`; + item.innerHTML = `${on ? "✓" : ""}${label}`; + } + } + for (const ms of pollPresets) { + const item = document.createElement("div"); + item.className = "aimdo-menu-item"; + item.addEventListener("click", (e) => { + e.stopPropagation(); + pollInterval = ms; + saveState({ pollInterval }); + renderPollItems(); + rootMenu.style.display = "none"; + closeAllSubmenus(); + }); + pollSubmenu.appendChild(item); + pollItems.set(ms, item); + } + renderPollItems(); + + // --- Theme submenu (single-select; live-applies) + // applyPalette pushes the active palette into CSS variables on :root, so + // most chrome repaints itself. We only need to manually clear places that + // bake colors into innerHTML / canvas at render time and pick the new + // palette up on their next tick. + function applyTheme(name) { + if (!THEME_NAMES.includes(name)) return; + currentTheme = name; + applyPalette(name); + if (refs && refs.bottomLegend) { + refs.bottomLegend.remove(); + refs.bottomLegend = null; + } + } + const themeItems = new Map(); + function renderThemeItems() { + for (const [name, item] of themeItems) { + const on = name === currentTheme; + const label = name[0].toUpperCase() + name.slice(1); + item.innerHTML = `${on ? "✓" : ""}${label}`; + } + } + for (const name of THEME_NAMES) { + const item = document.createElement("div"); + item.className = "aimdo-menu-item"; + item.addEventListener("click", (e) => { + e.stopPropagation(); + applyTheme(name); + saveState({ theme: name }); + renderThemeItems(); + rootMenu.style.display = "none"; + closeAllSubmenus(); + }); + themeSubmenu.appendChild(item); + themeItems.set(name, item); + } + renderThemeItems(); + + // --- Dock width submenu (section width when docked into the topbar) + const dockWidthSliderRow = document.createElement("div"); + dockWidthSliderRow.style.cssText = `padding:6px 10px;display:flex;flex-direction:column;gap:4px;min-width:180px;`; + dockWidthSliderRow.addEventListener("click", (e) => e.stopPropagation()); + const dockWidthLabel = document.createElement("div"); + dockWidthLabel.style.cssText = `display:flex;justify-content:space-between;font-size:0.833em;color:var(--aimdo-textDim);`; + dockWidthLabel.innerHTML = `${_t("dockSectionW")}${dockSectionWidth}px`; + const dockWidthSlider = document.createElement("input"); + dockWidthSlider.type = "range"; + dockWidthSlider.min = "60"; + dockWidthSlider.max = "400"; + dockWidthSlider.step = "5"; + dockWidthSlider.value = String(dockSectionWidth); + dockWidthSlider.style.cssText = `width:100%;accent-color:var(--aimdo-vram);cursor:pointer;`; + const dockWidthValSpan = dockWidthLabel.querySelector(".aimdo-dw-val"); + dockWidthSlider.addEventListener("input", () => { + dockSectionWidth = parseInt(dockWidthSlider.value, 10); + dockWidthValSpan.textContent = dockSectionWidth + "px"; + applyDockSectionWidth(); + saveState({ dockSectionWidth }); + }); + dockWidthSliderRow.appendChild(dockWidthLabel); + dockWidthSliderRow.appendChild(dockWidthSlider); + dockWidthSubmenu.appendChild(dockWidthSliderRow); + function renderDockWidthItems() { + dockWidthSlider.value = String(dockSectionWidth); + dockWidthValSpan.textContent = dockSectionWidth + "px"; + } + + buildGraphSubmenu(graphSubmenu, () => redrawGraph()); + + // --- Root menu items + rootMenu.appendChild(makeSubmenuParent(_t("menuScale"), scaleSubmenu)); + rootMenu.appendChild(makeSubmenuParent(_t("menuFontSize"), fontSizeSubmenu)); + rootMenu.appendChild(makeSubmenuParent(_t("menuPoll"), pollSubmenu)); + rootMenu.appendChild(makeSubmenuParent(_t("menuDisplay"), displaySubmenu)); + rootMenu.appendChild(makeSubmenuParent(_t("menuMini"), miniSubmenu)); + rootMenu.appendChild(makeSubmenuParent(_t("menuGraph"), graphSubmenu)); + rootMenu.appendChild(makeSubmenuParent(_t("menuTheme"), themeSubmenu)); + rootMenu.appendChild(makeSubmenuParent(_t("menuDockWidth"), dockWidthSubmenu)); + rootMenu.appendChild(makeSubmenuParent(_t("menuLang"), langSubmenu)); + + // dock / undock toggle — present only when the actionbar is available so we don't + // offer a no-op when ComfyUI's new menu is disabled. + const dockItem = document.createElement("div"); + dockItem.className = "aimdo-menu-item"; + function renderDockItem() { + const canDock = !!getActionbarContainer(); + dockItem.style.display = (isDocked || canDock) ? "" : "none"; + dockItem.innerHTML = `${isDocked ? "✓" : ""}${isDocked ? _t("undock") : _t("dockTop")}`; + } + dockItem.addEventListener("click", (e) => { + e.stopPropagation(); + if (isDocked) undock(); else dock(); + renderDockItem(); + rootMenu.style.display = "none"; + closeAllSubmenus(); + }); + renderDockItem(); + rootMenu.appendChild(dockItem); + + // reset peak VRAM marker + clear history graph; this used to live on the header + const resetItem = document.createElement("div"); + resetItem.className = "aimdo-menu-item"; + resetItem.innerHTML = `${_t("resetHistory")}`; + resetItem.title = _t("resetHistoryTitle"); + resetItem.addEventListener("click", (e) => { + e.stopPropagation(); + resetHistory(); + rootMenu.style.display = "none"; + closeAllSubmenus(); + }); + rootMenu.appendChild(resetItem); + + function renderColorBarsItem() { + renderScaleItems(); renderPollItems(); renderThemeItems(); renderDockWidthItems(); + colorBars.render(); colorStroke.render(); colorName.render(); showLeg.render(); + showRam.render(); showVram.render(); showCpu.render(); showGpu.render(); showNames.render(); + showTitleItem.render(); showExecBtnItem.render(); + showType.render(); showNumbers.render(); showUnits.render(); + showGpuTemp.render(); showGpuPower.render(); + renderDockItem(); + } + + document.body.appendChild(rootMenu); + for (const m of allSubmenus) document.body.appendChild(m); + + panel.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + renderColorBarsItem(); + closeAllSubmenus(); + rootMenu.style.zoom = panelScale; + rootMenu.style.display = "block"; + const mRect = rootMenu.getBoundingClientRect(); + const mw = mRect.width || 120 * panelScale; + const mh = mRect.height || 120 * panelScale; + rootMenu.style.left = (Math.min(e.clientX, window.innerWidth - mw - 4) / panelScale) + "px"; + rootMenu.style.top = (Math.min(e.clientY, window.innerHeight - mh - 4) / panelScale) + "px"; + }); + document.addEventListener("click", (e) => { + if (rootMenu.contains(e.target)) return; + for (const m of allSubmenus) if (m.contains(e.target)) return; + rootMenu.style.display = "none"; + closeAllSubmenus(); + }); + // moving the mouse out of the menu structure into empty space should also + // close the submenu so it doesn't linger over unrelated UI. + rootMenu.addEventListener("mouseleave", (e) => { + const target = e.relatedTarget; + if (target && (target instanceof Node) && allSubmenus.some(m => m.contains(target))) return; + closeAllSubmenus(); + }); + for (const m of allSubmenus) { + m.addEventListener("mouseleave", (e) => { + const target = e.relatedTarget; + if (target && (target instanceof Node) && (rootMenu.contains(target) || allSubmenus.some(x => x.contains(target)))) return; + m.style.display = "none"; + }); + } + + document.body.appendChild(panel); + applyOffsets(); + + // restore docked placement once ComfyUI has built the topbar. Until the container + // exists we wait — the panel sits floating in its persisted spot in the meantime. + // Capped at ~3s so a user who disabled the new menu doesn't keep us polling. + // Any user-initiated drag or explicit undock during the wait cancels the poll so + // we don't yank the panel out from under them. + if (isDocked) { + isDocked = false; // dock() guards on this; reset so it actually runs + autoDockPending = true; + let frames = 0; + const tryDock = () => { + if (!autoDockPending) return; + if (dock(dockSide)) return; + if (++frames > 180) { autoDockPending = false; saveState({ docked: false }); return; } + requestAnimationFrame(tryDock); + }; + requestAnimationFrame(tryDock); + } + + // width changes shift ro to anchor one edge (unless suppressed by the left handle). + // height changes always anchor the top edge by shifting bo by -Δh. + let lastPanelWidth = null; + let lastPanelHeight = null; + if (typeof ResizeObserver !== "undefined") { + new ResizeObserver(() => { + if (isPoppedOut()) return; // PiP resize doesn't persist back to main-page state + if (isDocked) { lastPanelWidth = null; lastPanelHeight = null; positionDockedBody(); return; } + const r = panel.getBoundingClientRect(); + const w = r.width, h = r.height; + if (lastPanelWidth !== null && w !== lastPanelWidth) { + if (!suppressWidthAnchor) rightOffset -= (w - lastPanelWidth); + saveState({ width: w, rightOffset, bottomOffset }); + } + if (lastPanelHeight !== null && h !== lastPanelHeight) { + bottomOffset -= (h - lastPanelHeight); + // persist height only on explicit drag, and key it by current mode so + // expanded vs collapsed heights don't overwrite each other. + if (bottomDrag || cornerDrag) { + const key = collapsed ? "heightCollapsed" : "height"; + saveState({ [key]: h, bottomOffset }); + } else saveState({ bottomOffset }); + } + lastPanelWidth = w; + lastPanelHeight = h; + applyOffsets(); + }).observe(panel); + } + + // sidebar toggles don't fire window.resize. canvas may not exist yet — poll until it does. + let observed = null; + function attachCanvasObserver() { + const el = getCanvasEl(); + if (!el) { + requestAnimationFrame(attachCanvasObserver); + return; + } + if (el === observed) return; + observed = el; + if (typeof ResizeObserver !== "undefined") { + new ResizeObserver(() => { applyOffsets(); positionDockedBody(); }).observe(el); + } + applyOffsets(); + } + attachCanvasObserver(); + body._titleSpan = titleSpan; + body._miniBar = miniBar; + body._panel = panel; + body._updateExecBtnState = updateExecBtnState; + return body; +} + +// persistent DOM refs to avoid re-querying / re-creating +let refs = null; + +function ensureStructure(body) { + if (refs) return refs; + + body.innerHTML = ""; + + const contentDiv = document.createElement("div"); + contentDiv.id = "aimdo-content"; + contentDiv.style.cssText = "flex-shrink: 0;"; + contentDiv.addEventListener("click", (e) => { + const t = e.target.closest(".aimdo-gpu-util"); + if (!t) return; + graphState.gpuLineVisible = !graphState.gpuLineVisible; + saveState({ gpuLineVisible: graphState.gpuLineVisible }); + redrawGraph(); + t.style.opacity = graphState.gpuLineVisible ? "1" : "0.4"; + }); + // skeleton built once; renderData mutates text/widths/visibility only. + contentDiv.innerHTML = ` +
+
+ ${_t("ram")} + +
+
+
+
+
+
+
+
+ ■ + ■ + ■ + ■ +
+
+
+
+ ${_t("vram")} + +
+
+
+
+
+
+
+
+ ■ + ■ + ■ + ■ +
+
+ + + + + + +
+
+
+
+ ${_t("pagefile")} + +
+
+
+
+
+ `; + const _q = (s) => contentDiv.querySelector(s); + // .aimdo-seg-other: [0]=RAM block, [1]=VRAM block + const _others = contentDiv.querySelectorAll(".aimdo-seg-other"); + contentDiv._refs = { + ramUsage: _q(".content-ram-usage"), + ramSegs: [_q(".aimdo-seg-pinned"), _q(".aimdo-seg-loadedRam"), _q(".aimdo-seg-python"), _others[0]], + ramTexts: { + pinned: _q(".content-ram-pinned-txt"), + loaded: _q(".content-ram-loaded-txt"), + python: _q(".content-ram-python-txt"), + other: _q(".content-ram-other-txt"), + }, + ramLegend: _q(".content-ram-legend"), + vramLabel: _q(".content-vram-label"), + vramUsage: _q(".content-vram-usage"), + vramSegs: [_q(".aimdo-seg-vram"), _q(".aimdo-seg-torch"), _q(".aimdo-seg-torchCache"), _others[1]], + vramWraps: { + models: _q(".content-vram-models-wrap"), + torch: _q(".content-vram-torch-wrap"), + cache: _q(".content-vram-cache-wrap"), + }, + vramTexts: { + models: _q(".content-vram-models-txt"), + torch: _q(".content-vram-torch-txt"), + cache: _q(".content-vram-cache-txt"), + other: _q(".content-vram-other-txt"), + }, + vramLegend: _q(".content-vram-legend"), + infoPeak: _q(".content-info-peak"), + infoCache: _q(".content-info-cache"), + infoGpu: _q(".content-info-gpu"), + infoTemp: _q(".content-info-temp"), + infoPower: _q(".content-info-power"), + infoState: _q(".content-info-state"), + pagefileSection: _q(".content-pagefile-section"), + pagefileUsage: _q(".content-pagefile-usage"), + pagefileSegs: contentDiv.querySelectorAll(".content-pagefile-bar > .aimdo-seg"), + }; + body.appendChild(contentDiv); + + const graphHeader = document.createElement("div"); + graphHeader.style.cssText = `display:flex;justify-content:space-between;font-size:0.75em;color:var(--aimdo-textDim);margin-bottom:2px;flex-shrink:0;`; + graphHeader.innerHTML = ``; + body.appendChild(graphHeader); + + const graphCanvas = document.createElement("canvas"); + graphCanvas.className = "aimdo-graph-canvas"; + graphCanvas.width = 300; + graphCanvas.height = graphHeight; + graphCanvas.style.cssText = `width:100%;height:${graphHeight}px;border-radius:3px;background:var(--aimdo-graphBg);flex-shrink:0;`; + body.appendChild(graphCanvas); + + const redrawGraph = () => { + if (!refs || !refs.graphCtx) return; + const panelScale = (body._panel && body._panel._scale) || 1; + const gRect = graphCanvas.getBoundingClientRect(); + if (gRect.width > 0 && gRect.height > 0) { + drawGraph(refs.graphCtx, gRect.width / panelScale, gRect.height / panelScale); + } + updateGraphTimes(); + }; + // rAF-coalesce bursts from mousemove (hover + scrub) so we draw at most once per frame + let redrawPending = false; + const scheduleRedraw = () => { + if (redrawPending) return; + redrawPending = true; + requestAnimationFrame(() => { redrawPending = false; redrawGraph(); }); + }; + + graphCanvas.addEventListener("mousemove", (e) => { + if (scrubDrag) return; // hover line during drag is distracting + const rect = graphCanvas.getBoundingClientRect(); + const scale = (body._panel && body._panel._scale) || 1; + const x = (e.clientX - rect.left) / scale; + const w = rect.width / scale; + const stepX = w / (GRAPH_POINTS - 1); + const slotIdx = Math.round(x / stepX); + const len = history.len; + const visible = Math.min(GRAPH_POINTS, len - history.viewOffset); + const visibleIdx = slotIdx - (GRAPH_POINTS - visible); + if (visible < 1 || visibleIdx < 0 || visibleIdx >= visible) { + graphHover.x = null; + graphHover.idx = null; + } else { + const startIdx = len - visible - history.viewOffset; + graphHover.idx = startIdx + visibleIdx; + graphHover.x = slotIdx * stepX; + } + scheduleRedraw(); + }); + graphCanvas.addEventListener("mouseleave", () => { + graphHover.x = null; + graphHover.idx = null; + redrawGraph(); + }); + + // drag scrubbing: dragging the full visible window's worth scrolls by GRAPH_POINTS. + // Drag right pulls older points into view; releasing at viewOffset 0 re-enables + // follow-live so new data slides into the window automatically. + let scrubDrag = null; + graphCanvas.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + const visualScale = (body._panel && body._panel._scale) || 1; + scrubDrag = { startX: e.clientX, startOffset: history.viewOffset, scale: visualScale }; + history.followLive = false; + graphCanvas.style.cursor = "grabbing"; + }); + document.addEventListener("mousemove", (e) => { + if (!scrubDrag) return; + const rect = graphCanvas.getBoundingClientRect(); + const dxPx = (e.clientX - scrubDrag.startX) / scrubDrag.scale; + const ptsPerPx = GRAPH_POINTS / Math.max(1, rect.width / scrubDrag.scale); + const maxOffset = Math.max(0, history.len - GRAPH_POINTS); + history.viewOffset = Math.max(0, Math.min(maxOffset, Math.round(scrubDrag.startOffset + dxPx * ptsPerPx))); + scheduleRedraw(); + }); + document.addEventListener("mouseup", () => { + if (!scrubDrag) return; + scrubDrag = null; + graphCanvas.style.cursor = ""; // restore to CSS-default crosshair + if (history.viewOffset <= 1) { + history.viewOffset = 0; + history.followLive = true; + } + redrawGraph(); + }); + + // drag handle below the graph — adjusts canvas height; modelsDiv takes the rest. + const graphResize = document.createElement("div"); + graphResize.className = "aimdo-graph-resize"; + graphResize.title = _t("dragResizeGraph"); + let graphDrag = null; + graphResize.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + graphDrag = { startY: e.clientY, startHeight: graphCanvas.getBoundingClientRect().height }; + graphResize.classList.add("is-dragging"); + }); + document.addEventListener("mousemove", (e) => { + if (!graphDrag) return; + const visualScale = (body._panel && body._panel._scale) || 1; + const delta = (e.clientY - graphDrag.startY) / visualScale; + const next = Math.max(20, Math.min(600, graphDrag.startHeight / visualScale + delta)); + graphHeight = Math.round(next); + graphCanvas.style.height = graphHeight + "px"; + }); + document.addEventListener("mouseup", () => { + if (graphDrag) { + graphDrag = null; + graphResize.classList.remove("is-dragging"); + saveState({ graphHeight }); + } + }); + body.appendChild(graphResize); + + const modelsDiv = document.createElement("div"); + modelsDiv.id = "aimdo-models"; + modelsDiv.style.cssText = "flex: 1 1 auto; min-height: 0; overflow-y: auto;"; + body.appendChild(modelsDiv); + + refs = { + contentDiv, + graphHeader, + graphCanvas, + graphResize, + graphCtx: graphCanvas.getContext("2d"), + modelsDiv, + pageCanvases: {}, // keyed by `${index}_${vi}` + pageCtxs: {}, + modelRows: {}, // keyed by m.index — refs to mutable row parts + noModelsMsg: null, + bottomLegend: null, + }; + return refs; +} + +function updateGraphTimes() { + if (!refs || !refs.graphHeader) return; + refs.graphHeader.style.color = "var(--aimdo-textDim)"; + const leftEl = refs.graphHeader.querySelector(".graph-time-left"); + const hoverEl = refs.graphHeader.querySelector(".graph-hover-info"); + const rightEl = refs.graphHeader.querySelector(".graph-time-right"); + const len = history.len; + if (len < 2) { + leftEl.textContent = ""; + hoverEl.textContent = ""; + rightEl.textContent = ""; + return; + } + const visible = Math.min(GRAPH_POINTS, len - history.viewOffset); + if (visible < 1) { + leftEl.textContent = ""; + hoverEl.textContent = ""; + rightEl.textContent = ""; + return; + } + const startIdx = len - visible - history.viewOffset; + const endIdx = len - 1 - history.viewOffset; + leftEl.textContent = formatClock(historyGet(history.times, startIdx)); + rightEl.textContent = history.followLive ? _t("live") : formatClock(historyGet(history.times, endIdx)); + if (graphHover.idx != null) { + const used = history.total_vram - historyGet(history.free_vram, graphHover.idx); + const parts = [ + formatClock(historyGet(history.times, graphHover.idx)), + formatBytes(used), + ]; + if (graphState.gpuLineVisible) parts.push(Math.round(historyGet(history.gpu_util, graphHover.idx)) + "%"); + hoverEl.textContent = parts.join(" · "); + } else { + hoverEl.textContent = ""; + } +} + +function applyRowCollapsed(row) { + row.bar.style.display = row.collapsed ? "none" : "flex"; + row.legend.style.display = (row.collapsed || !showLegends) ? "none" : "flex"; + row.vbarsDiv.style.display = row.collapsed ? "none" : ""; + row.chevron.textContent = row.collapsed ? "▸" : "▾"; +} + +// build (or reuse) a model row, mutating only what changed +function renderModelRow(r, m, data) { + const wantsWm = m.dynamic && data.aimdo_active; + let row = r.modelRows[m.index]; + if (!row) { + const el = document.createElement("div"); + el.className = "aimdo-model-row"; + const head = document.createElement("div"); + head.className = "aimdo-model-head"; + const nameWrap = document.createElement("span"); + nameWrap.className = "aimdo-model-name"; + nameWrap.title = _t("clickCollapse"); + const chevron = document.createElement("span"); + chevron.className = "aimdo-model-chevron"; + const nameSpan = document.createElement("span"); + nameWrap.appendChild(chevron); + nameWrap.appendChild(nameSpan); + const right = document.createElement("span"); + right.className = "aimdo-model-right"; + const sizeSpan = document.createElement("span"); + right.appendChild(sizeSpan); + const unloadBtn = document.createElement("span"); + unloadBtn.className = "aimdo-unload-btn"; + unloadBtn.dataset.index = m.index; + unloadBtn.textContent = "x"; + unloadBtn.title = _t("unloadModel"); + right.appendChild(unloadBtn); + head.appendChild(nameWrap); + head.appendChild(right); + el.appendChild(head); + const bar = document.createElement("div"); + bar.className = "aimdo-model-bar"; + el.appendChild(bar); + const legend = document.createElement("div"); + legend.className = "aimdo-model-legend"; + const makeLegendEntry = () => { + const wrap = document.createElement("span"); + const sw = document.createElement("span"); + sw.innerHTML = "■"; + const txt = document.createTextNode(""); + wrap.appendChild(sw); + wrap.appendChild(txt); + return { wrap, sw, txt }; + }; + const legendEntries = { + vram: makeLegendEntry(), + pinned: makeLegendEntry(), + loaded: makeLegendEntry(), + last: makeLegendEntry(), // "unloaded" for dynamic models, "RAM" for static + }; + legendEntries.pinned.sw.style.color = "var(--aimdo-pinned)"; + legendEntries.loaded.sw.style.color = "var(--aimdo-loadedRam)"; + legend.appendChild(legendEntries.vram.wrap); + legend.appendChild(legendEntries.pinned.wrap); + legend.appendChild(legendEntries.loaded.wrap); + legend.appendChild(legendEntries.last.wrap); + el.appendChild(legend); + const vbarsDiv = document.createElement("div"); + el.appendChild(vbarsDiv); + row = { el, chevron, nameSpan, sizeSpan, right, unloadBtn, bar, barSegs: [], legend, legendEntries, vbarsDiv, vbarRefs: [], wmBtn: null, lastDynamic: null, lastVbarSig: "", collapsed: false }; + nameWrap.addEventListener("click", () => { + row.collapsed = !row.collapsed; + modelCollapsed[m.name] = row.collapsed; + saveState({ modelCollapsed }); + applyRowCollapsed(row); + }); + row.collapsed = !!modelCollapsed[m.name]; + applyRowCollapsed(row); + r.modelRows[m.index] = row; + } + + row.nameSpan.textContent = m.name + (m.dynamic ? "" : ` (${_t("static_")})`); + const typeColor = MODEL_TYPE_COLOR[m.type]; + const vramColor = (colorModelBars && typeColor) || C.vram; + row.nameSpan.style.color = (colorModelName && typeColor) || C.text; + row.el.style.borderColor = (colorModelStroke && typeColor) ? hexToRgba(typeColor, 0.4) : "transparent"; + if (!row.collapsed) row.legend.style.display = showLegends ? "flex" : "none"; + row.sizeSpan.textContent = formatBytes(m.total_size); + + if (wantsWm && !row.wmBtn) { + const wm = document.createElement("span"); + wm.className = "aimdo-reset-wm-btn"; + wm.dataset.index = m.index; + wm.textContent = _t("wm"); + wm.title = _t("wmTitle"); + row.right.insertBefore(wm, row.unloadBtn); + row.wmBtn = wm; + } else if (!wantsWm && row.wmBtn) { + row.wmBtn.remove(); + row.wmBtn = null; + } + + const barColors = m.dynamic ? [C.vram, C.pinned, C.loadedRam, C.unloaded] : [C.vram, C.pinned, C.loadedRam, C.pinned]; + if (row.lastDynamic !== m.dynamic || row.barSegs.length !== barColors.length) { + row.bar.innerHTML = ""; + row.barSegs = []; + for (const color of barColors) { + const seg = document.createElement("div"); + seg.style.cssText = `background:${color};height:100%;`; + row.bar.appendChild(seg); + row.barSegs.push(seg); + } + row.lastDynamic = m.dynamic; + } + // re-apply each tick so theme changes reach the segments created once on dynamic-change rebuild. + row.barSegs.forEach((seg, i) => { seg.style.background = i === 0 ? vramColor : barColors[i]; }); + + const total = m.total_size || 1; + const pinnedRam = m.pinned_ram || 0; + const loadedRam = m.loaded_ram || 0; + let vramShown, lastSize, lastLabel, lastColor, lastAlwaysShow; + if (m.dynamic) { + vramShown = m.vbar_loaded; + lastSize = Math.max(0, m.total_size - m.vbar_loaded - pinnedRam - loadedRam); + lastLabel = _t("unloaded"); + lastColor = "var(--aimdo-unloaded)"; + lastAlwaysShow = true; + } else { + vramShown = m.loaded_size; + const inRam = Math.max(0, m.total_size - m.loaded_size); + lastSize = Math.max(0, inRam - pinnedRam - loadedRam); + lastLabel = _t("ramLabel"); + lastColor = "var(--aimdo-pinned)"; + lastAlwaysShow = false; + } + row.barSegs[0].style.width = (vramShown / total * 100) + "%"; + row.barSegs[0].title = _t("vramLabel") + ": " + formatBytes(vramShown); + row.barSegs[1].style.width = (pinnedRam / total * 100) + "%"; + row.barSegs[1].title = _t("pinnedRAM") + ": " + formatBytes(pinnedRam); + row.barSegs[2].style.width = (loadedRam / total * 100) + "%"; + row.barSegs[2].title = _t("loadedRAM") + ": " + formatBytes(loadedRam); + row.barSegs[3].style.width = (lastSize / total * 100) + "%"; + row.barSegs[3].title = (m.dynamic ? _t("unloaded") + ": " : _t("ramTooltip") + ": ") + formatBytes(lastSize); + + const le = row.legendEntries; + le.vram.sw.style.color = vramColor; + le.vram.txt.nodeValue = ` ${_t("vramLabel")} ${formatBytes(vramShown)}`; + le.pinned.wrap.style.display = pinnedRam > 0 ? "" : "none"; + if (pinnedRam > 0) le.pinned.txt.nodeValue = ` ${_t("pinned")} ${formatBytes(pinnedRam)}`; + le.loaded.wrap.style.display = loadedRam > 0 ? "" : "none"; + if (loadedRam > 0) le.loaded.txt.nodeValue = ` ${_t("loaded")} ${formatBytes(loadedRam)}`; + const showLast = lastAlwaysShow || lastSize > 0; + le.last.wrap.style.display = showLast ? "" : "none"; + if (showLast) { + le.last.sw.style.color = lastColor; + le.last.txt.nodeValue = ` ${lastLabel} ${formatBytes(lastSize)}`; + } + + // vbars: rebuild structure only when device list / count changes + const vbars = (m.vbars || []).filter(v => v.residency && v.residency.length > 0); + const sig = vbars.map(v => v.device + ":" + v.residency.length).join("|"); + if (row.lastVbarSig !== sig) { + row.vbarsDiv.innerHTML = ""; + row.vbarRefs = []; + const showLabel = vbars.length > 1; + for (let vi = 0; vi < vbars.length; vi++) { + const vb = vbars[vi]; + if (showLabel) { + const lbl = document.createElement("div"); + lbl.style.cssText = `font-size:0.833em;color:var(--aimdo-textDim);margin-top:3px;`; + lbl.textContent = vb.device; + row.vbarsDiv.appendChild(lbl); + } + const pgrid = document.createElement("div"); + pgrid.style.cssText = "margin-top:2px;"; + row.vbarsDiv.appendChild(pgrid); + const stats = document.createElement("div"); + stats.style.cssText = `color:var(--aimdo-textDim);font-size:0.833em;margin-top:2px;`; + const vramSw = document.createElement("span"); vramSw.innerHTML = "■"; + const vramTxt = document.createTextNode(""); + const vramWrap = document.createElement("span"); + vramWrap.appendChild(vramSw); vramWrap.appendChild(vramTxt); + const unloadedSw = document.createElement("span"); + unloadedSw.style.color = "var(--aimdo-unloaded)"; + unloadedSw.innerHTML = "■"; + const unloadedTxt = document.createTextNode(""); + const unloadedWrap = document.createElement("span"); + unloadedWrap.appendChild(unloadedSw); unloadedWrap.appendChild(unloadedTxt); + stats.appendChild(vramWrap); + stats.appendChild(document.createTextNode(" ")); + stats.appendChild(unloadedWrap); + row.vbarsDiv.appendChild(stats); + row.vbarRefs.push({ vi, pgrid, stats, vramSw, vramTxt, unloadedTxt }); + } + row.lastVbarSig = sig; + } + return row; +} + +function renderData(body, data) { + if (!data.enabled) { + body.innerHTML = `
${_t("noGpu")}
`; + refs = null; + return; + } + + const r = ensureStructure(body); + const pw = body._panel.getBoundingClientRect().width; + body._titleSpan.style.display = showTitle ? "" : "none"; + body._titleSpan.textContent = + pw >= 320 && data.aimdo_active ? _t("titleAimdo") : + pw >= 240 ? _t("title") : ""; + data.gpu_util = smoothGpuUtil(data.gpu_util); + // throttle history snapshots so a fast poll rate (e.g. 100 ms) doesn't fill + // the buffer in 2 minutes; UI keeps updating at the full poll cadence. + if (Date.now() - (history.lastPush || 0) >= HISTORY_TICK_MS) { + pushHistory(data); + history.lastPush = Date.now(); + } + + const used = data.total_vram - data.free_vram; + if (used > peakVramUsed) peakVramUsed = used; + + // aimdo allocates through pytorch's caching allocator, so aimdo_usage + // and torch_reserved overlap. Derive non-overlapping segments from + // the driver-level total (used) as ground truth. + let aimdo, torchActive, torchCache, otherUsed; + if (data.aimdo_usage > 0) { + // aimdo active: torch stats are a subset of aimdo, not additive + aimdo = data.aimdo_usage; + torchActive = 0; + torchCache = 0; + otherUsed = Math.max(0, used - aimdo); + } else { + // no aimdo: torch stats are the full picture + aimdo = 0; + torchActive = data.torch_active; + torchCache = Math.max(0, data.torch_reserved - data.torch_active); + otherUsed = Math.max(0, used - data.torch_reserved); + } + const aimdoPct = (aimdo / data.total_vram * 100).toFixed(0); + const torchPct = (torchActive / data.total_vram * 100).toFixed(0); + const torchCachePct = (torchCache / data.total_vram * 100).toFixed(0); + const otherPct = (otherUsed / data.total_vram * 100).toFixed(0); + + const ramUsed = data.used_ram || 0; + const ramTotal = data.total_ram || 1; + const processRam = data.process_ram || 0; + const pinnedRamTotal = data.pinned_ram || 0; + const loadedRamTotal = data.loaded_ram || 0; + const pythonOther = Math.max(0, processRam - pinnedRamTotal - loadedRamTotal); + const ramOther = Math.max(0, ramUsed - processRam); + const pinnedRamPct = (pinnedRamTotal / ramTotal * 100).toFixed(0); + const loadedRamPct = (loadedRamTotal / ramTotal * 100).toFixed(0); + const pythonOtherPct = (pythonOther / ramTotal * 100).toFixed(0); + const ramOtherPct = (ramOther / ramTotal * 100).toFixed(0); + + const mb = body._miniBar; + const m = mb._refs; // refs cached at panel creation (see mbRefs in createPanel) + const _u = miniShowUnits; + const _n = miniShowNumbers; + // "%" isn't a unit — units off should still show ratios as percentages, just + // without GB/MB/°C/W. Quantities with a denominator (RAM/VRAM/power) become + // "used/total" percentages; CPU/GPU util are already percentages so the "%" + // stays on regardless; temp has no natural denominator so we drop the °C. + // leading zero on single-digit values keeps width stable, matching the CPU/GPU util format + const asPct = (num, total) => { + if (total <= 0) return "?"; + const p = Math.round(num / total * 100); + return (p < 10 ? "0" : "") + p + "%"; + }; + m.vramUsage.textContent = !_n ? "" : + _u ? `${formatBytes(used)}|${formatBytes(data.total_vram)}` + : asPct(used, data.total_vram); + m.vramSegs[0].style.width = aimdoPct + "%"; + m.vramSegs[1].style.width = torchPct + "%"; + m.vramSegs[2].style.width = torchCachePct + "%"; + m.vramSegs[3].style.width = otherPct + "%"; + m.ramUsage.textContent = !_n ? "" : + _u ? `${formatBytes(ramUsed)}|${formatBytes(ramTotal)}` + : asPct(ramUsed, ramTotal); + m.ramSegs[0].style.width = pinnedRamPct + "%"; + m.ramSegs[1].style.width = loadedRamPct + "%"; + m.ramSegs[2].style.width = pythonOtherPct + "%"; + m.ramSegs[3].style.width = ramOtherPct + "%"; + + // toggleable "Type" label hides RAM / VRAM / CPU / GPU prefixes (plus any device suffix) + m.ramLabel.style.display = miniShowType ? "" : "none"; + m.vramLabel.style.display = miniShowType ? "" : "none"; + + m.bar.classList.toggle("has-separators", miniShowSeparators); + m.ramSection.style.display = showRamInMini ? "" : "none"; + m.vramSection.style.display = showVramInMini ? "" : "none"; + if (data.cpu_util != null && showCpuInMini) { + m.cpuSection.style.display = ""; + const cpuColor = gpuUtilColor(data.cpu_util); + const cpuPct = Math.round(data.cpu_util); + m.cpuUsage.innerHTML = _n + ? `${(cpuPct < 10 ? "0" : "") + cpuPct}%` + : ""; + m.cpuFill.style.background = cpuColor; + m.cpuFill.style.width = `${cpuPct}%`; + m.cpuLabel.textContent = (showHwNames && data.cpu_name) ? `CPU (${shortenCpuName(data.cpu_name)})` : "CPU"; + m.cpuLabel.title = data.cpu_name || ""; + m.cpuLabel.style.display = miniShowType ? "" : "none"; + } else { + m.cpuSection.style.display = "none"; + } + if (showDiskInMini && data.disk_read != null && data.disk_write != null) { + m.diskSection.style.display = ""; + const now = performance.now(); + let readRate = 0, writeRate = 0; + if (diskState.prevRead != null && diskState.prevTime > 0) { + const dt = (now - diskState.prevTime) / 1000; + if (dt > 0) { + readRate = Math.max(0, (data.disk_read - diskState.prevRead) / dt); + writeRate = Math.max(0, (data.disk_write - diskState.prevWrite) / dt); + const decay = Math.pow(0.5, dt / DISK_PEAK_HALFLIFE); + diskState.peakRead = Math.max(readRate, diskState.peakRead * decay, DISK_PEAK_FLOOR); + diskState.peakWrite = Math.max(writeRate, diskState.peakWrite * decay, DISK_PEAK_FLOOR); + } + } + diskState.prevRead = data.disk_read; + diskState.prevWrite = data.disk_write; + diskState.prevTime = now; + m.diskReadUsage.textContent = _n ? (formatBytes(readRate) + "/s ↓") : ""; + m.diskWriteUsage.textContent = _n ? (formatBytes(writeRate) + "/s ↑") : ""; + m.diskReadFill.style.width = (readRate / diskState.peakRead * 100) + "%"; + m.diskWriteFill.style.width = (writeRate / diskState.peakWrite * 100) + "%"; + m.diskLabel.style.display = miniShowType ? "" : "none"; + } else { + m.diskSection.style.display = "none"; + diskState.prevRead = null; + diskState.prevTime = 0; + } + // Free disk space — one row per selected drive, with all detected drives in + // the tooltip so the user sees the full picture without checking every box. + if (Array.isArray(data.disks)) { + const oldMps = allDisksCache.map(d => d.mountpoint).join("|"); + const newMps = data.disks.map(d => d.mountpoint).join("|"); + allDisksCache = data.disks; + if (_rebuildDriveMenu && oldMps !== newMps) _rebuildDriveMenu(); + } + const visibleDisks = selectedDisks.length + ? allDisksCache.filter(d => selectedDisks.includes(d.mountpoint)) + : []; + if (visibleDisks.length > 0) { + m.diskSpaceSection.style.display = ""; + m.diskSpaceSection.classList.toggle("no-units", !_u); + const rows = m.diskSpaceRows; + while (rows.children.length < visibleDisks.length) { + const row = document.createElement("div"); + row.className = "aimdo-mini-inline mini-diskspace-drive-row"; + row.innerHTML = `` + + `
` + + ``; + rows.appendChild(row); + } + while (rows.children.length > visibleDisks.length) rows.lastChild.remove(); + for (let i = 0; i < visibleDisks.length; i++) { + const d = visibleDisks[i]; + const row = rows.children[i]; + const mpEl = row.firstChild; + const fillEl = row.querySelector(".mini-diskspace-fill"); + const usageEl = row.lastChild; + const total = d.total, free = d.free; + const used = (total != null && free != null) ? total - free : null; + const pct = (used != null && total) ? (used / total * 100) : 0; + mpEl.textContent = shortMountpoint(d.mountpoint); + fillEl.style.width = pct.toFixed(1) + "%"; + // colour warms as the drive fills — same scale as gpuUtilColor (10/80 thresholds + // are util-shaped; for free space "low → warmer" maps better at 75/90). + fillEl.style.background = pct >= 90 ? C.gpuUtilHi : (pct >= 75 ? C.gpuUtil : C.pinned); + usageEl.textContent = !_n ? "" : + (free == null || !total) ? "?" : + _u ? formatBytes(free) + : asPct(used, total); + } + m.diskSpaceSection.title = buildDiskTooltip(allDisksCache); + m.diskSpaceLabel.style.display = miniShowType ? "" : "none"; + } else { + m.diskSpaceSection.style.display = "none"; + } + if (data.total_swap > 0 && showPagefileInMini) { + m.pagefileSection.style.display = ""; + const swapPct = Math.round(data.used_swap / data.total_swap * 100); + m.pagefileUsage.textContent = _n + ? (_u ? `${formatBytes(data.used_swap)}|${formatBytes(data.total_swap)}` : (swapPct < 10 ? "0" : "") + swapPct + "%") + : ""; + m.pagefileSegs[0].style.width = (data.used_swap / data.total_swap * 100) + "%"; + m.pagefileLabel.style.display = miniShowType ? "" : "none"; + } else { + m.pagefileSection.style.display = "none"; + } + if (showFaultsInMini && data.hard_faults != null) { + m.faultsSection.style.display = ""; + const now = performance.now(); + let rate = 0; + if (faultState.prev != null && faultState.prevTime > 0) { + const dt = (now - faultState.prevTime) / 1000; + if (dt > 0) { + rate = Math.max(0, (data.hard_faults - faultState.prev) / dt); + const decay = Math.pow(0.5, dt / FAULT_PEAK_HALFLIFE); + faultState.peak = Math.max(rate, faultState.peak * decay, FAULT_PEAK_FLOOR); + } + } + faultState.prev = data.hard_faults; + faultState.prevTime = now; + // bar height is rate vs recent peak; fixed color (height conveys activity) + m.faultsFill.style.width = (rate / faultState.peak * 100) + "%"; + m.faultsUsage.textContent = _n ? `${Math.round(rate)}/s` : ""; + m.faultsLabel.style.display = miniShowType ? "" : "none"; + } else { + m.faultsSection.style.display = "none"; + faultState.prev = null; + faultState.prevTime = 0; + } + // each bar is independently toggleable; null data shows "N/A" rather than hiding, + // so a missing pynvml doesn't make the whole section vanish silently. + const _showUtil = showGpuInMini; + const _showTemp = miniShowGpuTemp; + const _showPower = miniShowGpuPower; + const _utilOk = data.gpu_util != null; + const _tempOk = data.gpu_temp != null; + const _powerOk = data.gpu_power != null; + const _activeBars = (_showUtil ? 1 : 0) + (_showTemp ? 1 : 0) + (_showPower ? 1 : 0); + if (_activeBars > 0) { + m.gpuSection.style.display = ""; + // compact 8px / 3px styling kicks in only when there's >1 bar to fit + const isSingleBar = _activeBars === 1; + m.gpuSection.classList.toggle("is-multibar", !isSingleBar); + + // title row — "GPU" or "GPU (RTX 4090)" on the left, value on the right when + // only one bar is visible (then the layout matches RAM/VRAM/CPU above). + m.gpuLabel.textContent = (showHwNames && data.gpu_name) ? `GPU (${shortenGpuName(data.gpu_name)})` : "GPU"; + m.gpuLabel.title = data.gpu_name || ""; + m.gpuLabel.style.display = miniShowType ? "" : "none"; + // keep the row visible if either the label is on, or there's a single-bar value to show + m.gpuRow.style.display = (miniShowType || isSingleBar) ? "" : "none"; + + // util row + m.utilRow.style.display = _showUtil ? "" : "none"; + if (_showUtil) { + if (_utilOk) { + const gpuColor = gpuUtilColor(data.gpu_util); + m.gpuFill.style.background = gpuColor; + m.gpuFill.style.width = `${data.gpu_util}%`; + m.gpuUsage.innerHTML = _n + ? `${(data.gpu_util < 10 ? "0" : "") + data.gpu_util}%` + : ""; + } else { + m.gpuFill.style.width = "0%"; + m.gpuUsage.textContent = _t("na"); + } + } + + // temp row — 100°C is full scale; units-off uses "%" since the bar already + // treats 100°C as the denominator (75°C → 75% of the bar full). + m.tempRow.style.display = _showTemp ? "" : "none"; + if (_showTemp) { + if (_tempOk) { + const tempColor = gpuTempColor(data.gpu_temp); + m.tempFill.style.background = tempColor; + m.tempFill.style.width = `${Math.min(100, data.gpu_temp)}%`; + const tempDisplay = _u ? `${data.gpu_temp}°C` : `${Math.min(100, data.gpu_temp)}%`; + m.tempUsage.innerHTML = _n + ? `${tempDisplay}` + : ""; + } else { + m.tempFill.style.width = "0%"; + m.tempUsage.textContent = _t("na"); + } + } + + // power row — fill is draw/limit; value is W or % depending on the Units toggle + m.powerRow.style.display = _showPower ? "" : "none"; + if (_showPower) { + if (_powerOk) { + const powerColor = gpuPowerColor(data.gpu_power, data.gpu_power_limit); + m.powerFill.style.background = powerColor; + const powerPct = data.gpu_power_limit > 0 + ? Math.min(100, data.gpu_power / data.gpu_power_limit * 100) + : 0; + m.powerFill.style.width = `${powerPct}%`; + const powerText = _u + ? formatPower(data.gpu_power, data.gpu_power_limit) + : asPct(data.gpu_power, data.gpu_power_limit); + m.powerUsage.innerHTML = _n + ? `${powerText}` + : ""; + } else { + m.powerFill.style.width = "0%"; + m.powerUsage.textContent = _t("na"); + } + } + + // single-bar mode: lift the visible value into the title row so the section + // reads like RAM/VRAM/CPU above (label + value on top, bar below). + let headerHtml = ""; + if (isSingleBar) { + if (_showUtil) headerHtml = m.gpuUsage.innerHTML; + else if (_showTemp) headerHtml = m.tempUsage.innerHTML; + else if (_showPower) headerHtml = m.powerUsage.innerHTML; + } + m.gpuHeaderValue.innerHTML = headerHtml; + } else { + m.gpuSection.style.display = "none"; + } + + // Group dividers: mark the first visible section of each non-leading group + // so CSS can draw a separator above it. Recompute every render because the + // first visible can change as the user toggles individual sections. + function markFirstVisible(group) { + let found = false; + for (const sec of group) { + if (!found && sec.style.display !== "none") { + sec.classList.add("mini-group-start"); + found = true; + } else { + sec.classList.remove("mini-group-start"); + } + } + } + markFirstVisible([m.diskSection, m.diskSpaceSection]); + markFirstVisible([m.cpuSection, m.gpuSection]); + + const cr = r.contentDiv._refs; + cr.ramUsage.textContent = `${formatBytes(ramUsed)}|${formatBytes(ramTotal)}`; + cr.ramSegs[0].style.width = pinnedRamPct + "%"; + cr.ramSegs[0].title = _t("pinned") + ": " + formatBytes(pinnedRamTotal); + cr.ramSegs[1].style.width = loadedRamPct + "%"; + cr.ramSegs[1].title = _t("loaded") + ": " + formatBytes(loadedRamTotal); + cr.ramSegs[2].style.width = pythonOtherPct + "%"; + cr.ramSegs[2].title = _t("python") + ": " + formatBytes(pythonOther); + cr.ramSegs[3].style.width = ramOtherPct + "%"; + cr.ramSegs[3].title = _t("other") + ": " + formatBytes(ramOther); + cr.ramLegend.style.display = showLegends ? "flex" : "none"; + if (showLegends) { + cr.ramTexts.pinned.textContent = `${_t("pinned")} ${formatBytes(pinnedRamTotal)}`; + cr.ramTexts.loaded.textContent = `${_t("loaded")} ${formatBytes(loadedRamTotal)}`; + cr.ramTexts.python.textContent = `${_t("python")} ${formatBytes(pythonOther)}`; + cr.ramTexts.other.textContent = `${_t("other")} ${formatBytes(ramOther)}`; + } + + cr.vramLabel.textContent = _t("vram") + ((showHwNames && data.gpu_name) ? ` (${shortenGpuName(data.gpu_name)})` : ""); + cr.vramLabel.title = data.gpu_name || ""; + cr.vramUsage.textContent = `${formatBytes(used)}|${formatBytes(data.total_vram)}`; + cr.vramSegs[0].style.width = aimdoPct + "%"; + cr.vramSegs[0].title = _t("models") + ": " + formatBytes(aimdo); + cr.vramSegs[1].style.width = torchPct + "%"; + cr.vramSegs[1].title = _t("torch") + ": " + formatBytes(torchActive); + cr.vramSegs[2].style.width = torchCachePct + "%"; + cr.vramSegs[2].title = _t("cache") + ": " + formatBytes(torchCache); + cr.vramSegs[3].style.width = otherPct + "%"; + cr.vramSegs[3].title = _t("other") + ": " + formatBytes(otherUsed); + cr.vramLegend.style.display = showLegends ? "flex" : "none"; + if (showLegends) { + cr.vramWraps.models.style.display = aimdo > 0 ? "" : "none"; + if (aimdo > 0) cr.vramTexts.models.textContent = `${_t("models")} ${formatBytes(aimdo)}`; + cr.vramWraps.torch.style.display = torchActive > 0 ? "" : "none"; + if (torchActive > 0) cr.vramTexts.torch.textContent = `${_t("torch")} ${formatBytes(torchActive)}`; + cr.vramWraps.cache.style.display = torchCache > 0 ? "" : "none"; + if (torchCache > 0) cr.vramTexts.cache.textContent = `${_t("cache")} ${formatBytes(torchCache)}`; + cr.vramTexts.other.textContent = `${_t("other")} ${formatBytes(otherUsed)}`; + } + + cr.infoPeak.textContent = _t("peak") + ": " + formatBytes(peakVramUsed); + cr.infoCache.textContent = _t("cache") + ": " + formatBytes(data.torch_reserved - data.torch_active); + if (data.gpu_util != null) { + cr.infoGpu.style.display = ""; + cr.infoGpu.style.color = gpuUtilColor(data.gpu_util); + cr.infoGpu.style.opacity = graphState.gpuLineVisible ? "1" : "0.4"; + cr.infoGpu.textContent = `GPU ${data.gpu_util < 10 ? "0" : ""}${data.gpu_util}%`; + } else { + cr.infoGpu.style.display = "none"; + } + if (data.gpu_temp != null) { + cr.infoTemp.style.display = ""; + cr.infoTemp.style.color = gpuTempColor(data.gpu_temp); + cr.infoTemp.innerHTML = `${data.gpu_temp}°C`; + } else { + cr.infoTemp.style.display = "none"; + } + if (data.gpu_power != null) { + cr.infoPower.style.display = ""; + cr.infoPower.style.color = gpuPowerColor(data.gpu_power, data.gpu_power_limit); + cr.infoPower.textContent = formatPower(data.gpu_power, data.gpu_power_limit); + } else { + cr.infoPower.style.display = "none"; + } + if (execState.running) { + cr.infoState.style.color = "var(--aimdo-running)"; + cr.infoState.textContent = `● ${execState.node || _t("running")}${execState.progress ? " " + execState.progress : ""}`; + } else { + cr.infoState.style.color = ""; + cr.infoState.textContent = "● " + _t("idle"); + } + if (data.total_swap > 0) { + cr.pagefileSection.style.display = ""; + cr.pagefileSegs[0].style.width = (data.used_swap / data.total_swap * 100) + "%"; + cr.pagefileUsage.textContent = `${formatBytes(data.used_swap)}|${formatBytes(data.total_swap)}`; + } else { + cr.pagefileSection.style.display = "none"; + } + + // sync canvas backing to device pixels: visual viewport px × devicePixelRatio. + // Drawing then happens in logical (panel-local) CSS px via the totalScale transform, + // so cells/lines scale with panelScale while staying crisp on HiDPI. + const panelScaleNow = body._panel._scale || 1; + const dpr = window.devicePixelRatio || 1; + const totalScale = panelScaleNow * dpr; + const gRect = r.graphCanvas.getBoundingClientRect(); + if (gRect.width > 0 && gRect.height > 0) { + const backingW = Math.max(1, Math.round(gRect.width * dpr)); + const backingH = Math.max(1, Math.round(gRect.height * dpr)); + if (r.graphCanvas.width !== backingW) r.graphCanvas.width = backingW; + if (r.graphCanvas.height !== backingH) r.graphCanvas.height = backingH; + r.graphCtx.setTransform(totalScale, 0, 0, totalScale, 0, 0); + drawGraph(r.graphCtx, gRect.width / panelScaleNow, gRect.height / panelScaleNow); + updateGraphTimes(); + } + + // models section — incremental DOM updates: keep rows across polls, only mutate text/widths + if (data.models.length === 0 && !r.noModelsMsg) { + r.noModelsMsg = document.createElement("div"); + r.noModelsMsg.textContent = _t("noModels"); + r.noModelsMsg.style.cssText = `color:var(--aimdo-textDim);margin-top:6px;`; + r.modelsDiv.insertBefore(r.noModelsMsg, r.modelsDiv.firstChild); + } else if (data.models.length > 0 && r.noModelsMsg) { + r.noModelsMsg.remove(); + r.noModelsMsg = null; + } + + // remove rows for models no longer present and clean up their canvas refs + const liveIndices = new Set(data.models.map(m => m.index)); + for (const idx of Object.keys(r.modelRows)) { + if (!liveIndices.has(parseInt(idx))) { + r.modelRows[idx].el.remove(); + delete r.modelRows[idx]; + } + } + for (const key of Object.keys(r.pageCanvases)) { + const idx = parseInt(key.split("_")[0]); + if (!liveIndices.has(idx)) { + delete r.pageCanvases[key]; + delete r.pageCtxs[key]; + delete modelState[key]; + } + } + + if (!r.bottomLegend) { + r.bottomLegend = document.createElement("div"); + r.bottomLegend.style.cssText = `display:flex;flex-wrap:wrap;gap:8px;font-size:0.833em;color:var(--aimdo-textDim);margin-top:4px;border-bottom:1px solid var(--aimdo-border);padding-bottom:4px;`; + r.bottomLegend.innerHTML = + `■ ${_t("vram")}` + + `■ ${_t("pinned")}` + + `■ ${_t("loaded")}` + + `■ ${_t("unloaded")}` + + `■ ${_t("torch")}` + + `─ ${_t("total")}` + + `─ ${_t("gpuPct")}`; + r.modelsDiv.insertBefore(r.bottomLegend, r.modelsDiv.firstChild); + } + r.bottomLegend.style.display = showLegends ? "flex" : "none"; + + for (const m of data.models) { + const isNew = !r.modelRows[m.index]; + const row = renderModelRow(r, m, data); + if (isNew) r.modelsDiv.appendChild(row.el); + } + + // draw page grids and update vbar stat text + for (const m of data.models) { + const row = r.modelRows[m.index]; + if (!row || !row.vbarRefs.length || row.collapsed) continue; + const vbars = (m.vbars || []).filter(v => v.residency && v.residency.length > 0); + for (let vi = 0; vi < row.vbarRefs.length; vi++) { + const vb = vbars[vi]; + if (!vb) continue; + const ref = row.vbarRefs[vi]; + const vkey = `${m.index}_${ref.vi}`; + const st = diffResidency(vkey, vb.residency); + let residentCount = 0, pinnedCount = 0; + for (let i = 0; i < vb.residency.length; i++) { + const flag = vb.residency[i]; + if (flag & 2) pinnedCount++; + else if (flag & 1) residentCount++; + } + const PAGE = 32 * 1024 * 1024; + const vramPages = residentCount + pinnedCount; + const ramPages = vb.residency.length - vramPages; + const vramColor = (colorModelBars && MODEL_TYPE_COLOR[m.type]) || C.vram; + // swatch carries the category color; text inherits readable textDim. + ref.vramSw.style.color = vramColor; + ref.vramTxt.nodeValue = ` ${vramPages} ${_t("pageVRAM")} (${formatBytes(vramPages * PAGE)})`; + ref.unloadedTxt.nodeValue = ` ${ramPages} ${_t("pageUnloaded")} (${formatBytes(ramPages * PAGE)})`; + + let canvas = r.pageCanvases[vkey]; + if (!canvas) { + canvas = document.createElement("canvas"); + canvas.style.cssText = "width:100%;border-radius:2px;"; + r.pageCanvases[vkey] = canvas; + r.pageCtxs[vkey] = canvas.getContext("2d"); + } + if (canvas.parentElement !== ref.pgrid) ref.pgrid.appendChild(canvas); + const pgVisualW = ref.pgrid.getBoundingClientRect().width + || r.modelsDiv.getBoundingClientRect().width + || 300 * panelScaleNow; + const pgCssW = pgVisualW / panelScaleNow; + drawPageGrid(r.pageCtxs[vkey], pgCssW, vb.residency, st ? st.changeAge : new Uint8Array(vb.residency.length), panelScaleNow, colorModelBars ? MODEL_TYPE_COLOR[m.type] : undefined); + } + } + + // attach button handlers via event delegation (once) + if (!r.modelsDiv._delegated) { + r.modelsDiv._delegated = true; + r.modelsDiv.addEventListener("click", async (e) => { + const wmBtn = e.target.closest(".aimdo-reset-wm-btn"); + if (wmBtn) { + const idx = parseInt(wmBtn.dataset.index); + wmBtn.textContent = "..."; + try { + await api.fetchApi("/aimdo/reset_watermark", { + method: "POST", + body: JSON.stringify({ index: idx }), + headers: { "Content-Type": "application/json" }, + }); + } finally { + wmBtn.textContent = _t("wm"); + } + return; + } + const unloadBtn = e.target.closest(".aimdo-unload-btn"); + if (unloadBtn) { + const idx = parseInt(unloadBtn.dataset.index); + unloadBtn.textContent = "..."; + try { + await api.fetchApi("/aimdo/unload_model", { + method: "POST", + body: JSON.stringify({ index: idx }), + headers: { "Content-Type": "application/json" }, + }); + } catch { /* next poll will reflect state */ } + } + }); + } +} + +app.registerExtension({ + name: "aimdo.VRAMVisualization", + async setup() { + // ── 注册 ComfyUI 设置面板开关 ── + const SETTING_ID = "xb_toolbox.memory_viz.enabled"; + let panelEnabled = true; + + // 从 localStorage 读取初始状态(settings 系统可能还没就绪) + try { + const raw = localStorage.getItem("Comfy.Settings." + SETTING_ID); + if (raw !== null) panelEnabled = JSON.parse(raw) !== false; + } catch {} + + // 注册设置项(出现在 ComfyUI 设置 → XB_ToolBox 分类下) + if (app.ui && app.ui.settings) { + app.ui.settings.addSetting({ + id: SETTING_ID, + name: "XB 📊 硬件监控面板 (Memory Visualization)", + type: "boolean", + defaultValue: true, + category: ["XB_ToolBox", "硬件监控"], + onChange: (value) => { + panelEnabled = value; + const panel = document.getElementById("aimdo-viz-panel"); + if (panel) panel.style.display = value ? "" : "none"; + } + }); + } + + if (!panelEnabled) return; // 用户禁用了面板,不创建 + + // wait for aimdo_viz.css so applyPalette can read CSS variables back into C — + // canvas drawing needs real hex strings, not unresolved var() references. + await cssLoaded; + // load before wiring the periodic save so a stray tick can't overwrite the + // stored history with an empty buffer between extension boot and first poll. + await loadHistory(); + setInterval(saveHistory, 10000); + // beforeunload is best-effort with IDB: writes are async and may not commit + // before teardown. The 10s interval bounds loss to ~1% of the 20-min buffer. + window.addEventListener("beforeunload", saveHistory); + const body = createPanel(); + + api.addEventListener("execution_start", () => { + execState.running = true; + execState.node = null; + execState.progress = null; + pushExecEvent("start"); + body._updateExecBtnState?.(); + }); + api.addEventListener("executing", ({ detail }) => { + // detail is the node ID string while executing; null/undefined when done + // (older ComfyUI) or when interrupted. Newer versions may not fire null. + if (detail != null) { + execState.running = true; + execState.node = detail; + } + body._updateExecBtnState?.(); + }); + api.addEventListener("execution_interrupted", () => { + execState.running = false; + execState.node = null; + execState.progress = null; + pushExecEvent("end"); + body._updateExecBtnState?.(); + }); + // status event — reliable execution-end signal in newer ComfyUI (queue_remaining == 0) + api.addEventListener("status", ({ detail }) => { + if (detail && detail.exec_info && detail.exec_info.queue_remaining === 0) { + if (execState.running) { + execState.running = false; + execState.node = null; + execState.progress = null; + pushExecEvent("end"); + body._updateExecBtnState?.(); + } + } + }); + api.addEventListener("progress", ({ detail }) => { + if (detail) { + execState.progress = `${detail.value}/${detail.max}`; + } + }); + + let pollTimer = null; + async function poll() { + // Skip fetch when the tab is hidden or the panel is detached + if (document.hidden || !body.isConnected || !panelEnabled) { + pollTimer = setTimeout(poll, pollInterval); + return; + } + try { + const params = []; + if (showDiskInMini) params.push("disk=1"); + if (showPagefileInMini) params.push("pagefile=1"); + if (showFaultsInMini) params.push("faults=1"); + // list_disks drives both the visible bars (filtered by selectedDisks) + // and the tooltip listing every fixed drive. The one-shot + // wantDisksList flag lets the settings menu request a fresh list + // even when nothing is selected yet. + if (selectedDisks.length > 0 || wantDisksList) { + params.push("list_disks=1"); + wantDisksList = false; + } + const url = "/aimdo/vram" + (params.length ? "?" + params.join("&") : ""); + const resp = await api.fetchApi(url); + const data = await resp.json(); + renderData(body, data); + } catch (e) { + body.innerHTML = `
${_t("fetchError")}
`; + refs = null; + } + pollTimer = setTimeout(poll, pollInterval); + } + + // Wake immediately when the tab becomes visible + document.addEventListener("visibilitychange", () => { + if (!document.hidden && pollTimer != null) { + clearTimeout(pollTimer); + pollTimer = null; + poll(); + } + }); + + poll(); + } +}); \ No newline at end of file diff --git a/js/xb_memory_viz_graph.js b/js/xb_memory_viz_graph.js new file mode 100644 index 0000000..c9e69b2 --- /dev/null +++ b/js/xb_memory_viz_graph.js @@ -0,0 +1,623 @@ +// Graph subsystem: history ring buffer, persistence, drawGraph, graph submenu. +// +// External dependencies (palette, color utilities, persistence) are wired by +// initGraph(deps) — graph.js doesn't import from aimdo_viz.js so the dependency +// graph stays one-way (main → graph). + +let C = null; +let hexToRgb = null; +let hexToRgba = null; +let saveState = null; +let _t = null; + +export function initGraph(deps) { + C = deps.C; + hexToRgb = deps.hexToRgb; + hexToRgba = deps.hexToRgba; + saveState = deps.saveState; + _t = deps._t || ((k) => k); +} + +export const GRAPH_POINTS = 120; // visible window size on the canvas +export const HISTORY_BUFFER = 1200; // total points retained for scrollback (~20 min @ 1s) +export const HISTORY_TICK_MS = 1000; // min interval between history snapshots +const EXEC_EVENTS_MAX = 200; +const EXEC_NOOP_MS = 1500; // start→end shorter than this is treated as a no-op +const HISTORY_DB_NAME = "aimdo_viz"; +const HISTORY_DB_VERSION = 1; +const HISTORY_STORE = "kv"; +const HISTORY_KEY = "history"; +const HISTORY_SCHEMA = 1; // bump when the stored record shape changes +const GPU_SMOOTH_WINDOW = 3; + +// Mutable graph state. Both main and graph mutate via property assignment so +// `let` exports' read-only semantics don't get in the way. +export const graphState = { + gpuLineVisible: true, + graphStyle: "area", // "area" | "dots" | "ticker" | "bars" + graphVramColor: null, // null = use theme C.vram; otherwise hex override + graphTotalColor: null, // null = use theme C.totalLine; otherwise hex override + graphTotalSmoothness: 0, // 0 = raw, larger = wider moving-average window radius +}; + +export const history = { + torch_active: new Float64Array(HISTORY_BUFFER), + aimdo_usage: new Float64Array(HISTORY_BUFFER), + free_vram: new Float64Array(HISTORY_BUFFER), + gpu_util: new Float64Array(HISTORY_BUFFER), + times: new Float64Array(HISTORY_BUFFER), + total_vram: 1, + head: 0, + len: 0, + pushCount: 0, // monotonic total pushes; absolute sample id + viewOffset: 0, // points back from newest; 0 = right edge (live) + followLive: true, + execEvents: [], // {type: "start"|"end", time: ms} +}; + +// hover-line state for the graph; null when cursor is outside the data range. +export const graphHover = { x: null, idx: null }; + +const gpuRawBuf = []; +export function smoothGpuUtil(raw) { + // NVML util frequently dips to 0 mid-workload; peak-hold preserves real peaks. + if (raw == null) { + gpuRawBuf.length = 0; + return null; + } + gpuRawBuf.push(raw); + if (gpuRawBuf.length > GPU_SMOOTH_WINDOW) gpuRawBuf.shift(); + let m = 0; + for (const v of gpuRawBuf) if (v > m) m = v; + return m; +} + +export function pushExecEvent(type) { + if (type === "end" && history.execEvents.length > 0) { + const last = history.execEvents[history.execEvents.length - 1]; + if (last.type === "start" && Date.now() - last.time < EXEC_NOOP_MS) { + history.execEvents.pop(); + return; + } + } + history.execEvents.push({ type, time: Date.now() }); + if (history.execEvents.length > EXEC_EVENTS_MAX) { + history.execEvents.splice(0, history.execEvents.length - EXEC_EVENTS_MAX); + } +} + +export function pushHistory(data) { + history.total_vram = data.total_vram; + const i = history.head; + // store non-overlapping values matching the bar logic + if (data.aimdo_usage > 0) { + history.aimdo_usage[i] = data.aimdo_usage; + history.torch_active[i] = 0; + } else { + history.aimdo_usage[i] = 0; + history.torch_active[i] = data.torch_active; + } + history.free_vram[i] = data.free_vram; + history.gpu_util[i] = data.gpu_util != null ? data.gpu_util : 0; + history.times[i] = Date.now(); + history.head = (i + 1) % HISTORY_BUFFER; + if (history.len < HISTORY_BUFFER) history.len++; + history.pushCount++; + // when scrolled back, advance viewOffset so the user's pinned window keeps + // showing the same chronological data instead of sliding with new points. + if (!history.followLive) { + const maxOffset = Math.max(0, history.len - GRAPH_POINTS); + history.viewOffset = Math.min(maxOffset, history.viewOffset + 1); + } +} + +export function historyGet(arr, idx) { + // idx 0 = oldest valid, idx len-1 = newest + return arr[(history.head - history.len + idx + HISTORY_BUFFER) % HISTORY_BUFFER]; +} + +export function resetHistoryState() { + history.head = 0; + history.len = 0; + history.pushCount = 0; + history.viewOffset = 0; + history.followLive = true; + history.torch_active.fill(0); + history.aimdo_usage.fill(0); + history.free_vram.fill(0); + history.gpu_util.fill(0); + history.times.fill(0); + history.execEvents.length = 0; +} + +let _dbPromise = null; +function openHistoryDb() { + if (_dbPromise) return _dbPromise; + _dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(HISTORY_DB_NAME, HISTORY_DB_VERSION); + req.onupgradeneeded = () => req.result.createObjectStore(HISTORY_STORE); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + _dbPromise.catch(() => { _dbPromise = null; }); // allow retry after open failure + return _dbPromise; +} + +// cleanup the localStorage key for users upgrading from the previous version. +try { localStorage.removeItem("aimdo_viz_history"); } catch {} + +export async function saveHistory() { + if (history.len === 0) return; + try { + const db = await openHistoryDb(); + db.transaction(HISTORY_STORE, "readwrite").objectStore(HISTORY_STORE).put({ + v: HISTORY_SCHEMA, + head: history.head, + len: history.len, + buffer_size: HISTORY_BUFFER, + total_vram: history.total_vram, + times: history.times, + torch_active: history.torch_active, + aimdo_usage: history.aimdo_usage, + free_vram: history.free_vram, + gpu_util: history.gpu_util, + execEvents: history.execEvents, + }, HISTORY_KEY); + } catch (err) { + if (!saveHistory._warned) { + saveHistory._warned = true; + console.warn("aimdo-viz: history save failed", err); + } + } +} + +export async function clearHistoryStorage() { + try { + const db = await openHistoryDb(); + db.transaction(HISTORY_STORE, "readwrite").objectStore(HISTORY_STORE).delete(HISTORY_KEY); + } catch (err) { console.warn("aimdo-viz: history clear failed", err); } +} + +export async function loadHistory() { + try { + const db = await openHistoryDb(); + const data = await new Promise((resolve, reject) => { + const req = db.transaction(HISTORY_STORE, "readonly").objectStore(HISTORY_STORE).get(HISTORY_KEY); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + if (!data || data.v !== HISTORY_SCHEMA || typeof data.len !== "number") return; + if (!data.times || data.times.length !== HISTORY_BUFFER) return; + const copy = (target, source) => { if (source) target.set(source); }; + history.total_vram = data.total_vram || 1; + copy(history.times, data.times); + copy(history.torch_active, data.torch_active); + copy(history.aimdo_usage, data.aimdo_usage); + copy(history.free_vram, data.free_vram); + copy(history.gpu_util, data.gpu_util); + history.len = Math.min(data.len, HISTORY_BUFFER); + history.head = (typeof data.head === "number" ? data.head : history.len) % HISTORY_BUFFER; + history.pushCount = history.len; + if (Array.isArray(data.execEvents)) history.execEvents = data.execEvents.slice(-EXEC_EVENTS_MAX); + } catch (err) { console.warn("aimdo-viz: history load failed", err); } +} + +export function loadGraphSavedState(saved) { + if (typeof saved.gpuLineVisible === "boolean") graphState.gpuLineVisible = saved.gpuLineVisible; + if (typeof saved.graphStyle === "string") graphState.graphStyle = saved.graphStyle; + if (typeof saved.graphVramColor === "string") graphState.graphVramColor = saved.graphVramColor; + if (typeof saved.graphTotalColor === "string") graphState.graphTotalColor = saved.graphTotalColor; + if (typeof saved.graphTotalSmoothness === "number") { + graphState.graphTotalSmoothness = Math.max(0, Math.min(20, saved.graphTotalSmoothness | 0)); + } +} + +export function drawGraph(ctx, w, h) { + const total = history.total_vram; + const len = history.len; + if (len < 2) return; + + const visible = Math.min(GRAPH_POINTS, len - history.viewOffset); + if (visible < 2) return; + const startIdx = len - visible - history.viewOffset; + + ctx.clearRect(0, 0, w, h); + + ctx.strokeStyle = C.gridLine; + ctx.lineWidth = 1; + for (const pct of [0.25, 0.5, 0.75]) { + const y = Math.round(h - h * pct) + 0.5; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + + const stepX = w / (GRAPH_POINTS - 1); + const yFor = val => h - (val / total) * h; + const dataStartX = (GRAPH_POINTS - visible) * stepX; + const xFor = i => (GRAPH_POINTS - visible + i) * stepX; + const at = (arr, i) => historyGet(arr, startIdx + i); + + // smoothed total-used values, precomputed once per frame and shared by the total + // line, dots, and area-"other" layer so dots/areas stay at-or-below the line. + const smoothN = graphState.graphTotalSmoothness; + const totalValues = new Float64Array(visible); + for (let i = 0; i < visible; i++) totalValues[i] = total - at(history.free_vram, i); + let smoothedValues; + if (smoothN > 0 && visible > 0) { + smoothedValues = new Float64Array(visible); + let sum = 0, count = 0; + const initEnd = Math.min(visible - 1, smoothN); + for (let k = 0; k <= initEnd; k++) { sum += totalValues[k]; count++; } + smoothedValues[0] = sum / count; + for (let i = 1; i < visible; i++) { + const add = i + smoothN; + const drop = i - smoothN - 1; + if (add < visible) { sum += totalValues[add]; count++; } + if (drop >= 0) { sum -= totalValues[drop]; count--; } + smoothedValues[i] = sum / count; + } + } else { + smoothedValues = totalValues; + } + const valueAt = (i) => smoothedValues[i]; + + const style = graphState.graphStyle; + const vramHex = graphState.graphVramColor || C.vram; + if (style === "area") { + ctx.beginPath(); + ctx.moveTo(dataStartX, h); + for (let i = 0; i < visible; i++) { + ctx.lineTo(xFor(i), yFor(at(history.aimdo_usage, i))); + } + ctx.lineTo(xFor(visible - 1), h); + ctx.closePath(); + ctx.fillStyle = hexToRgba(vramHex, 0.35); + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(dataStartX, yFor(at(history.aimdo_usage, 0))); + for (let i = 0; i < visible; i++) { + ctx.lineTo(xFor(i), yFor(at(history.aimdo_usage, i) + at(history.torch_active, i))); + } + for (let i = visible - 1; i >= 0; i--) { + ctx.lineTo(xFor(i), yFor(at(history.aimdo_usage, i))); + } + ctx.closePath(); + ctx.fillStyle = hexToRgba(C.torch, 0.4); + ctx.fill(); + + const stackTop = (i) => at(history.aimdo_usage, i) + at(history.torch_active, i); + const topFor = (i) => Math.max(stackTop(i), valueAt(i)); + ctx.beginPath(); + ctx.moveTo(dataStartX, yFor(stackTop(0))); + for (let i = 0; i < visible; i++) { + ctx.lineTo(xFor(i), yFor(topFor(i))); + } + for (let i = visible - 1; i >= 0; i--) { + ctx.lineTo(xFor(i), yFor(stackTop(i))); + } + ctx.closePath(); + ctx.fillStyle = hexToRgba(C.other, 0.4); + ctx.fill(); + } else if (style === "bars") { + const groupSize = 3; + const barStep = stepX * groupSize; + const barW = Math.max(4, barStep - 4); + ctx.fillStyle = hexToRgba(vramHex, 0.55); + const newestAbs = history.pushCount - 1 - history.viewOffset; + const oldestAbs = newestAbs - visible + 1; + const firstGroupAbs = Math.floor(oldestAbs / groupSize) * groupSize; + const lastGroupAbs = Math.floor(newestAbs / groupSize) * groupSize; + for (let absG = firstGroupAbs; absG <= lastGroupAbs; absG += groupSize) { + const sStart = Math.max(absG, oldestAbs); + const sEnd = Math.min(absG + groupSize - 1, newestAbs); + let peak = 0; + for (let a = sStart; a <= sEnd; a++) { + const v = total - at(history.free_vram, a - oldestAbs); + if (v > peak) peak = v; + } + const centerRel = (absG - oldestAbs) + (groupSize - 1) / 2; + const cx = xFor(centerRel); + const y = yFor(peak); + ctx.fillRect(cx - barW / 2, y, barW, h - y); + } + } else if (style === "ticker") { + const groupSize = 3; + const candleStep = stepX * groupSize; + const barW = Math.max(5, candleStep - 3); + const up = "#10b981"; + const down = "#ef4444"; + const newestAbs = history.pushCount - 1 - history.viewOffset; + const oldestAbs = newestAbs - visible + 1; + const firstGroupAbs = Math.floor(oldestAbs / groupSize) * groupSize; + const lastGroupAbs = Math.floor(newestAbs / groupSize) * groupSize; + ctx.lineWidth = 1; + for (let absG = firstGroupAbs; absG <= lastGroupAbs; absG += groupSize) { + const sStart = Math.max(absG, oldestAbs); + const sEnd = Math.min(absG + groupSize - 1, newestAbs); + const oRel = sStart - oldestAbs; + const cRel = sEnd - oldestAbs; + const o = total - at(history.free_vram, oRel); + const c = total - at(history.free_vram, cRel); + const hiLoStartRel = Math.max(0, oRel - 1); + const hiLoEndRel = Math.min(visible - 1, cRel + 1); + let hi = o, lo = o; + for (let rel = hiLoStartRel; rel <= hiLoEndRel; rel++) { + const v = total - at(history.free_vram, rel); + if (v > hi) hi = v; + if (v < lo) lo = v; + } + const yO = yFor(o), yC = yFor(c); + const yTop = Math.min(yO, yC); + const bodyH = Math.max(1, Math.abs(yC - yO)); + const centerRel = (absG - oldestAbs) + (groupSize - 1) / 2; + const cx = xFor(centerRel); + const isUp = c >= o; + const color = isUp ? up : down; + const yBot = yTop + bodyH; + ctx.strokeStyle = color; + const wickX = Math.round(cx) + 0.5; + const yHi = yFor(hi); + const yLo = yFor(lo); + if (yHi < yTop) { + ctx.beginPath(); + ctx.moveTo(wickX, yHi); + ctx.lineTo(wickX, yTop); + ctx.stroke(); + } + if (yLo > yBot) { + ctx.beginPath(); + ctx.moveTo(wickX, yBot); + ctx.lineTo(wickX, yLo); + ctx.stroke(); + } + const bx = Math.round(cx - barW / 2); + if (isUp) { + ctx.strokeRect(bx + 0.5, Math.round(yTop) + 0.5, barW - 1, bodyH); + } else { + ctx.fillStyle = color; + ctx.fillRect(bx, Math.round(yTop), barW, bodyH); + } + } + } else if (style === "dots") { + const dotR = 1; + const dotStep = 8; + const colStep = 2; + const [r, g, b] = hexToRgb(vramHex); + for (let y = 0; y < h; y += dotStep) { + const f = 1 - y / h; + ctx.fillStyle = `rgba(${r},${g},${b},${(0.15 + 0.65 * f).toFixed(3)})`; + ctx.beginPath(); + for (let i = 0; i < visible; i += colStep) { + if (y < yFor(valueAt(i))) continue; + const x = xFor(i); + ctx.moveTo(x + dotR, y); + ctx.arc(x, y, dotR, 0, Math.PI * 2); + } + ctx.fill(); + } + } + + // total used line — ticker already encodes the value in each candle's edge, + // so skip the line to avoid overlapping the bars. + if (style !== "ticker") { + ctx.beginPath(); + ctx.strokeStyle = graphState.graphTotalColor || C.totalLine; + ctx.lineWidth = 1.5; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + if (smoothN > 0 && visible >= 3) { + const xs = new Float64Array(visible); + const ys = new Float64Array(visible); + for (let i = 0; i < visible; i++) { xs[i] = xFor(i); ys[i] = yFor(valueAt(i)); } + ctx.moveTo(xs[0], ys[0]); + for (let i = 1; i < visible - 1; i++) { + const mx = (xs[i] + xs[i + 1]) / 2; + const my = (ys[i] + ys[i + 1]) / 2; + ctx.quadraticCurveTo(xs[i], ys[i], mx, my); + } + ctx.lineTo(xs[visible - 1], ys[visible - 1]); + } else { + for (let i = 0; i < visible; i++) { + const x = xFor(i); + const y = yFor(valueAt(i)); + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + } + ctx.stroke(); + } + + // capacity line + ctx.strokeStyle = C.capLine; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(0, yFor(total)); + ctx.lineTo(w, yFor(total)); + ctx.stroke(); + ctx.setLineDash([]); + + // exec start/end markers — positioned by interpolating each event's time into the + // visible sample times array, so markers stay glued to the data during scrubbing + // even if pollInterval drifts between ticks. + if (history.execEvents.length) { + const sampleTimes = new Float64Array(visible); + for (let i = 0; i < visible; i++) sampleTimes[i] = historyGet(history.times, startIdx + i); + const tFirst = sampleTimes[0]; + const tLast = sampleTimes[visible - 1]; + ctx.lineWidth = 1; + ctx.setLineDash([2, 3]); + for (const evt of history.execEvents) { + if (evt.time < tFirst || evt.time > tLast) continue; + let lo = 0, hi = visible - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (sampleTimes[mid] <= evt.time) lo = mid; else hi = mid - 1; + } + const tA = sampleTimes[lo]; + const tB = lo < visible - 1 ? sampleTimes[lo + 1] : tA; + const frac = tB > tA ? (evt.time - tA) / (tB - tA) : 0; + const x = xFor(lo + frac); + ctx.strokeStyle = evt.type === "start" ? C.torch : C.gpuUtilHi; + ctx.beginPath(); + ctx.moveTo(x + 0.5, 0); + ctx.lineTo(x + 0.5, h); + ctx.stroke(); + } + ctx.setLineDash([]); + } + + // gpu line uses its own 0..100 scale, not the VRAM byte scale + if (graphState.gpuLineVisible) { + ctx.beginPath(); + ctx.strokeStyle = C.gpuUtil; + ctx.lineWidth = 1.25; + for (let i = 0; i < visible; i++) { + const y = h - (at(history.gpu_util, i) / 100) * h; + if (i === 0) ctx.moveTo(xFor(i), y); else ctx.lineTo(xFor(i), y); + } + ctx.stroke(); + } + + if (graphHover.x != null && graphHover.idx != null) { + const hi = graphHover.idx - startIdx; + if (hi >= 0 && hi < visible) { + ctx.strokeStyle = C.vram; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(graphHover.x + 0.5, 0); + ctx.lineTo(graphHover.x + 0.5, h); + ctx.stroke(); + + const dot = (x, y, fill) => { + ctx.beginPath(); + ctx.arc(x, y, 3, 0, Math.PI * 2); + ctx.fillStyle = fill; + ctx.fill(); + ctx.lineWidth = 1.5; + ctx.strokeStyle = C.graphBg; + ctx.stroke(); + }; + const totalY = yFor(total - at(history.free_vram, hi)); + dot(graphHover.x, totalY, C.totalLine); + if (graphState.gpuLineVisible) { + const gpuY = h - (at(history.gpu_util, hi) / 100) * h; + dot(graphHover.x, gpuY, C.gpuUtil); + } + } + } +} + +// Build the "Graph" submenu (style + colors + smoothness). The caller passes the +// submenu container + a redraw callback that's tied to the active panel instance. +export function buildGraphSubmenu(submenu, redrawGraph) { + const graphStyles = [ + { key: "area", label: _t("graphArea") }, + { key: "bars", label: _t("graphBars") }, + { key: "ticker", label: _t("graphTicker") }, + { key: "dots", label: _t("graphDots") }, + ]; + const items = new Map(); + function renderItems() { + for (const [k, item] of items) { + const on = k === graphState.graphStyle; + const label = graphStyles.find(s => s.key === k).label; + item.innerHTML = `${on ? "✓" : ""}${label}`; + } + } + for (const s of graphStyles) { + const item = document.createElement("div"); + item.className = "aimdo-menu-item"; + item.addEventListener("click", (e) => { + e.stopPropagation(); + graphState.graphStyle = s.key; + saveState({ graphStyle: s.key }); + renderItems(); + redrawGraph(); + }); + submenu.appendChild(item); + items.set(s.key, item); + } + renderItems(); + + // VRAM color picker + const colorRow = document.createElement("div"); + colorRow.style.cssText = `padding:6px 10px;display:flex;align-items:center;gap:8px;font-size:0.917em;`; + colorRow.addEventListener("click", (e) => e.stopPropagation()); + const colorInput = document.createElement("input"); + colorInput.type = "color"; + colorInput.value = graphState.graphVramColor || C.vram; + colorInput.style.cssText = `width:24px;height:18px;padding:0;border:none;background:transparent;cursor:pointer;`; + colorInput.addEventListener("input", () => { + graphState.graphVramColor = colorInput.value; + saveState({ graphVramColor: colorInput.value }); + redrawGraph(); + }); + const resetColor = document.createElement("span"); + resetColor.textContent = _t("reset"); + resetColor.style.cssText = `margin-left:auto;color:var(--aimdo-textDim);cursor:pointer;font-size:0.833em;`; + resetColor.addEventListener("click", () => { + graphState.graphVramColor = null; + saveState({ graphVramColor: null }); + colorInput.value = C.vram; + redrawGraph(); + }); + colorRow.appendChild(colorInput); + colorRow.appendChild(document.createTextNode(_t("vramColor"))); + colorRow.appendChild(resetColor); + submenu.appendChild(colorRow); + + // Total-line color picker + const totalColorRow = document.createElement("div"); + totalColorRow.style.cssText = colorRow.style.cssText; + totalColorRow.addEventListener("click", (e) => e.stopPropagation()); + const totalColorInput = document.createElement("input"); + totalColorInput.type = "color"; + totalColorInput.value = graphState.graphTotalColor || C.totalLine; + totalColorInput.style.cssText = colorInput.style.cssText; + totalColorInput.addEventListener("input", () => { + graphState.graphTotalColor = totalColorInput.value; + saveState({ graphTotalColor: totalColorInput.value }); + redrawGraph(); + }); + const resetTotalColor = document.createElement("span"); + resetTotalColor.textContent = _t("reset"); + resetTotalColor.style.cssText = resetColor.style.cssText; + resetTotalColor.addEventListener("click", () => { + graphState.graphTotalColor = null; + saveState({ graphTotalColor: null }); + totalColorInput.value = C.totalLine; + redrawGraph(); + }); + totalColorRow.appendChild(totalColorInput); + totalColorRow.appendChild(document.createTextNode(_t("totalColor"))); + totalColorRow.appendChild(resetTotalColor); + submenu.appendChild(totalColorRow); + + // Total-line smoothness slider + const smoothRow = document.createElement("div"); + smoothRow.style.cssText = `padding:6px 10px;display:flex;flex-direction:column;gap:4px;min-width:180px;font-size:0.833em;`; + smoothRow.addEventListener("click", (e) => e.stopPropagation()); + const smoothLabel = document.createElement("div"); + smoothLabel.style.cssText = `display:flex;justify-content:space-between;color:var(--aimdo-textDim);`; + smoothLabel.innerHTML = `${_t("smoothness")}${graphState.graphTotalSmoothness}`; + const smoothSlider = document.createElement("input"); + smoothSlider.type = "range"; + smoothSlider.min = "0"; + smoothSlider.max = "20"; + smoothSlider.step = "1"; + smoothSlider.value = String(graphState.graphTotalSmoothness); + smoothSlider.style.cssText = `width:100%;accent-color:var(--aimdo-vram);cursor:pointer;`; + const smoothValSpan = smoothLabel.querySelector(".aimdo-sm-val"); + smoothSlider.addEventListener("input", () => { + graphState.graphTotalSmoothness = parseInt(smoothSlider.value, 10); + smoothValSpan.textContent = String(graphState.graphTotalSmoothness); + saveState({ graphTotalSmoothness: graphState.graphTotalSmoothness }); + redrawGraph(); + }); + smoothRow.appendChild(smoothLabel); + smoothRow.appendChild(smoothSlider); + submenu.appendChild(smoothRow); +} diff --git a/locales/zh/nodeDefs.json b/locales/zh/nodeDefs.json index 1d64486..908208d 100644 --- a/locales/zh/nodeDefs.json +++ b/locales/zh/nodeDefs.json @@ -4670,5 +4670,14 @@ "name": "📝 系统提示词" } } + }, + "XB_MemoryVisualization": { + "display_name": "XB 📊 硬件监控面板 (Memory Visualization)", + "description": "实时显存内存监控面板——显示GPU/CPU/内存/磁盘等硬件资源使用情况。将此节点添加到工作流即可在界面右下角激活可拖拽的监控面板。", + "inputs": { + "any_input": { + "name": "任意输入(可选)" + } + } } } \ No newline at end of file diff --git a/nodes_memory_viz.py b/nodes_memory_viz.py new file mode 100644 index 0000000..8e72979 --- /dev/null +++ b/nodes_memory_viz.py @@ -0,0 +1,888 @@ +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +import logging +import asyncio +import ctypes +import os +import platform +import subprocess +import time +import torch +import server +from aiohttp import web +import psutil +import comfy.model_management +import comfy.memory_management +import comfy.model_base + + +log = logging.getLogger(__name__) + +try: + import comfy_aimdo.control +except ImportError: + comfy_aimdo = None + +# ── AMD / ROCm GPU 监控 ── +# 优先使用 LibreHardwareMonitor(跨厂商硬件监控库), +# 其次尝试 rocm-smi CLI。 +# LHM 需要 pythonnet + LibreHardwareMonitorLib.dll (net8.0) +_lhm_state = {"checked": False, "available": False, "computer": None} +_amd_state = {"checked": False, "available": False, "gpu_name": None, + "rocm_smi_path": None, + "util_cache": None, "temp_cache": None, + "power_cache": None, "cache_time": 0} + +def _init_lhm(): + """初始化 LibreHardwareMonitor (跨厂商硬件监控)""" + if _lhm_state["checked"]: + return _lhm_state["available"] + _lhm_state["checked"] = True + + try: + import pythonnet + pythonnet.load('coreclr') + import clr + + dll_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "LibreHardwareMonitorLib.dll") + if not os.path.exists(dll_path): + log.info("aimdo-viz: LibreHardwareMonitorLib.dll 未找到") + return False + + clr.AddReference(dll_path) + from LibreHardwareMonitor.Hardware import Computer + + computer = Computer() + computer.IsGpuEnabled = True + computer.Open() + + _lhm_state["computer"] = computer + _lhm_state["available"] = True + log.info("aimdo-viz: LibreHardwareMonitor 初始化成功") + return True + except Exception as e: + log.debug("aimdo-viz: LHM 初始化失败: %s", e) + _lhm_state["available"] = False + return False + +def _query_lhm(): + """通过 LibreHardwareMonitor 读取 AMD GPU 利用率/温度/功耗""" + computer = _lhm_state.get("computer") + if computer is None: + return None, None, None + + try: + util = None + temp = None + power = None + + for hw in computer.Hardware: + hw.Update() + hw_type = str(hw.HardwareType) + + # 只处理 AMD 独立显卡,跳过集成显卡 + if "GpuAmd" not in hw_type: + continue + + hw_name = str(hw.Name).lower() + # 如果是集成显卡(通常包含 "graphics" 不含具体型号标记),跳过 + # RX 7900 XTX 等独显名称包含 "rx" 或 "radeon rx" + is_dgpu = any(kw in hw_name for kw in ["rx", "7900", "7800", "7700", "7600", + "6900", "6800", "6700", "6600", + "5700", "5600", "5500", "vega", + "radeon vii", "fury"]) + + for sensor in hw.Sensors: + s_type = str(sensor.SensorType) + s_name = str(sensor.Name).lower() + s_val = sensor.Value + if s_val is None: + continue + + if s_type == "Load" and s_name == "gpu core" and is_dgpu: + util = s_val + elif s_type == "Temperature" and s_name == "gpu core" and is_dgpu: + temp = s_val + elif s_type == "Power" and s_name == "gpu package" and is_dgpu: + power = s_val # 瓦特 + + # 如果独显没找到数据,回退到任意 AMD GPU + if util is None and not is_dgpu: + for sensor in hw.Sensors: + s_type = str(sensor.SensorType) + s_name = str(sensor.Name).lower() + s_val = sensor.Value + if s_val is None: + continue + if s_type == "Load" and s_name == "gpu core" and util is None: + util = s_val + elif s_type == "Temperature" and s_name == "gpu core" and temp is None: + temp = s_val + elif s_type == "Power" and s_name == "gpu package" and power is None: + power = s_val + + # power 是瓦特,转换为毫瓦以匹配 NVIDIA API + if power is not None: + power = power * 1000 + + return util, temp, power + except Exception as e: + log.debug("aimdo-viz: LHM 查询失败: %s", e) + return None, None, None + +def _detect_amd_gpu(): + """检测是否为 AMD GPU 并初始化监控后端""" + if _amd_state["checked"]: + return _amd_state["available"] + _amd_state["checked"] = True + + if not torch.cuda.is_available(): + return False + + device = comfy.model_management.get_torch_device() + gpu_name = torch.cuda.get_device_name(device) + + is_amd = any(kw in gpu_name.lower() for kw in + ["amd", "radeon", "instinct", "mi", "rx", "vega", "navi", + "gfx", "radeon rx", "radeon pro", "firepro"]) + if not is_amd: + return False + + log.info("aimdo-viz: 检测到 AMD GPU: %s", gpu_name) + _amd_state["gpu_name"] = gpu_name + + # 方案 1: LibreHardwareMonitor (推荐,支持 RDNA 3) + if _init_lhm(): + _amd_state["available"] = True + log.info("aimdo-viz: 使用 LibreHardwareMonitor 监控 AMD GPU") + return True + + # 方案 2: rocm-smi CLI (Linux / 未来 Windows) + import shutil + candidates = [] + if platform.system() == "Windows": + candidates = [ + r"C:\Program Files\AMD\ROCm\7.1\bin\rocm-smi.exe", + r"C:\Program Files\AMD\ROCm\6.2\bin\rocm-smi.exe", + "rocm-smi.exe", + ] + else: + candidates = ["rocm-smi", "/opt/rocm/bin/rocm-smi"] + + for path in candidates: + if "*" not in path and shutil.which(path): + _amd_state["rocm_smi_path"] = path + log.info("aimdo-viz: 使用 rocm-smi: %s", path) + _amd_state["available"] = True + return True + + log.info("aimdo-viz: AMD GPU 监控不可用,仅显示 VRAM") + _amd_state["available"] = True + return True + +def _query_amd_gpu(): + """查询 AMD GPU 状态,返回 (util, temp_c, power_mw)""" + # LHM 优先 + if _lhm_state.get("available"): + now = time.time() + if now - _amd_state["cache_time"] < 2: + return _amd_state["util_cache"], _amd_state["temp_cache"], _amd_state["power_cache"] + _amd_state["cache_time"] = now + u, t, p = _query_lhm() + _amd_state["util_cache"] = u + _amd_state["temp_cache"] = t + _amd_state["power_cache"] = p + return u, t, p + + # rocm-smi 回退 + if _amd_state.get("rocm_smi_path"): + now = time.time() + if now - _amd_state["cache_time"] < 2: + return _amd_state["util_cache"], _amd_state["temp_cache"], _amd_state["power_cache"] + _amd_state["cache_time"] = now + + path = _amd_state["rocm_smi_path"] + import json as _json + try: + proc = subprocess.run( + [path, "--showuse", "--showtemp", "--showpower", "--json"], + capture_output=True, text=True, timeout=5 + ) + if proc.returncode == 0: + data = _json.loads(proc.stdout) + for card_key in data: + card = data[card_key] + try: + _amd_state["util_cache"] = int(float(card.get("GPU use (%)", "0"))) + except (ValueError, TypeError): + _amd_state["util_cache"] = None + try: + _amd_state["temp_cache"] = float(card.get("Temperature (C)", "0")) + except (ValueError, TypeError): + _amd_state["temp_cache"] = None + try: + _amd_state["power_cache"] = float(card.get("Average Graphics Package Power (W)", 0)) * 1000 + except (ValueError, TypeError): + _amd_state["power_cache"] = None + break + except Exception as e: + log.debug("aimdo-viz: rocm-smi 错误: %s", e) + + return _amd_state["util_cache"], _amd_state["temp_cache"], _amd_state["power_cache"] + +# NVML handle + power-cap cache. Cap and device name are static for a given +# driver state, so we only query them once. Handle init is best-effort; failures stick. +_nvml_state = {"handle": None, "tried": False, "power_limit": None, "gpu_name": None} + +def _resolve_nvml_handle(pynvml, device): + # NVML enumerates physical GPUs and ignores CUDA_VISIBLE_DEVICES, so the torch + # device index can point at the wrong card on multi-GPU systems. Match by UUID. + idx = device.index if device.index is not None else torch.cuda.current_device() + try: + torch_uuid = "GPU-" + str(torch.cuda.get_device_properties(idx).uuid) + for i in range(pynvml.nvmlDeviceGetCount()): + h = pynvml.nvmlDeviceGetHandleByIndex(i) + uuid = pynvml.nvmlDeviceGetUUID(h) + if (uuid.decode() if isinstance(uuid, bytes) else uuid) == torch_uuid: + return h + except Exception as e: + log.debug("aimdo-viz: nvml uuid match failed: %s", e) + return pynvml.nvmlDeviceGetHandleByIndex(idx) + +def _nvml_handle(device): + if _nvml_state["tried"]: + return _nvml_state["handle"] + _nvml_state["tried"] = True + try: + import pynvml + pynvml.nvmlInit() + _nvml_state["handle"] = _resolve_nvml_handle(pynvml, device) + except Exception as e: + log.debug("aimdo-viz: pynvml init failed: %s", e) + return _nvml_state["handle"] + +def _nvml_mem_info(device): + # On Windows WDDM, cudaMemGetInfo reports per-process memory, hiding other + # processes' VRAM. NVML is always device-wide. + h = _nvml_handle(device) + if h is None: + return None + try: + import pynvml + info = pynvml.nvmlDeviceGetMemoryInfo(h) + return info.free, info.total + except Exception as e: + log.debug("aimdo-viz: nvmlDeviceGetMemoryInfo failed: %s", e) + return None + +def _nvml_power_limit(device): + if _nvml_state["power_limit"] is not None: + return _nvml_state["power_limit"] + h = _nvml_handle(device) + if h is None: + return None + try: + import pynvml + _nvml_state["power_limit"] = pynvml.nvmlDeviceGetPowerManagementLimit(h) + return _nvml_state["power_limit"] + except Exception as e: + log.debug("aimdo-viz: nvmlDeviceGetPowerManagementLimit failed: %s", e) + return None + +def _get_lock(): + # Stored on comfy.model_management so the same lock survives hot reloads. + mm = comfy.model_management + if not hasattr(mm, '_viz_model_lock'): + mm._viz_model_lock = asyncio.Lock() + return mm._viz_model_lock + +# cached; CPU model doesn't change at runtime. +_cpu_name_cache = {"tried": False, "name": None} + +def _get_cpu_name(): + if _cpu_name_cache["tried"]: + return _cpu_name_cache["name"] + _cpu_name_cache["tried"] = True + try: + sys_name = platform.system() + if sys_name == "Windows": + import winreg + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System\CentralProcessor\0") as key: + name, _ = winreg.QueryValueEx(key, "ProcessorNameString") + _cpu_name_cache["name"] = name.strip() + elif sys_name == "Darwin": + _cpu_name_cache["name"] = subprocess.check_output( + ["sysctl", "-n", "machdep.cpu.brand_string"], timeout=2 + ).decode().strip() + elif sys_name == "Linux": + with open("/proc/cpuinfo") as f: + for line in f: + if "model name" in line: + _cpu_name_cache["name"] = line.split(":", 1)[1].strip() + break + except Exception as e: + log.debug("aimdo-viz: cpu name lookup failed: %s", e) + return _cpu_name_cache["name"] + +# Fixed drives + volume labels are static for the session — enumerate once and cache. +# Sleepy/external drives could make GetVolumeInformationW block, so do it lazily off +# the polling path (first time a client asks for the list). +_disks_cache = {"tried": False, "list": []} + +def _get_volume_label(mountpoint): + try: + if platform.system() != "Windows": + return None + buf = ctypes.create_unicode_buffer(256) + fs = ctypes.create_unicode_buffer(256) + ok = ctypes.windll.kernel32.GetVolumeInformationW( + ctypes.c_wchar_p(mountpoint), buf, 256, None, None, None, fs, 256) + if ok: + return buf.value or None + except Exception as e: + log.debug("aimdo-viz: GetVolumeInformationW failed for %s: %s", mountpoint, e) + return None + +def _get_disk_partitions(): + if _disks_cache["tried"]: + return _disks_cache["list"] + _disks_cache["tried"] = True + try: + # all=False filters CD-ROM, removable, and network on Windows + parts = [] + for p in psutil.disk_partitions(all=False): + parts.append({"mountpoint": p.mountpoint, "label": _get_volume_label(p.mountpoint)}) + _disks_cache["list"] = parts + except Exception as e: + log.debug("aimdo-viz: disk_partitions failed: %s", e) + return _disks_cache["list"] + + +# Windows reads true pagefile.sys usage via NtQuerySystemInformation +# falls back to psutil on non-Windows or on any failure. +_pagefile_state = {"tried": False, "query": None} + +def _build_win_pagefile_query(): + from ctypes import wintypes + + class UNICODE_STRING(ctypes.Structure): + _fields_ = [("Length", wintypes.USHORT), + ("MaximumLength", wintypes.USHORT), + ("Buffer", wintypes.LPWSTR)] + + class SYSTEM_PAGEFILE_INFORMATION(ctypes.Structure): + _fields_ = [("NextEntryOffset", wintypes.ULONG), + ("TotalSize", wintypes.ULONG), + ("TotalInUse", wintypes.ULONG), + ("PeakUsage", wintypes.ULONG), + ("PageFileName", UNICODE_STRING)] + + class SYSTEM_INFO(ctypes.Structure): + _fields_ = [("wProcessorArchitecture", wintypes.WORD), + ("wReserved", wintypes.WORD), + ("dwPageSize", wintypes.DWORD), + ("lpMinimumApplicationAddress", ctypes.c_void_p), + ("lpMaximumApplicationAddress", ctypes.c_void_p), + ("dwActiveProcessorMask", ctypes.POINTER(wintypes.DWORD)), + ("dwNumberOfProcessors", wintypes.DWORD), + ("dwProcessorType", wintypes.DWORD), + ("dwAllocationGranularity", wintypes.DWORD), + ("wProcessorLevel", wintypes.WORD), + ("wProcessorRevision", wintypes.WORD)] + + SystemPageFileInformation = 18 + ntdll = ctypes.WinDLL("ntdll") + ntdll.NtQuerySystemInformation.restype = wintypes.LONG + si = SYSTEM_INFO() + ctypes.windll.kernel32.GetSystemInfo(ctypes.byref(si)) + page = si.dwPageSize + buf = ctypes.create_string_buffer(8192) + ret = wintypes.ULONG(0) + + def query(): + status = ntdll.NtQuerySystemInformation(SystemPageFileInformation, buf, len(buf), ctypes.byref(ret)) + if status < 0 or ret.value == 0: + return 0, 0 + total = used = 0 + off = 0 + while True: + info = SYSTEM_PAGEFILE_INFORMATION.from_buffer(buf, off) + total += info.TotalSize + used += info.TotalInUse + if info.NextEntryOffset == 0: + break + off += info.NextEntryOffset + return total * page, used * page + + return query + +def _pagefile_usage(): + """(total_bytes, used_bytes) of system pagefiles — true on-disk usage.""" + if platform.system() == "Windows": + st = _pagefile_state + if not st["tried"]: + st["tried"] = True + try: + st["query"] = _build_win_pagefile_query() + except Exception as e: + log.debug("aimdo-viz: pagefile query init failed: %s", e) + if st["query"] is not None: + try: + return st["query"]() + except Exception as e: + log.debug("aimdo-viz: pagefile query failed: %s", e) + try: + sw = psutil.swap_memory() + return sw.total, sw.used + except Exception: + return 0, 0 + + +# Hard page faults — faults that hit disk, i.e. memory paged back in. A rising +# rate is the thrashing signal. Windows reads SYSTEM_PERFORMANCE_INFORMATION; +# Linux reads /proc/vmstat pgmajfault. Returns a monotonic count; the client +# differentiates it into faults/sec. +_hardfault_state = {"tried": False, "query": None} + +def _build_win_hardfault_query(): + from ctypes import wintypes + + class PERF_PREFIX(ctypes.Structure): + _fields_ = [("IdleProcessTime", ctypes.c_longlong), + ("IoReadTransferCount", ctypes.c_longlong), + ("IoWriteTransferCount", ctypes.c_longlong), + ("IoOtherTransferCount", ctypes.c_longlong), + ("IoReadOperationCount", wintypes.ULONG), + ("IoWriteOperationCount", wintypes.ULONG), + ("IoOtherOperationCount", wintypes.ULONG), + ("AvailablePages", wintypes.ULONG), + ("CommittedPages", wintypes.ULONG), + ("CommitLimit", wintypes.ULONG), + ("PeakCommitment", wintypes.ULONG), + ("PageFaultCount", wintypes.ULONG), + ("CopyOnWriteCount", wintypes.ULONG), + ("TransitionCount", wintypes.ULONG), + ("CacheTransitionCount", wintypes.ULONG), + ("DemandZeroCount", wintypes.ULONG), + ("PageReadCount", wintypes.ULONG), + ("PageReadIoCount", wintypes.ULONG)] + + SystemPerformanceInformation = 2 + ntdll = ctypes.WinDLL("ntdll") + ntdll.NtQuerySystemInformation.restype = wintypes.LONG + buf = ctypes.create_string_buffer(8192) + ret = wintypes.ULONG(0) + + def query(): + status = ntdll.NtQuerySystemInformation(SystemPerformanceInformation, buf, len(buf), ctypes.byref(ret)) + if status < 0 or ret.value < ctypes.sizeof(PERF_PREFIX): + return None + return PERF_PREFIX.from_buffer(buf).PageReadIoCount + + return query + +def _hard_fault_count(): + """Monotonic count of hard/major page faults, or None if unavailable.""" + sysname = platform.system() + if sysname == "Windows": + st = _hardfault_state + if not st["tried"]: + st["tried"] = True + try: + st["query"] = _build_win_hardfault_query() + except Exception as e: + log.debug("aimdo-viz: hardfault query init failed: %s", e) + if st["query"] is not None: + try: + return st["query"]() + except Exception as e: + log.debug("aimdo-viz: hardfault query failed: %s", e) + return None + if sysname == "Linux": + try: + with open("/proc/vmstat") as f: + for line in f: + if line.startswith("pgmajfault "): + return int(line.split()[1]) + except Exception as e: + log.debug("aimdo-viz: /proc/vmstat read failed: %s", e) + return None + + +def _vbar_residency(vbar, used_pages): + """Per-page residency flags for the used range only. vbar.get_residency() + builds a Python list over the whole ~10x-overallocated VBAR every poll + (hundreds of us for large models); we run the same native fill but convert + only the used slice. Falls back to the full read on any binding mismatch.""" + try: + lib = comfy_aimdo.control.lib + nr = vbar.get_nr_pages() + buf = (ctypes.c_uint8 * nr)() + lib.vbar_get_residency(vbar._devctx, vbar._ptr, buf, nr) + return buf[:max(0, min(used_pages, nr))] + except Exception as e: + log.debug("aimdo-viz: bounded residency read failed, using full: %s", e) + try: + return vbar.get_residency()[:used_pages] + except Exception: + return [] + + +def _detect_model_type(model_obj): + """Classify a loaded model into a ComfyUI slot type so the UI can color it + consistently with node connection colors. Returns None when nothing matches + so the UI falls back to the default text color rather than mislabeling.""" + try: + if isinstance(model_obj, comfy.model_base.BaseModel): + return "model" + except Exception: + pass + cls = model_obj.__class__ + name = cls.__name__.lower() + module = (cls.__module__ or "").lower() + # order matters: clip_vision before clip, style_model before model substring matches. + if "clipvision" in name or "clip_vision" in name or "clip_vision" in module: + return "clip_vision" + if "controlnet" in name or "t2iadapter" in name or "controlnet" in module: + return "controlnet" + if "stylemodel" in name or "style_model" in name: + return "style_model" + if "gligen" in name: + return "gligen" + if "vae" in name or "autoencod" in name or module.endswith(".vae"): + return "vae" + if "clip" in name or "t5" in name or "textencoder" in name or "text_encoders" in module: + return "clip" + if "esrgan" in name or "upscal" in name or "rrdb" in name or "spandrel" in module: + return "upscale_model" + return None +routes = server.PromptServer.instance.routes + +@routes.get("/aimdo/vram") +async def aimdo_vram_status(request): + device = comfy.model_management.get_torch_device() + if not torch.cuda.is_available() or device.type != "cuda": + return web.json_response({"enabled": False}) + + aimdo_active = getattr(comfy.memory_management, 'aimdo_enabled', False) and comfy_aimdo is not None + + models = [] + loaded_models = list(comfy.model_management.current_loaded_models) + for model_idx, lm in enumerate(loaded_models): + patcher = lm.model + if patcher is None: + continue + + model_obj = patcher.model + if model_obj is None: + continue + + name = model_obj.__class__.__name__ + is_dynamic = patcher.is_dynamic() + total_size = patcher.model_size() + loaded = patcher.loaded_size() + + # RAM side: pinned host memory used for fast transfers, plus + # non-pinned loaded host memory when the patcher exposes it. + pinned_ram = 0 + try: + if hasattr(patcher, 'pinned_memory_size'): + pinned_ram = patcher.pinned_memory_size() + except Exception as e: + log.debug("aimdo-viz: pinned_memory_size failed: %s", e) + loaded_ram = 0 + try: + if hasattr(patcher, 'loaded_ram_size'): + loaded_ram = max(0, patcher.loaded_ram_size() - pinned_ram) + except Exception as e: + log.debug("aimdo-viz: loaded_ram_size failed: %s", e) + + # VBAR state per device (aimdo only) + vbars = [] + vbar_loaded_total = 0 + + if aimdo_active and is_dynamic and hasattr(model_obj, "dynamic_vbars"): + for dev, vbar in model_obj.dynamic_vbars.items(): + try: + loaded_bytes = vbar.loaded_size() + vbar_loaded_total += loaded_bytes + page_size = getattr(vbar, 'page_size', 32 * 1024 * 1024) + vbar_offset = getattr(vbar, 'offset', 0) + if vbar_offset > 0: + used_pages = (vbar_offset + page_size - 1) // page_size + else: + used_pages = (total_size + page_size - 1) // page_size + vbars.append({ + "device": str(dev), + "loaded": loaded_bytes, + "watermark": vbar.get_watermark(), + "residency": _vbar_residency(vbar, used_pages), + }) + except Exception as e: + log.warning("aimdo-viz: VBAR query failed: %s", e) + + entry = { + "index": model_idx, + "name": name, + "type": _detect_model_type(model_obj), + "total_size": total_size, + "loaded_size": loaded, + "vbar_loaded": vbar_loaded_total, + "ram_size": max(0, total_size - vbar_loaded_total), + "pinned_ram": pinned_ram, + "loaded_ram": loaded_ram, + "dynamic": is_dynamic, + "vbars": vbars, + } + + models.append(entry) + + has_dynamic = any(m.get("dynamic") for m in models) + aimdo_usage = comfy_aimdo.control.get_total_vram_usage() if aimdo_active and has_dynamic else 0 + + # prefer NVML (device-wide on every driver model) and fall back to cudaMemGetInfo + # — the latter under-reports on Windows WDDM by hiding other processes' VRAM. + _mem = _nvml_mem_info(device) + if _mem is not None: + free_cuda, total_vram = _mem + else: + free_cuda, total_vram = torch.cuda.mem_get_info(device) + + try: + gpu_util = torch.cuda.utilization(device) + except Exception: + gpu_util = None + + try: + gpu_temp = torch.cuda.temperature(device) + except Exception: + gpu_temp = None + + try: + gpu_power = torch.cuda.power_draw(device) # mW + except Exception: + gpu_power = None + gpu_power_limit = _nvml_power_limit(device) # mW + + # ── AMD/ROCm 回退方案 ── + # 当 NVIDIA 专有 API 返回 None 时(AMD 显卡),尝试 rocm-smi + if _detect_amd_gpu() and (gpu_util is None or gpu_temp is None): + amd_util, amd_temp, amd_power = _query_amd_gpu() + if gpu_util is None and amd_util is not None: + gpu_util = amd_util + if gpu_temp is None and amd_temp is not None: + gpu_temp = amd_temp + if gpu_power is None and amd_power is not None: + gpu_power = amd_power + + gpu_name = _nvml_state["gpu_name"] + if gpu_name is None: + h = _nvml_handle(device) + if h is not None: + try: + import pynvml + name = pynvml.nvmlDeviceGetName(h) + gpu_name = _nvml_state["gpu_name"] = name.decode() if isinstance(name, bytes) else name + except Exception as e: + log.debug("aimdo-viz: nvmlDeviceGetName failed: %s", e) + if gpu_name is None: + try: + gpu_name = _nvml_state["gpu_name"] = torch.cuda.get_device_name(device) + except Exception: + pass + # AMD 回退:torch.cuda.get_device_name 在 ROCm 下可用 + if gpu_name is None and _amd_state.get("gpu_name"): + gpu_name = _amd_state["gpu_name"] + + # non-blocking; first call after process start returns 0, subsequent calls are real + try: + cpu_util = psutil.cpu_percent(interval=None) + except Exception: + cpu_util = None + + # pytorch internal stats + stats = torch.cuda.memory_stats(device) + torch_active = stats.get('active_bytes.all.current', 0) + torch_reserved = stats.get('reserved_bytes.all.current', 0) + + ram = psutil.virtual_memory() + proc = psutil.Process() + + process_ram = proc.memory_info().rss + swap_total = swap_used = 0 + if request.query.get("pagefile") == "1": + swap_total, swap_used = _pagefile_usage() + + hard_faults = None + if request.query.get("faults") == "1": + hard_faults = _hard_fault_count() + + disk_read = disk_write = None + if request.query.get("disk") == "1": + try: + d = psutil.disk_io_counters() + if d is not None: + disk_read, disk_write = d.read_bytes, d.write_bytes + except Exception: + pass + + # client opts in by sending list_disks=1; backend skips entirely when absent. + disks_list = None + if request.query.get("list_disks") == "1": + disks_list = [] + for p in _get_disk_partitions(): + entry = {"mountpoint": p["mountpoint"], "label": p["label"], "total": None, "free": None} + try: + du = psutil.disk_usage(p["mountpoint"]) + entry["total"] = du.total + entry["free"] = du.free + except Exception as e: + log.debug("aimdo-viz: disk_usage(%s) failed: %s", p["mountpoint"], e) + disks_list.append(entry) + + total_pinned = sum(m.get("pinned_ram", 0) for m in models) + total_loaded_ram = sum(m.get("loaded_ram", 0) for m in models) + + return web.json_response({ + "enabled": True, + "aimdo_active": aimdo_active, + "total_vram": total_vram, + "free_vram": free_cuda, + "gpu_util": gpu_util, + "gpu_temp": gpu_temp, + "gpu_power": gpu_power, + "gpu_power_limit": gpu_power_limit, + "gpu_name": gpu_name, + "cpu_util": cpu_util, + "cpu_name": _get_cpu_name(), + "aimdo_usage": aimdo_usage, + "torch_active": torch_active, + "torch_reserved": torch_reserved, + "total_ram": ram.total, + "used_ram": ram.used, + "total_swap": swap_total, + "used_swap": swap_used, + "hard_faults": hard_faults, + "disk_read": disk_read, + "disk_write": disk_write, + "disks": disks_list, + "process_ram": process_ram, + "pinned_ram": total_pinned, + "loaded_ram": total_loaded_ram, + "models": models, + }) + +@routes.post("/aimdo/unload_all") +async def aimdo_unload_all(request): + if _is_executing(): + return web.json_response({"error": "cannot unload during execution"}, status=409) + async with _get_lock(): + await asyncio.get_running_loop().run_in_executor(None, comfy.model_management.unload_all_models) + return web.json_response({"status": "ok"}) + + +def _is_executing(): + return bool(server.PromptServer.instance.prompt_queue.currently_running) + +def _get_model_idx(data): + idx = data.get("index") + if not isinstance(idx, int) or isinstance(idx, bool): + return None, web.json_response({"error": "missing or invalid index"}, status=400) + models = comfy.model_management.current_loaded_models + if idx < 0 or idx >= len(models): + return None, web.json_response({"error": "index out of range"}, status=400) + return idx, None + +@routes.post("/aimdo/reset_watermark") +async def aimdo_reset_watermark(request): + idx, err = _get_model_idx(await request.json()) + if err: + return err + async with _get_lock(): + torch.cuda.empty_cache() + models = comfy.model_management.current_loaded_models + if idx >= len(models): + return web.json_response({"error": "model no longer at index"}, status=409) + patcher = models[idx].model + if patcher is not None and hasattr(patcher, '_vbar_get'): + vbar = patcher._vbar_get() + if vbar is not None: + vbar.prioritize() + return web.json_response({"status": "ok"}) + +@routes.post("/aimdo/unload_model") +async def aimdo_unload_model(request): + if _is_executing(): + return web.json_response({"error": "cannot unload during execution"}, status=409) + idx, err = _get_model_idx(await request.json()) + if err: + return err + async with _get_lock(): + models = comfy.model_management.current_loaded_models + if idx >= len(models): + return web.json_response({"error": "model no longer at index"}, status=409) + models[idx].model_unload() + models.pop(idx) + comfy.model_management.soft_empty_cache() + return web.json_response({"status": "ok"}) + + +# ============================================================ +# XB_MemoryVisualization — 显存内存观察节点 +# ============================================================ +class XB_MemoryVisualization: + """显存内存实时监控面板节点 + + 【重要说明】 + 监控面板会在 ComfyUI 启动时自动加载到界面右下角,无需将此节点添加到工作流。 + 面板提供可拖拽的硬件监控窗口,功能包括: + - VRAM/内存使用率分段条形图 + - GPU利用率/温度/功耗实时显示 + - 磁盘I/O读写速率 + - 页面文件使用情况 + - CPU使用率 + - 已加载模型列表及显存占用详情 + - 历史VRAM使用量趋势图(面积图/柱状图/K线图/点阵图) + - 模型卸载、缓存清理快捷按钮 + + 此节点的作用: + - 作为工作流中的"锚点",方便快速定位监控面板 + - 可通过右键菜单 → "字号"调节面板字体大小 + - 可通过右键菜单 → "缩放"调节面板整体缩放 + - 执行此节点不会影响工作流,仅作为标记使用 + """ + + @classmethod + def INPUT_TYPES(s): + return { + "required": {}, + "optional": { + "any_input": ("*", { + "tooltip": "可选任意输入——面板启动时自动显示,此输入仅用于触发节点执行" + }), + } + } + + RETURN_TYPES = () + FUNCTION = "show_panel" + CATEGORY = "XB_ToolBox/Dashboard" + OUTPUT_NODE = True + DESCRIPTION = "实时显存内存监控面板——启动时自动显示在界面右下角,可拖拽/缩放/调节字号。右键面板查看更多选项。" + + def show_panel(self, any_input=None, **kwargs): + import torch + if torch.cuda.is_available(): + free, total = torch.cuda.mem_get_info() + free_gb = free / (1024**3) + total_gb = total / (1024**3) + used_gb = total_gb - free_gb + print(f"[XB 硬件监控] GPU显存: {used_gb:.1f}GB / {total_gb:.1f}GB (空闲 {free_gb:.1f}GB)") + else: + print("[XB 硬件监控] 未检测到CUDA GPU,面板仅显示CPU/内存信息") + return () + + +NODE_CLASS_MAPPINGS["XB_MemoryVisualization"] = XB_MemoryVisualization +NODE_DISPLAY_NAME_MAPPINGS["XB_MemoryVisualization"] = "XB 📊 硬件监控面板 (Memory Visualization)" diff --git a/requirements.txt b/requirements.txt index 94e2d81..cdcd2fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,12 @@ easyocr # OCR 文字识别 (漫画工具) onnxruntime>=1.18.0 # ONNX 推理 (人像分割 + CosyVoice3) imageio-ffmpeg # FFmpeg 回退 (视频服务器) +# ── 硬件监控面板依赖 (Memory Visualization) ── +psutil # CPU/内存/磁盘系统信息 +pythonnet # .NET 桥接 (LibreHardwareMonitor GPU 监控) +# LibreHardwareMonitorLib.dll 已内置在工具箱目录中,无需额外下载 +# NVIDIA GPU: 可选安装 nvidia-ml-py 以获得更精准的显存数据 + # ── Llama 节点依赖 (LLM/VLM 本地推理) ── diskcache # 磁盘缓存 gguf # GGUF 模型格式解析